# Write your own adapter

A sending adapter is delivery and suppression infrastructure and nothing else. Two ship — Postmark and Resend — and this is how to write a third. The conformance suite, not the adapter count, is what certifies it.

## What core already does, so you don't

An adapter is deliberately small, because everything that isn't delivery stays on your Mimeo:

- **Suppression is local.** Your Mimeo's database is the authority. A standing guard checks it before every single send, so provider-side suppression is defense in depth and nothing more.
- **Compiled HTML is provider-independent.** Liquid renders locally, against operator-owned layouts.
- **Tracking is ours.** Opens and clicks go through our own routes, into one dataset. Turn the provider's own tracking off.
- **Unsubscribe links are ours.** The link in the body is always generated locally and attributed to the exact send.
- **Pacing is ours.** Declare your limits and the sender respects them.

The minimum viable adapter is therefore `deliver` plus capability declarations. Core fills every gap: no webhooks means polling or guard-only; no suppression enforcement means the standing guard is already the authority; no bulk pipeline means broadcasts fan out through `deliver`.

## The contract

Inherit from `EmailProviders::Base`, in `app/services/email_providers/my_provider.rb`. Two methods are required; every other one already answers with a failure Result unless you override it.

```ruby
module EmailProviders
  class MyProvider < Base
    def capabilities
      {
        custom_headers: true,
        injects_unsubscribe_headers: false,
        send_time_suppression: false,
        suppression_read: :none,        # :bulk | :per_address | :none
        suppression_webhooks: true,
        engagement_webhooks: false,
        push_unsubscribe: false,
        remote_delete: false,
        verified_identities: false,
        limits: { emails_per_request: 100, emails_per_minute: 600 }
      }
    end

def deliver(messages, transactional: false)
      # messages: [{ to:, from:, subject:, html_body:, headers: }]  
      success(provider_message_id: response["id"], raw: response)
    rescue MyClient::Error => e
      SyncHealth.record_error(:my_provider, e.message)
      failure(e.message)
    end
  end
end
```

Every method returns a `Result` — `success(data)` or `failure(message)`. **Never raise.** Core is built to fill a gap when it's told about one, and an exception is not being told.

### Capabilities are promises core plans around

Declare all nine. A missing key is a question nobody answered rather than an answer, and the conformance suite fails you for it. Say `false`.

| Key | What core does with it |
| --- | --- |
| `custom_headers` | Whether the `headers:` key on a message means anything to you. |
| `injects_unsubscribe_headers` | Whether you add your own RFC 8058 headers. If you don't, and you take custom headers, core sends you ours — see below. |
| `send_time_suppression` | Whether you filter sends against your own list. Display only; the local guard has already run. |
| `suppression_read` | `:per_address` makes the nightly suppression sync poll you. `:none` skips it. |
| `suppression_webhooks` | Whether you push suppression events, and therefore whether `handle_webhook` matters. |
| `push_unsubscribe` | Whether an unsubscribe here is mirrored to you. |
| `remote_delete` | Whether GDPR erase can remove the address on your side, or has to tell the operator to do it by hand. |
| `verified_identities` | Whether you can list what your provider will accept as a From identity. Declaring it opts you into send-time domain enforcement — see below. |
| `limits` | `emails_per_minute` is what the sender's pacer counts against, floored by the account-wide ceiling (Settings → Sending). Omit it and the account ceiling alone sets the pace. |

### Headers

If you declare `custom_headers: true` and `injects_unsubscribe_headers: false`, every message arrives with a `headers:` hash carrying `List-Unsubscribe` and `List-Unsubscribe-Post`. Pass them through. If you inject your own, you'll be sent none — a message carrying two is worse than one carrying yours.

Transactional mail carries no unsubscribe header, because you can't opt out of your own password reset. That's core's decision; you just pass through what arrives.

### Verified identities

If your provider verifies sending identities — DKIM domains, sender signatures, anything it refuses to send without — declare `verified_identities: true` and implement the method:

