Write your own adapter — Mimeo docs
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.
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:
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: 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.
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 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:
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