Outbound SMTP relay integration (Lettermint + SMTP2GO behind OutboundRelay interface)

closed
#941e9ad opened by agent Sep 15

Goal

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

  1. OutboundRelay interface (new package, e.g. internal/relay/): provider abstraction with methods like RegisterDomain, 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.
  2. 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 + ValidateSecrets coverage.
  3. Cell Stalwart wiring: per-cell MtaRoute Relay (address/port/auth per provider; 587 STARTTLS implicitTls:false / 465 implicit true) + MtaOutboundStrategy: IF is_local_domain(rcpt_domain) local ELSE <relay> via Registry/set + Action::ReloadSettings; set dkimSignDomain=false. SMTP creds: Lettermint fixed-user lettermint + project token (one project/token per cell or route-scoped), SMTP2GO per-cell SMTP user via POST /users/smtp/add.
  4. DNS delegation: feed provider records from RegisterDomain into the domain flow (internal/domain + readiness gate): Lettermint = DMARC TXT + lm1/lm2._domainkey CNAME + bounce CNAME (no SPF change); SMTP2GO = DKIM CNAME + return-path CNAME (+ optional tracking CNAME). Update ProbeReadiness expectations; keep merged SPF under lookup limits.
  5. 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

agent 994417e Sep 15

Outbound SMTP Relay Integration Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Wire each cell’s Stalwart to send outbound through a selectable third-party relay (Lettermint or SMTP2GO) behind one OutboundRelay interface, with DNS delegation and webhook feedback in a common format.

Architecture: New internal/relay package owns the provider interface plus both implementations and a fake (mirroring the pdsprovisioner.Provisioner + Fake pattern); config.go gains a Relay section using the existing secret-file pattern; Stalwart outbound is driven through the existing stalwart.Client Registry/set path; webhooks arrive on a new unauthenticated top-mux route with per-provider signature verification (same placement rationale as GET /internal/caddy-ask in router.go:167).

Tech Stack: Go 1.26.5, stdlib net/http + encoding/json, existing internal/stalwart client, 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

File Responsibility
Create internal/relay/relay.go DNSRecord, DomainStatus, Event/EventType, Relay interface, SMTPRoute descriptor
Create internal/relay/fake.go In-memory fake for all tests
Create internal/relay/relay_test.go Interface-conformance + fake behavior tests
Create internal/relay/lettermint.go + lettermint_test.go Team API impl (httptest-based, no live calls)
Create internal/relay/smtp2go.go + smtp2go_test.go v3 API impl (httptest-based)
Create internal/relay/webhook.go + webhook_test.go Common event parsing + signature verification per provider
Modify config.go RelayConfig, defaults, bindEnv, ValidateSecrets
Modify router.go Construct relay from config, mount webhook route, fail loud on missing secrets
Create internal/relay/stalwart.go MtaRoute Relay + MtaOutboundStrategy + dkimSignDomain=false via Registry/set + ReloadSettings
Modify internal/dnsprober/prober.go + internal/domain/readiness.go Relay CNAME expectations in readiness gate
Modify internal/verifier/verifier.go Trigger relay RegisterDomain/CheckDomain in the sweep (behind interface, no-op when relay unconfigured)
Modify deployment/inventory/group_vars/all/sovrn.yml Non-secret relay vars (provider, hosts, ports, file paths)
Modify deployment/roles/sovrn_secrets/tasks/main.yml Materialize relay secret files (0600), following the OIDC-key pattern
Live check (manual, no commit) Scratch-subdomain walkthrough against both test accounts

Interface (locked — Task 1 defines exactly this)

package relay

type DNSRecord struct {
    Name     string // owner, e.g. "lm1._domainkey.example.com"
    Type     string // "TXT" | "CNAME"
    Value    string // rdata / target
    Required bool   // gates sending readiness
}

type DomainStatus string
const (
    StatusPending  DomainStatus = "pending"
    StatusVerified DomainStatus = "verified"
    StatusFailed   DomainStatus = "failed"
)

