Outbound SMTP relay integration (Lettermint + SMTP2GO behind OutboundRelay interface)
closedGoal
Configure each cell’s Stalwart to send outbound through a third-party SMTP relay, with DNS delegated to that relay, behind a provider abstraction so Lettermint and SMTP2GO can both be used and compared for deliverability.
Background
Eval bug 05830c8 (comment 02528c3) proved both providers expose the full per-domain loop via API: create domain → get DNS records → verify → send via SMTP relay → bounce/spam feedback via webhook + poll fallback. Sign-at-relay-only decided (Stalwart dkimSignDomain=false, cell DKIM keys dormant). Target scale: dozens of hosted domains, low volume (~10k/mo).
Prior art in repo:
- Stalwart control plane: internal/stalwart/client.go (JMAP Registry/set only, no REST CRUD), docs/05-stalwart-integration.md.
- DNS readiness: internal/domain/readiness.go (MX + DKIM TXT gate), internal/domain/zonefile.go, internal/dnsprober/.
- Config: config.go (StalwartConfig, MailConfig, secret-file pattern with ValidateSecrets; secrets via ansible-vault → 0600 host files, docs/deployment.md:131-138).
- Relay runbooks: docs/runbooks/relay-drain-verify.md, docs/deployment.md:71-74 (outbound = single warm relay; MtaRoute Relay + MtaOutboundStrategy, name-miss falls back to direct MX).
Scope
- OutboundRelay interface (new package, e.g.
internal/relay/): provider abstraction with methods likeRegisterDomain,DomainRecords/CheckDNS(verify + poll status),RemoveDomain,DomainStatus. Implementations for Lettermint (api.lettermint.co/v1, Team token) and SMTP2GO (eu-api.smtp2go.com/v3, API key) conform to it. Fakes for tests. - sovrnd config: select outbound relay + keys. New config section (provider choice, API base URLs, SMTP relay host/port, credential
SecretFiles following the existing secret-file pattern, never env-plaintext). Wire env bindings + defaults +ValidateSecretscoverage. - Cell Stalwart wiring: per-cell
MtaRoute Relay(address/port/auth per provider; 587 STARTTLSimplicitTls:false/ 465 implicittrue) +MtaOutboundStrategy: IF is_local_domain(rcpt_domain) local ELSE <relay>viaRegistry/set+Action::ReloadSettings; setdkimSignDomain=false. SMTP creds: Lettermint fixed-userlettermint+ project token (one project/token per cell or route-scoped), SMTP2GO per-cell SMTP user viaPOST /users/smtp/add. - DNS delegation: feed provider records from
RegisterDomaininto the domain flow (internal/domain+ readiness gate): Lettermint = DMARC TXT +lm1/lm2._domainkeyCNAME + bounce CNAME (no SPF change); SMTP2GO = DKIM CNAME + return-path CNAME (+ optional tracking CNAME). UpdateProbeReadinessexpectations; keep merged SPF under lookup limits. - Webhook processor behind the same interface: common event format (bounce hard/soft, spam complaint, unsubscribe, delivered/failed/reject) + HTTP handler with per-provider signature/auth verification; poll fallback (
GET /messages/{id}/events/POST /activity/search, suppressions APIs) for missed webhooks.
Acceptance
- [ ] Interface + both provider impls + fakes; unit/integration tests green.
- [ ] sovrnd selects relay via config; startup fails loudly on missing secret files.
- [ ] Dev cell sends through relay (seed inboxes show single relay DKIM-Signature, SPF/DMARC pass, correct return-path); name-miss→direct-MX fallback explicitly tested.
- [ ] Bounce + spam-complaint round-trip verified (webhook received in common format).
- [ ] DNS record plan per hosted domain documented; no prod credentials in repo.
Non-goals
Self-hosted relay-host build (separate follow-up), marketing/broadcast mail, full IP-warmup study, per-tier pricing automation.
3 Comments
Outbound SMTP Relay Integration Implementation Plan
Goal: Wire each cell’s Stalwart to send outbound through a selectable third-party relay (Lettermint or SMTP2GO) behind one
OutboundRelayinterface, with DNS delegation and webhook feedback in a common format.Architecture: New
internal/relaypackage owns the provider interface plus both implementations and a fake (mirroring thepdsprovisioner.Provisioner+Fakepattern);config.gogains aRelaysection using the existing secret-file pattern; Stalwart outbound is driven through the existingstalwart.ClientRegistry/setpath; webhooks arrive on a new unauthenticated top-mux route with per-provider signature verification (same placement rationale asGET /internal/caddy-askinrouter.go:167).Tech Stack: Go 1.26.5, stdlib
net/http+encoding/json, existinginternal/stalwartclient, viper config, ansible-vault secrets.Locked decisions: fleet-wide vault for relay creds (
vault-shared.yml); live tests env-gated (SOVRN_LIVE_RELAY=1), manual, CI hermetic; subagent-driven execution.File map
internal/relay/relay.goDNSRecord,DomainStatus,Event/EventType,Relayinterface,SMTPRoutedescriptorinternal/relay/fake.gointernal/relay/relay_test.gointernal/relay/lettermint.go+lettermint_test.gointernal/relay/smtp2go.go+smtp2go_test.gointernal/relay/webhook.go+webhook_test.goconfig.goRelayConfig, defaults,bindEnv,ValidateSecretsrouter.gointernal/relay/stalwart.goMtaRoute Relay+MtaOutboundStrategy+dkimSignDomain=falseviaRegistry/set+ReloadSettingsinternal/dnsprober/prober.go+internal/domain/readiness.gointernal/verifier/verifier.goRegisterDomain/CheckDomainin the sweep (behind interface, no-op when relay unconfigured)deployment/inventory/group_vars/all/sovrn.ymldeployment/roles/sovrn_secrets/tasks/main.ymlInterface (locked — Task 1 defines exactly this)
Task 1: Interface + fake + conformance test
Files: - Create:
internal/relay/relay.go- Create:internal/relay/fake.go- Test:internal/relay/relay_test.go[]Event, and a compile-time assertion pins every future impl to the interface:go test ./internal/relay/ -run TestFakeLifecycle -v→ FAIL (undefined: Fake).relay.go(types + interface exactly as locked above) andfake.go(map-backed store;CheckDomainreturns verified;ParseWebhookdecodes the tiny JSON shape used in the test).go test ./internal/relay/ -v→ PASS.jj commit -m "feat(relay): OutboundRelay interface with fake" internal/relay/relay.go internal/relay/fake.go internal/relay/relay_test.goTask 2: Lettermint implementation (httptest only, no live calls)
Files: - Create:
internal/relay/lettermint.go- Test:internal/relay/lettermint_test.goEndpoint map (from eval comment
02528c3):POST /domains→GET /domains/{id}?include=dnsRecords,projects→POST /domains/{id}/dns-records/verify→PUT /domains/{id}/projects; webhooksPOST /webhooks. Auth:Authorization: Bearer <team-token>.httptest.Serverreturning cannedDomainData(two CNAME records + DMARC TXT) and assertingRegisterDomainmaps them to[]DNSRecordwithRequired=true,CheckDomainmapsstatus:"verified"→StatusVerified, andRoute()returns{smtp.lettermint.co, 587, false, "lettermint"}.go test ./internal/relay/ -run TestLettermint -v→ FAIL.lettermint.go(NewLettermint(baseURL, teamToken, projectID string); stdlib client, 30s timeout likeinternal/stalwart/client.go:49; map non-2xx to typed errors including body snippet).jj commit -m "feat(relay): Lettermint provider" ...Task 3: SMTP2GO implementation (httptest only)
Files: - Create:
internal/relay/smtp2go.go- Test:internal/relay/smtp2go_test.goEndpoint map:
POST /domain/add→POST /domain/view(polldkim_verified/rpath_verified/cname_verified) →POST /domain/verify; webhooksPOST /webhook/add; fallbackPOST /activity/search. Auth headerX-Smtp2go-Api-Key. Base URL configurable (defaulthttps://eu-api.smtp2go.com/v3); SMTP hostmail-eu.smtp2go.com./domain/addresponse (dkim_selector/dkim_value/rpath_selector/rpath_value) → assert CNAME mapping;Route()returns{mail-eu.smtp2go.com, 587, false, <smtp-user>}where the SMTP username comes from config (Task 4).jj commit -m "feat(relay): SMTP2GO provider" ...Task 4: sovrnd config + secret-file wiring
Files: - Modify:
config.go(addRelayConfignext toMailConfig), defaults,bindEnv,ValidateSecrets- Test: extend config test coverage (TestRelayConfigSecrets)Defaults:
provider "", Lettermintapibaseurl https://api.lettermint.co/v1,smtphost smtp.lettermint.co,smtpport 587,smtpusername lettermint; env bindingsrelay.provider,relay.apibaseurl, …relay.smtpsecretfile.ValidateSecrets: whenprovider != "", both secret files must be non-empty (existence/readability checked at startup inrouter.govia the existingreadSecrethelper,router.go:254-266).go test . -run TestRelay -vPASS →jj commit -m "feat(config): outbound relay selection + secret files" config.go ...Task 5: Cell Stalwart outbound wiring
Files: - Create:
internal/relay/stalwart.go(functionApplyOutbound(ctx, stalwart.Client, SMTPRoute, secret string)) - Test: httptest JMAP fake asserting the exactRegistry/setmethod sequenceSequence (first step is a schema check, not code): confirm
MtaRoute(Relay variant:address/port/protocol/implicitTls/authUsername/authSecret) andMtaOutboundStrategy+SenderAuth/dkimSignDomainobject shapes against live/api/schemain dev (devenv up, Stalwart on:8080), since noMtaRouteusage exists in-repo yet (grep confirms onlyDkimSignature/Domain/Accountare used).ApplyOutboundperforms:x:MtaRoute/set(name"sovrn-relay") →x:MtaOutboundStrategy/set(route expressionIF is_local_domain(rcpt_domain) local ELSE sovrn-relay) →x:SenderAuth/set(dkimSignDomain: false) →x:Action/set({"@type":"ReloadSettings"}), following the two-phase pattern indocs/05-stalwart-integration.md:35. Test asserts the method sequence and that a name-miss surfaces an error rather than silently falling back to direct MX.jj commit -m "feat(relay): Stalwart smarthost route via Registry" ...Task 6: Webhook endpoint + signature verification
Files: - Create:
internal/relay/webhook.go(providerParseWebhookbodies: Lettermint HMAC-secret verification; SMTP2GO basic/bearer auth-header check) +webhook_test.go- Modify:router.go:144-148(mountPOST /internal/relay/webhook/{provider}on the unauthenticatedtopmux — providers call from the internet, so auth is per-request signature, not session; same placement rationale as caddy-ask)[]Eventwith correctType/Hard/Recipient/MessageIDfor bounce, spam, unsubscribe, delivered on both providers.jj commit -m "feat(relay): common webhook events + endpoint" ...Task 7: DNS delegation + readiness gate
Files: - Modify:
internal/dnsprober/prober.go:50-66(expectedRecords: append relay CNAMEs as required records when relay configured),internal/domain/readiness.go:56-60(CNAME exact-match handling alongside DKIM TXT),internal/verifier/verifier.go:42-80(callRegisterDomainon first sweep forverifyingdomains when relay enabled; gate activation on relayCheckDomain == verified)Keep the change additive: when
relay.provider == "", behavior is byte-identical to today (existing tests must pass unmodified — rungo test ./internal/domain/ ./internal/dnsprober/ ./internal/verifier/ -v).jj commit -m "feat(relay): relay DNS records in readiness gate" ...Task 8: Live verification (manual, nothing committed)
Gated, operator-run, using the test accounts. Keys come from the secrets store and never touch the repo:
TestLive*tests skip unlessSOVRN_LIVE_RELAY=1(so CI stays hermetic). Walkthrough per provider on a scratch subdomain: register → capture records → publish → verify → poll to verified → remove. Record exact payloads/purposes back onto this bug as a comment, then proceed to a dev-cell send (seed inboxes: single relay DKIM-Signature, SPF/DMARC pass, correct return-path) and a bounce round-trip.Task 9: Ansible vault + secrets ceremony support
secrets decrypt lettermint_api_key/smtp2go_api_key→ paste intodeployment/inventory/group_vars/all/vault-shared.ymlas new vars (e.g.sovrn_relay_api_secret,sovrn_relay_smtp_secret),ansible-vault encrypt, commit ciphertext.deployment/inventory/group_vars/all/sovrn.yml— non-secret relay vars (sovrn_relay_provider, hosts/ports/usernames,sovrn_relay_api_secretfile/smtp_secretfileunder{{ sovrn_secrets_dir }});deployment/roles/sovrn_secrets/tasks/main.yml— assert + install the two 0600 files (copy the OIDC-key block at lines 28-35). Verify withcd deployment && ansible-playbook playbooks/site.yml --check.Implementation complete (code) — operator steps remain
All 9 plan tasks implemented, reviewed (spec + quality per task, plus final whole-tree review), full
go test ./...green. Changes (jj, bottom-up): interface+fake, Lettermint, SMTP2GO, config, Stalwart smarthost route, webhooks, readiness gate, deploy vars, gated live tests, plus a final wiring-gaps fix.What landed: -
internal/relay: lockedRelayinterface (RegisterDomain/CheckDomain/RemoveDomain/Route/ParseWebhook), Lettermint + SMTP2GO impls (httptest-covered),Fake,ApplyOutbound(MtaRoutesovrn-relay+ outbound strategy +dkimSignDomain=false+ reload; restart-tolerant on existing route), signed webhook parsing (Lettermint Svix-style, SMTP2GO auth-header), fail-closedPOST /internal/relay/webhook/{provider}(503 untilrelay.webhooksecretfilefollow-up wires secrets), TTL-cached verifier integration with check-before-register and reap cleanup. -config.go+sovrn.toml.j2+sovrn.yml+sovrn_secretsrole: fleet-wide relay selection + 0600 secret files; relay-off behavior byte-identical to before. -internal/relay/live_test.go: env-gated domain-cycle tests (skip withoutSOVRN_LIVE_RELAY=1).Known caveats (documented in code): Stalwart MtaRoute/strategy/expression payloads derived from 0.16.19 docs, NOT yet confirmed against a live server — the live run must verify; MtaRoute create-only (strategy update proceeds on re-apply).
Operator next (not done by agent): 1. Vault the test keys:
secrets decrypt lettermint_api_key/secrets decrypt smtp2go_api_key→sovrn_relay_api_secretinvault-shared.yml(+ SMTP secrets: Lettermint project token / SMTP2GO SMTP-user password →sovrn_relay_smtp_secret),ansible-vault encrypt, commit. For smtp2go also overridesovrn_relay_apibaseurl=https://eu-api.smtp2go.com/v3,sovrn_relay_smtphost=mail-eu.smtp2go.com,sovrn_relay_smtpusername=<smtp user>. 2. Live domain cycle:SOVRN_LIVE_RELAY=1 SOVRN_LIVE_DOMAIN=<scratch-subdomain> SOVRN_LIVE_LETTERMINT_KEYFILE=/tmp/lm.key go test ./internal/relay/ -run TestLiveLettermintDomainCycle -v(same for SMTP2GO), publish the logged DNS records between register and verify, then record payloads here. 3. Dev-cell send + bounce round-trip; confirm single relay DKIM-Signature, SPF/DMARC pass.Live SMTP2GO verification — PASS
Direct SMTP AUTH send from the command line using the vaulted fleet credentials (secret staged to a 0600 temp file, shredded afterwards; value never displayed):
This validates the committed smtp2go config end to end: EU endpoint, STARTTLS mode, SMTP username, vaulted SMTP password, and verified-sender acceptance. Remaining follow-ups live elsewhere: relay.webhooksecretfile wiring (webhook endpoint currently 503 fail-closed), live confirmation of Stalwart MtaRoute expression shapes, Task 8 scratch-domain API cycle.