Theme

Write your own adapter

A sending adapter is delivery and suppression infrastructure and nothing else. Three ship — Bento, Postmark and Resend — and this is how to write a fourth. 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 instance:

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.

module EmailProviders
  class MyProvider < Base
    def capabilities
      {
        one_off_send: true,
        broadcast_send: false,
        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,
        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 Resultsuccess(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 ten. A missing key is a question nobody answered rather than an answer, and the conformance suite fails you for it. Say false.

KeyWhat core does with it
one_off_sendWhether you can be the one-off sender. Validated at config time.
broadcast_sendWhether you can be the broadcast sender. false means broadcasts fan out through deliver instead — which is fine, and means they get a full report.
custom_headersWhether the headers: key on a message means anything to you.
injects_unsubscribe_headersWhether you add your own RFC 8058 headers. If you don't, and you take custom headers, core sends you ours — see below.
send_time_suppressionWhether 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_webhooksWhether you push suppression events, and therefore whether handle_webhook matters.
push_unsubscribeWhether an unsubscribe here is mirrored to you.
remote_deleteWhether GDPR erase can remove the address on your side, or has to tell the operator to do it by hand.
limitsemails_per_minute is what the sender's pacer counts against. Omit it and you are unpaced.

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.

Webhooks

Your whole job on the way in is turning your provider's vocabulary into ours. Nothing above the seam ever sees a provider's own event names.

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 the instance 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.

gap_notes

A plain-language list of what you can't do and how core covers it, rendered on the Settings page. This is capability transparency, and it's worth writing properly: it's what an operator reads when they're deciding whether your adapter is safe for their operation.

Certification

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

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:

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 · Webhooks