type EventType string
const (
    EventDelivered EventType = "delivered"
    EventBounce    EventType = "bounce" // Hard bool distinguishes hard/soft
    EventSpam      EventType = "spam"
    EventUnsub     EventType = "unsubscribe"
    EventFailed    EventType = "failed"
)

type Event struct {
    Type      EventType
    Hard      bool
    Domain    string // sender domain the event belongs to
    Recipient string
    MessageID string // provider message id, "" if absent
    Reason    string
    At        time.Time
}

// SMTPRoute is what Stalwart needs: everything except the secret itself.
type SMTPRoute struct {
    Address  string // e.g. "smtp.lettermint.co"
    Port     int    // 587 (STARTTLS) or 465 (implicit TLS)
    Implicit bool   // implicitTls flag for the MtaRoute object
    Username string // "lettermint" or per-cell SMTP2GO user
}

type Relay interface {
    Name() string // "lettermint" | "smtp2go"
    RegisterDomain(ctx context.Context, domain string) ([]DNSRecord, error)
    CheckDomain(ctx context.Context, domain string) (DomainStatus, []DNSRecord, error)
    RemoveDomain(ctx context.Context, domain string) error
    Route() SMTPRoute
    ParseWebhook(r *http.Request) ([]Event, error) // verifies signature/auth, maps to []Event
}

Task 1: Interface + fake + conformance test

Files: - Create: internal/relay/relay.go - Create: internal/relay/fake.go - Test: internal/relay/relay_test.go

  • [ ] Step 1: Write the failing test — fake registers a domain, returns its records, transitions pending→verified on check, parses a canned webhook into []Event, and a compile-time assertion pins every future impl to the interface:
package relay

import (
    "context"
    "net/http/httptest"
    "strings"
    "testing"
)

var _ Relay = (*Fake)(nil) // every impl gets this line in its own _test.go

func TestFakeLifecycle(t *testing.T) {
    f := NewFake()
    ctx := context.Background()
    recs, err := f.RegisterDomain(ctx, "example.com")
    if err != nil {
        t.Fatalf("register: %v", err)
    }
    if len(recs) == 0 {
        t.Fatal("expected DNS records")
    }
    st, _, err := f.CheckDomain(ctx, "example.com")
    if err != nil {
        t.Fatalf("check: %v", err)
    }
    if st != StatusVerified {
        t.Fatalf("status = %q, want verified", st)
    }
    req := httptest.NewRequest("POST", "/internal/relay/webhook/fake",
        strings.NewReader(`{"type":"bounce","hard":true,"recipient":"[email protected]"}`))
    evs, err := f.ParseWebhook(req)
    if err != nil {
        t.Fatalf("webhook: %v", err)
    }
    if len(evs) != 1 || evs[0].Type != EventBounce || !evs[0].Hard {
        t.Fatalf("events = %+v", evs)
    }
    if err := f.RemoveDomain(ctx, "example.com"); err != nil {
        t.Fatalf("remove: %v", err)
    }
}
  • [ ] Step 2: Run test, verify it fails: go test ./internal/relay/ -run TestFakeLifecycle -v → FAIL (undefined: Fake).
  • [ ] Step 3: Implement relay.go (types + interface exactly as locked above) and fake.go (map-backed store; CheckDomain returns verified; ParseWebhook decodes the tiny JSON shape used in the test).
  • [ ] Step 4: Re-run: go test ./internal/relay/ -v → PASS.
  • [ ] Step 5: Commit: jj commit -m "feat(relay): OutboundRelay interface with fake" internal/relay/relay.go internal/relay/fake.go internal/relay/relay_test.go

Task 2: Lettermint implementation (httptest only, no live calls)

Files: - Create: internal/relay/lettermint.go - Test: internal/relay/lettermint_test.go

