Contribute a built-in adapter
This guide shows how to contribute email adapter Go code to ReputeCast: a built-in adapter for a sending provider, compiled into the binary, tested against the shared contract suite and released with every version. If you only need your own instance to reach a provider, a generic HTTP mapping or a community adapter is quicker; provider adapters compares the three.
Before you begin
Section titled “Before you begin”- A checkout of the ReputeCast repository and Go 1.27 (the toolchain downloads automatically). Run ReputeCast from source sets up
make dev. - golangci-lint v2, for
make lint. - The provider’s API documentation, its current acceptable-use policy, and sample webhook payloads from its documentation.
docs/dev/CONVENTIONS.mdin the repository, which every change follows.
Write the adapter
Section titled “Write the adapter”-
Read the contract in
internal/providers/provider.go. It is read-only for adapter authors. Every adapter implementsproviders.Provider:Method Does Adapter() stringThe registry key, like "mailgun".Capabilities() CapabilitiesThe badges: bulk policy, webhooks, domain API, suppression sync, batch size, dedicated IPs, sandbox, quota and rate discovery, pricing, tracking. Send(ctx, msgs) ([]SendResult, error)Sends at most BatchSizemessages and returns one result per message, in input order.ParseWebhook(ctx, r, body) ([]Event, error)Verifies the request and maps its payload to normalized events. Then implement every optional interface the provider’s API really supports. Core code type-asserts before using them, and the screens explain the missing ones:
Interface Used for Verifier,QuotaReporterThe connect step and the health job (every 5 minutes, every minute while the provider is down). Quotawins when both exist.ReputationReporterThe provider’s own bounce and complaint rates, which its review thresholds apply to. DomainManagerDNS records with live status for the domain step and Deliverability. WebhookRegistrar,WebhookUnregistrarRegistering ReputeCast’s webhook, and removing it when the provider is removed. WebhookInspector,WebhookTesterThe “webhooks silent” diagnosis and “Send test event”. WebhookVerifier,WebhookHandshakerVerifying before ingest stores a request; answering handshakes (SNS subscriptions, Event Grid validation) without storing them. WebhookSecretsNaming headers that carry a shared webhook secret as it is, so ingest leaves them out of the stored raw event. SuppressionSyncerTwo-way sync with the shared suppression list. Return providers.ErrNotSupportedfromAddSuppressionsfor pull only.TrackingManagerReading and turning off provider-side tracking, so opens and clicks aren’t counted twice. EventBackfillerFetching message status when webhooks went silent. StatusPage,StatusReporterThe status button and incident text during an outage. BroadcastStreamCreatorProviders whose terms want a separate stream for bulk mail (Postmark). ExtrasSenderSending copies and attachments; a message with extras only goes to a provider that has it. -
Create the package.
internal/providers/resendis a compact example to follow:Directoryinternal/providers/example/
- example.go package comment with the provider’s terms, Descriptor, settings, credentials, New, Send
- webhook.go VerifyWebhook, ParseWebhook, normalization
- account.go Verify or Quota, domains, webhook registration, suppressions, tracking, backfill
- lang.go the message catalog and its messages (text for people)
Directorymessages/
- en.json the English people read
- vi.json its translation
- example_test.go fake API, contracttest.Run, adapter-specific tests
Directorytestdata/ recorded webhook payloads
- …
-
Write the descriptor, register it from
init, and fill its words from the adapter’s message catalog:internal/providers/example/example.go //go:embed messages/*.jsonvar messagesFS embed.FSvar msgs = i18n.MustLoad(messagesFS, "providers.example")func init() { providers.Register(Descriptor()) }func Descriptor() providers.Descriptor {return providers.Descriptor{Adapter: "example",Name: "Example ESP",Kind: "api", // api, smtp, self_hosted, httpCapabilities: providers.Capabilities{Bulk: providers.BulkAllowed, // cite the AUP in the package commentWebhooks: "Registered",DomainAPI: "full",SuppressionSync: true,BatchSize: 1,Sandbox: "mode",QuotaDiscovery: true,// Pricing and DedicatedIPs are in messages/en.json.},Version: version.Version,Maintainer: "HostRepute",Terms: []providers.Terms{{Bulk: providers.BulkAllowed,Streams: []providers.Stream{providers.StreamBroadcast, providers.StreamAutomation, providers.StreamSystem}}},Fields: []providers.Field{{Key: "api_key", Secret: true, Required: true},{Key: "region", Options: []string{"us", "eu"}},},New: New,}.WithMessages(msgs)}The words people read go in
messages/en.json, with anotewhere a translator needs one, and their translation inmessages/vi.json, with the terms ofdocs/i18n/glossary-vi.md:internal/providers/example/messages/en.json {"pricing": { "message": "plan, from $10 per month", "note": "on a capability chip: Pricing · …" },"terms.0.note": { "message": "Opted-in lists only…", "note": "what the terms allow, on Routing" },"events": "Webhooks","domainSetup": "API","suppressionMode": "Two-way","field.api_key.label": "API key","field.api_key.help": { "message": "Example › Settings › API keys.", "note": "Example's own menus" },"field.region.label": "Region"}The badges must be true for the provider today:
- Bulk:
BulkAllowed,BulkBroadcastStreams(only on a dedicated stream),BulkForbiddenorBulkYourPolicy(self-hosted). Quote the provider’s acceptable-use policy in the package comment with its URL and the date you checked it, and put the conditions that matter inTerms.Note(theterms.0.notemessage): the routing screen shows it. - Sandbox:
detection,test token,modeortest addresses; empty when there is none. - Quota and rate discovery: set them only when an API call reports them.
- Pricing and dedicated IPs: short summaries in the
pricinganddedicatedIpsmessages; prefer a plan name to a price that will go stale. - Tracking: set
RewritesLinksorAddsOpenPixelwhen the provider tracks by default and the adapter can’t turn it off per message. - The
events,domainSetupandsuppressionModemessages are the words the Adapters tab shows, like “SNS webhooks”, “API” and “Two-way”.
- Bulk:
-
Write the text people read. What an adapter tells people reads in each reader’s language: server text follows the reader’s app language (
i18n.LangFrom(ctx)), and API clients that send noAccept-Languageread English. Catalogs are global, so core code reads a kept message without importing the adapter.- The descriptor’s words.
WithMessages(msgs)fills the descriptor from well-known keys:field.<key>.labelandfield.<key>.help,terms.<n>.variantandterms.<n>.note(the nthTerms, from 0),pricing,dedicatedIps, and the Adapters tab’sevents,domainSetupandsuppressionMode. Logic keeps comparing the English. - What the adapter writes at runtime keeps its message beside the English:
Quota.AccountText(the account’s standing),ConnectionReport.SummaryText,ProvisionStep.LabelText,ProvisionResult.DoneText,Tracking.DetailTextandScopeText,ServiceStatus.SummaryText, andCallError.Textfor an error the adapter words itself. Return the English as before and the message withmsg.Keep(args); most adapters have asay(m, args)helper inlang.gothat returns both. Pass numbers as numbers and times astime.Time, so{n, number}and{at, time}follow the reader’s region format. - Names stay as they are: the provider’s name and its own words for its plans, keys, menus and stream types, its answers (error codes, API messages), event names, logs and internal errors.
- Stored text. Code that keeps an adapter’s text for later readers (a provider’s health, a setup’s state, an incident) stores the English and the message. A row written before its message was kept reads in English.
SMS adapters (
internal/sms/<name>) work the same way: their catalog’s namespace issms.<name>,Manifest.WithMessagesreads the keyssummary,badge.<capability>.note,field.<key>.labelandfield.<key>.help, andinternal/sms/contracttestchecks the texts. - The descriptor’s words.
-
Handle settings and credentials.
providers.Config.Settingsholds the non-secret fields;Config.Credentialsholds the decrypted secrets as a flat JSON object keyed by theSecretfields’ keys. Credentials are write-only: sealed at rest, never returned by the API, replaced one key at a time. Mark every field whose key looks secret (password,token,secret, or ending inapi_key) asSecret, or the contract suite fails.Newmust not do network I/O and must not fail because an optional value is missing; report missing credentials from the first call. The factory builds an adapter per request, so keep anything worth reusing in a package-level cache keyed by the account. Keep signing material read from the provider’s API in awebhookauth.Material, which reads it again when a signature fails (at most once a minute) and every hour. -
Implement
Send:- Return a per-message
SendErrorfor a refusal that concerns one message (an invalid address, an unverified sender). Return an error for an account or service failure, wrappingproviders.ErrUnauthorized(rejected credentials) orproviders.ErrUnavailable(outage).apiclientdoes this mapping:Dofor JSON calls,Execfor form or multipart bodies, signed requests and response headers. 401 and 403 become rejected credentials, 429 throttling, 408 and 5xx outages;apiclient.MessageErrorturns the other 4xx answers into a permanentSendError. - For one call per message, use
apiclient.Fanout: it keeps results in order, starts no new call after an account-level failure, and gives unsent messages a temporary error so the engine retries them. - Echo the ReputeCast message id in whatever the provider returns on events (custom args, tags, metadata), and send the
X-ReputeCast-Messageheader. - Where the provider supports idempotency, derive the key from the message id, so a retry after a timeout never sends twice.
- Turn the provider’s open and click tracking off on every message: ReputeCast tracks itself.
- Never mark an accepted message as failed. If the provider accepts it but returns no id, use the ReputeCast id as the provider id.
- Return a per-message
-
Verify and normalize webhooks:
VerifyWebhookmust return an error wrappingproviders.ErrBadSignaturefor every failure, including “couldn’t fetch the key”. Ingest stores a request with any other error as verified.ParseWebhookverifies unlessproviders.SignatureVerified(ctx)is set. The normalization job sets it when it re-parses a stored payload, which no longer has the original URL or theAuthorizationheader.- Use the scheme the provider signs with;
internal/providers/webhookauthhas Standard Webhooks, HMAC helpers and constant-time comparisons. Where the provider doesn’t sign, register the webhook with basic auth or a bearer token carryingConfig.WebhookSecret, which travels inAuthorizationand is never stored. - Bound the signed timestamp: five minutes with
webhookauth.Fresh, or a window that spans the provider’s retries withwebhookauth.Within. - Map every delivery-relevant event to
delivered,deferred,hard_bounce,soft_bounce,complaint,rejectedorunsubscribe. Keep the provider’s type inOriginalType, its code inOriginalCode(split it withbounce.Split), the rest inDiagnostic, and setBounceClasswithbounce.Classify. Lowercase the recipient, keep the raw JSON inRaw, and return no event for things that aren’t outcomes (sent, processed, auto-replies, test pings). - Don’t suppress on a guess: a permanent failure caused by the sender (a spam or reputation block, retries running out) is a soft bounce. Only an address problem is a hard bounce, because hard bounces and complaints go on the shared suppression list.
-
Test against the contract suite. Every adapter passes
internal/providers/contracttest, which checks registration, capabilities, a send, a permanent rejection, an outage and rejected credentials against your fake API, and every recorded webhook: verified, normalized, re-parsed with a verified context, and refused once tampered with. ItsTextssubtest checks the text people read: a built-in adapter of kindapi,smtp,self_hostedorhttpkeeps a catalog, every text it declares has a message whose English is that text, and every message is translated into each app language.contracttest.CheckTextchecks a text the adapter writes at runtime.internal/providers/example/example_test.go func TestContract(t *testing.T) {contracttest.Run(t, contracttest.Suite{Adapter: example.Adapter,API: fakeAPI, // reads env.Mode(): "", contracttest.ModeOutage, contracttest.ModeAuthNew: newProvider,Delivered: func(t *testing.T, env *contracttest.Env, msgs []providers.Message, res []providers.SendResult) {// check env.Requests(): headers, the echoed message id, tracking off},Webhooks: []contracttest.Webhook{{Name: "HardBounce",Request: signed("testdata/bounce.json"), // signed at test time with a test keyWant: []providers.Event{{Type: providers.EventHardBounce, Recipient: "john@example.com", OriginalCode: "550 5.1.1", BounceClass: "mailbox_unknown"}},}},// Tamper defaults to flipping a body byte; override it when the// signature doesn't cover the body or auth is a header.})}Tests use no network: run a fake of the provider’s API, record webhook payloads from the provider’s documentation as fixtures, and sign them in the test with a test key. Where the provider publishes a signature test vector, test against it. Test the optional interfaces, the error mapping and every quirk you code around (ids above 2^53, a field that is a string or an array, a 400 that carries per-message results).
-
Register the adapter in the build:
- Add a blank import of the package to
internal/app/wire_adapters.go. Registering the same key twice panics at startup. - Add the key to
adapterOrderininternal/providers/manage/adapters.go(the Adapters tab order) and tocatalogOrderininternal/connect/catalog.go(the setup catalog order). - Optionally, give the connect wizard its wording in
copyTableininternal/connect/copy.go, with the words asadapter.<key>.*messages ininternal/connect/messages/en.jsonandvi.json; without an entry, the wizard uses the descriptor’s name and fields. - Built-in adapters carry the ReputeCast version (
version.Version) and the maintainer “HostRepute”.
- Add a blank import of the package to
Check that it worked
Section titled “Check that it worked”-
Run the adapter’s tests and the linter:
Terminal go test ./internal/providers/example/golangci-lint run ./internal/providers/...make testruns every Go and web test, against the development Postgres when it is running;make lintruns every linter. -
Start the app with
make dev, sign in, and open Sending providers, then Adapters. The adapter is listed under built-in adapters with the versiondevand the maintainer HostRepute, and Sending providers, then Add provider offers it in the catalog.
Troubleshooting
Section titled “Troubleshooting”- The server panics with
providers: duplicate adapter <key>: another adapter already registers that key. Choose a uniqueAdapterkey. - The contract suite fails a connection field: its key looks like a secret (
password,token,secret,…api_key). SetSecret: true. - The contract suite’s
Textstest says the adapter “keeps no catalog”: the descriptor doesn’t load its messages. Embedmessages/*.json, load them withi18n.MustLoad, and endDescriptor()with.WithMessages(msgs). Textssays a text “has no message” or that the catalog “isn’t translated into Tiếng Việt”: add the key tomessages/en.jsonwith the English the adapter uses, or its translation tomessages/vi.json.- A tampered webhook passes, or a refused one is stored as verified:
VerifyWebhookreturned an error that doesn’t wrapproviders.ErrBadSignature. - Stored webhooks fail when they are normalized:
ParseWebhookverifies again althoughproviders.SignatureVerified(ctx)is set; the stored request has noAuthorizationheader. - Events don’t match their messages: the provider doesn’t echo the ReputeCast message id. Pass it in the provider’s metadata, tags or custom args, and send the
X-ReputeCast-Messageheader. - Opens or clicks are counted twice: the provider still tracks. Turn its tracking off on every message, or implement
TrackingManagerto turn it off for the account.
Related
Section titled “Related”- Provider adapters explains the three kinds of adapters and how to choose one.
- Delivery events and suppression sync describes the normalized events your adapter produces.
- Write a community adapter builds an adapter that runs in its own process instead.
- Run ReputeCast from source covers
make deps,make dev,make testandmake lint.