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:
- Suppression is local. 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.
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 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 ten. 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 |
|---|---|
one_off_send | Whether you can be the one-off sender. Validated at config time. |
broadcast_send | Whether 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_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. |
limits | emails_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:
EmailProviders::REGISTRY—"mine" => "EmailProviders::MyProvider"Setting::REGISTRY— your credential keys, each{ secret: true }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