Endpoint map (from eval comment 02528c3): POST /domains → GET /domains/{id}?include=dnsRecords,projects → POST /domains/{id}/dns-records/verify → PUT /domains/{id}/projects; webhooks POST /webhooks. Auth: Authorization: Bearer <team-token>.

  • [ ] Step 1: Write failing test with httptest.Server returning canned DomainData (two CNAME records + DMARC TXT) and asserting RegisterDomain maps them to []DNSRecord with Required=true, CheckDomain maps status:"verified" → StatusVerified, and Route() returns {smtp.lettermint.co, 587, false, "lettermint"}.
  • [ ] Step 2: go test ./internal/relay/ -run TestLettermint -v → FAIL.
  • [ ] Step 3: Implement lettermint.go (NewLettermint(baseURL, teamToken, projectID string); stdlib client, 30s timeout like internal/stalwart/client.go:49; map non-2xx to typed errors including body snippet).
  • [ ] Step 4: PASS. Step 5: jj commit -m "feat(relay): Lettermint provider" ...

Task 3: SMTP2GO implementation (httptest only)

Files: - Create: internal/relay/smtp2go.go - Test: internal/relay/smtp2go_test.go

Endpoint map: POST /domain/add → POST /domain/view (poll dkim_verified/rpath_verified/cname_verified) → POST /domain/verify; webhooks POST /webhook/add; fallback POST /activity/search. Auth header X-Smtp2go-Api-Key. Base URL configurable (default https://eu-api.smtp2go.com/v3); SMTP host mail-eu.smtp2go.com.

  • [ ] Steps mirror Task 2: canned /domain/add response (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).
  • [ ] Commit: jj commit -m "feat(relay): SMTP2GO provider" ...

Task 4: sovrnd config + secret-file wiring

Files: - Modify: config.go (add RelayConfig next to MailConfig), defaults, bindEnv, ValidateSecrets - Test: extend config test coverage (TestRelayConfigSecrets)

// RelayConfig selects the outbound SMTP relay. Secrets are files, never
// values: APISecretFile holds the Lettermint team token or SMTP2GO API key,
// SMTPSecretFile holds the SMTP auth password (Lettermint project token or
// SMTP2GO SMTP-user password). Empty Provider disables the relay (dev default).
type RelayConfig struct {
    Provider       string `mapstructure:"provider"`
    APIBaseURL     string `mapstructure:"apibaseurl"`
    APISecretFile  string `mapstructure:"apisecretfile"`
    SMTPHost       string `mapstructure:"smtphost"`
    SMTPPort       int    `mapstructure:"smtpport"`
    SMTPImplicit   bool   `mapstructure:"smtpimplicit"`
    SMTPUsername   string `mapstructure:"smtpusername"`
    SMTPSecretFile string `mapstructure:"smtpsecretfile"`
}

Defaults: provider "", Lettermint apibaseurl https://api.lettermint.co/v1, smtphost smtp.lettermint.co, smtpport 587, smtpusername lettermint; env bindings relay.provider, relay.apibaseurl, … relay.smtpsecretfile. ValidateSecrets: when provider != "", both secret files must be non-empty (existence/readability checked at startup in router.go via the existing readSecret helper, router.go:254-266).

  • [ ] Failing test → implement → go test . -run TestRelay -v PASS → jj commit -m "feat(config): outbound relay selection + secret files" config.go ...

Task 5: Cell Stalwart outbound wiring

Files: - Create: internal/relay/stalwart.go (function ApplyOutbound(ctx, stalwart.Client, SMTPRoute, secret string)) - Test: httptest JMAP fake asserting the exact Registry/set method sequence

Sequence (first step is a schema check, not code): confirm MtaRoute (Relay variant: address/port/protocol/implicitTls/authUsername/authSecret) and MtaOutboundStrategy + SenderAuth/dkimSignDomain object shapes against live /api/schema in dev (devenv up, Stalwart on :8080), since no MtaRoute usage exists in-repo yet (grep confirms only DkimSignature/Domain/Account are used).

ApplyOutbound performs: x:MtaRoute/set (name "sovrn-relay") → x:MtaOutboundStrategy/set (route expression IF is_local_domain(rcpt_domain) local ELSE sovrn-relay) → x:SenderAuth/set (dkimSignDomain: false) → x:Action/set ({"@type":"ReloadSettings"}), following the two-phase pattern in docs/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.

  • [ ] Commit: jj commit -m "feat(relay): Stalwart smarthost route via Registry" ...

Task 6: Webhook endpoint + signature verification

Files: - Create: internal/relay/webhook.go (provider ParseWebhook bodies: Lettermint HMAC-secret verification; SMTP2GO basic/bearer auth-header check) + webhook_test.go - Modify: router.go:144-148 (mount POST /internal/relay/webhook/{provider} on the unauthenticated top mux — providers call from the internet, so auth is per-request signature, not session; same placement rationale as caddy-ask)

  • [ ] Tests: tampered payload → rejection error; valid fixtures → []Event with correct Type/Hard/Recipient/MessageID for bounce, spam, unsubscribe, delivered on both providers.
  • [ ] Commit: 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 (call RegisterDomain on first sweep for verifying domains when relay enabled; gate activation on relay CheckDomain == verified)

Keep the change additive: when relay.provider == "", behavior is byte-identical to today (existing tests must pass unmodified — run go test ./internal/domain/ ./internal/dnsprober/ ./internal/verifier/ -v).

  • [ ] Commit: 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:

secrets decrypt lettermint_api_key > /tmp/lm.key && chmod 600 /tmp/lm.key
secrets decrypt smtp2go_api_key > /tmp/s2g.key && chmod 600 /tmp/s2g.key
SOVRN_LIVE_RELAY=1 go test ./internal/relay/ -run 'TestLive' -v  # scratch subdomain only; test removes it
shred -u /tmp/lm.key /tmp/s2g.key

TestLive* tests skip unless SOVRN_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

  • Operator (outside the agent): secrets decrypt lettermint_api_key / smtp2go_api_key → paste into deployment/inventory/group_vars/all/vault-shared.yml as new vars (e.g. sovrn_relay_api_secret, sovrn_relay_smtp_secret), ansible-vault encrypt, commit ciphertext.
  • Agent work: deployment/inventory/group_vars/all/sovrn.yml — non-secret relay vars (sovrn_relay_provider, hosts/ports/usernames, sovrn_relay_api_secretfile/smtp_secretfile under {{ 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 with cd deployment && ansible-playbook playbooks/site.yml --check.
agent 904610e Sep 15

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: locked Relay interface (RegisterDomain/CheckDomain/RemoveDomain/Route/ParseWebhook), Lettermint + SMTP2GO impls (httptest-covered), Fake, ApplyOutbound (MtaRoute sovrn-relay + outbound strategy + dkimSignDomain=false + reload; restart-tolerant on existing route), signed webhook parsing (Lettermint Svix-style, SMTP2GO auth-header), fail-closed POST /internal/relay/webhook/{provider} (503 until relay.webhooksecretfile follow-up wires secrets), TTL-cached verifier integration with check-before-register and reap cleanup. - config.go + sovrn.toml.j2 + sovrn.yml + sovrn_secrets role: 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 without SOVRN_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_secret in vault-shared.yml (+ SMTP secrets: Lettermint project token / SMTP2GO SMTP-user password → sovrn_relay_smtp_secret), ansible-vault encrypt, commit. For smtp2go also override sovrn_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.

agent 9a4a1ae Sep 16

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):

  • Host/port/TLS: mail-eu.smtp2go.com:2525, STARTTLS (matches sovrn_relay_smtpimplicit=false)
  • AUTH: 235 Authentication succeeded (user sovrn)
  • SEND: [email protected] -> [email protected] accepted, zero refused recipients
  • Delivery confirmed in the recipient inbox by the operator.

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.