```ruby
def verified_identities
  # What the provider will accept as a From identity, normalized.
  success(
    domains: [ "example.com" ],       # a domain covers every address on it
    addresses: [ "ada@other.com" ]    # an address covers only itself
  )
end
```

Declaring the capability opts your adapter into [send-time domain enforcement](/content/docs/usage/domains-and-senders.html): every subscriber send is checked against a defined sender on a connected, provider-verified domain, and mail that fails the check holds in the queue until it passes. An adapter without the capability sends unrestricted — which is the right answer for a provider (or a test double) that has no concept of verification.

Core stores your answer as a cached snapshot, refreshed when the operator presses **Verify connection** or **Check now** — the per-send check reads the snapshot and never calls you. So return the full current list every time; don't diff, don't paginate lazily, don't cache on your side.

### Webhooks

Your whole job on the way in is turning your provider's vocabulary into ours.

```ruby
def verify_webhook(request)
  # Prove it's really from your provider. The Base default is a shared secret in
  # the URL, which is all a provider that can't sign anything can offer. If yours
  # signs, check the signature — and don't fall back to the weaker check.
end

def handle_webhook(request)
  event = EmailProviders::WebhookEvent.new(
    type: "bounced",                  # delivered | bounced | complained | unsubscribed | suppressed
    provider_message_id: data["id"],  # so it can find the exact send
    email: data["to"],
    occurred_at: Time.zone.parse(data["at"]),
    bounce_kind: "hard",              # "soft" is a try-again, not a dead address
    raw: payload
  )
  WebhookIngestor.call(key, [ event ])
  success(handled: true)
end
```

**Assume at-least-once and unordered**, whatever your provider promises. `WebhookIngestor` is built for it — every write is set-if-not-already, and a late `delivered` never overwrites a bounce that already arrived — but only if you hand it normalized events and let it do the writing.

Anything your provider reports that isn't one of the five internal types: drop it, and answer `success(handled: false)`. Retrying a no-op helps nobody.

### Registering it

Three edits:

1. `EmailProviders::REGISTRY` — `"mine" => "EmailProviders::MyProvider"`
2. `Setting::REGISTRY` — your credential keys, each `{ secret: true }`
3. `Settings::ProviderController::CREDENTIAL_KEYS` — so the Settings page draws the fields

Credentials are entered on the Settings page and encrypted in your Mimeo's database. Never Rails credentials, never ENV — a self-hosted operator should be able to change providers without a deploy.

Implement `verify_config` too. It's what the **Verify** **connection** button calls, and it should fail loudly on the conditions that would make every send fail — an unverified sending domain, say — rather than reporting a good connection the operator finds out about later.

## Certification

The same suite that runs against the shipping adapters runs against yours:

```bash
bin/rails mimeo:adapter:conformance[EmailProviders::MyProvider]
```

That covers the contract layer with no network at all: declarations complete and correctly typed, unsupported operations answering rather than raising, pacing following your declaration, headers going only where they belong, declarations matching what you actually implemented.

For the behavioral layer — send shape, failure handling, webhook normalization and idempotency — write a test file, because only you can supply the HTTP stubs and your provider's own payloads:

```ruby
class MyProviderConformanceTest < ActiveSupport::TestCase
  include AdapterConformance

def adapter = EmailProviders::MyProvider.new
  def conformance_setup = Setting.set("provider.mine.api_key", "test")
  def stub_send = stub_request(:post, "https://api.mine.com/send").to_return(…)
  def stub_send_failure = stub_request(:post, "https://api.mine.com/send").to_return(status: 500)
  def webhook_payloads = { "bounced" => { … } }
  def webhook_request(payload) = …
end
```

`test/services/email_providers/conformance_test.rb` is the working example: three adapters, one suite. A green run is what says it's safe to point a real audience at.

See also: [Sending providers](/content/docs/providers/index.html) · [Webhooks](/content/docs/api/webhooks.html)
