mail: the domain walk — one path, two publishers
The record set is identical for a platform and a custom domain; only who writes it differs, so this is one code path that either publishes or emits. Suffix matching is label-aware so a lookalike domain is never mistaken for a zone CARLOS owns. A failed publish returns before the record is written, so a re-run repairs rather than finding a record that claims absent DNS.
2 files changed,
+295
−0
internal/mail/walk.go+144 −0internal/mail/walk_domain_test.go+151 −0
diff --git a/internal/mail/walk.go b/internal/mail/walk.go| new file mode 100644 |
| index 000000000..315982eee |
| --- /dev/null |
| +++ b/internal/mail/walk.go |
| @@ -0,0 +1,144 @@ |
| +// The walks: the provisioning sequences every verb trusts. Each is |
| +// check-then-create, so each is re-runnable after a half-failure — the |
| +// same discipline internal/objstore/walk.go follows, for the same reason. |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var ( |
| + // ErrNotDeclared is a provisioning verb for an app whose member never |
| + // asked for email. The operator's verb never invents the declaration: |
| + // the env prefix and the From address are the member's to choose. |
| + ErrNotDeclared = errors.New("mail: app has not declared email") |
| + // ErrNoDomains is a provisioning verb with no VERIFIED domain. A |
| + // credential with no identity would be a principal that can send |
| + // nothing, which is worse than no credential — it looks provisioned. |
| + ErrNoDomains = errors.New("mail: no verified sending domain") |
| + // ErrNotProvisioned is a rotation or pause aimed at an app that was |
| + // never provisioned. These verbs edit an existing credential estate; |
| + // they never create one. |
| + ErrNotProvisioned = errors.New("mail: app has no provisioned credential") |
| +) |
| + |
| +// Deployment is the per-deployment configuration the walks need. |
| +// |
| +// DMARCRua is the aggregate-report mailto target and may be empty, in which |
| +// case no rua is published (spec §7). SNSTopicARN receives SES events; empty |
| +// means no configuration set is created and no counts arrive, which is a |
| +// degraded deployment rather than a broken one. |
| +type Deployment struct { |
| + Region string |
| + PlatformSuffix string // e.g. "oncarlos.com" — the zone CARLOS owns |
| + DMARCRua string |
| + SNSTopicARN string |
| +} |
| + |
| +// DomainKindFor decides who publishes DNS for a domain. |
| +// |
| +// The suffix match is LABEL-AWARE: "evil-oncarlos.com" ends with |
| +// "oncarlos.com" as a string but is a different zone entirely, and treating |
| +// it as ours would have the walk try to write records into somebody else's |
| +// domain. Only an exact match or a true subdomain counts. |
| +func DomainKindFor(d Deployment, domain string) string { |
| + if d.PlatformSuffix == "" { |
| + return KindCustom |
| + } |
| + if domain == d.PlatformSuffix || strings.HasSuffix(domain, "."+d.PlatformSuffix) { |
| + return KindPlatform |
| + } |
| + return KindCustom |
| +} |
| + |
| +// EnsureDomain brings a sending domain into existence for an app. |
| +// |
| +// It is one code path for both kinds because the record SET is identical; |
| +// only the publisher differs. For a platform domain the walk writes every |
| +// record and returns nothing pending. For a custom domain it writes nothing |
| +// and returns the whole set for the member to publish. |
| +// |
| +// The order is: create the identity FIRST (it is what mints the DKIM tokens |
| +// the records name), then publish, then record. A crash between any two is |
| +// repaired by running again — the identity is idempotent and token-stable, |
| +// and the DNS writes are upserts. |
| +func EnsureDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, |
| + d Deployment, acct, app, domain string, now time.Time) (*DomainRecord, []dnsrec.Record, error) { |
| + |
| + if !ValidDomain(domain) { |
| + return nil, nil, fmt.Errorf("mail: invalid domain %q", domain) |
| + } |
| + if err := p.EnsureTenant(ctx, acct); err != nil { |
| + return nil, nil, fmt.Errorf("mail: ensure tenant %s: %w", acct, err) |
| + } |
| + |
| + rec, etag, err := GetDomain(ctx, s, acct, domain) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + if rec == nil { |
| + rec = &DomainRecord{ |
| + Domain: domain, Kind: DomainKindFor(d, domain), |
| + Regions: map[string]*RegionState{}, CreatedAt: now, |
| + } |
| + } |
| + if rec.Regions == nil { |
| + rec.Regions = map[string]*RegionState{} |
| + } |
| + rec.AddApp(app) |
| + |
| + mailFrom := MailFromDomain(domain, d.Region) |
| + ident, err := p.EnsureIdentity(ctx, d.Region, domain, mailFrom) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("mail: ensure identity %s in %s: %w", domain, d.Region, err) |
| + } |
| + |
| + configSet := ConfigSetName(acct) |
| + if d.SNSTopicARN != "" { |
| + if err := p.EnsureConfigSet(ctx, d.Region, configSet, d.SNSTopicARN); err != nil { |
| + return nil, nil, fmt.Errorf("mail: ensure config set %s: %w", configSet, err) |
| + } |
| + } |
| + |
| + records := RecordSet(domain, d.Region, ident.DKIMTokens, d.DMARCRua) |
| + var pending []dnsrec.Record |
| + if rec.Kind == KindPlatform { |
| + for _, r := range records { |
| + if err := w.EnsureRecord(ctx, r); err != nil { |
| + // Deliberately BEFORE the record is written: a failed |
| + // publish must leave the walk repairable by a re-run, not |
| + // leave a record claiming DNS that is not there. |
| + return nil, nil, fmt.Errorf("mail: publish %s %s: %w", r.Type, r.Name, err) |
| + } |
| + } |
| + } else { |
| + pending = records |
| + } |
| + |
| + // Preserve a status already reached: a verified region must not be |
| + // knocked back to pending by a re-run. |
| + status := StatusPending |
| + if st, ok := rec.Regions[d.Region]; ok && st.Status == StatusVerified { |
| + status = StatusVerified |
| + } |
| + rec.Regions[d.Region] = &RegionState{ |
| + IdentityARN: ident.ARN, DKIMTokens: ident.DKIMTokens, |
| + MailFrom: mailFrom, ConfigSet: configSet, |
| + Status: status, CheckedAt: now, |
| + } |
| + if _, err := PutDomain(ctx, s, acct, rec, etag); err != nil { |
| + return nil, nil, err |
| + } |
| + return rec, pending, nil |
| +} |
| + |
| +// ConfigSetName is the per-account SES configuration set that carries the |
| +// event destination feeding bounce, complaint and send counts (spec §9). |
| +func ConfigSetName(acct string) string { return "carlos-" + acct } |
diff --git a/internal/mail/walk_domain_test.go b/internal/mail/walk_domain_test.go| new file mode 100644 |
| index 000000000..18be12012 |
| --- /dev/null |
| +++ b/internal/mail/walk_domain_test.go |
| @@ -0,0 +1,151 @@ |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func testDeployment() Deployment { |
| + return Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.com", |
| + SNSTopicARN: "arn:aws:sns:eu-west-1:1:carlos-mail"} |
| +} |
| + |
| +func TestDomainKindSplitsOnWhoOwnsTheZone(t *testing.T) { |
| + d := testDeployment() |
| + if got := DomainKindFor(d, "seapointish.bag.oncarlos.com"); got != KindPlatform { |
| + t.Fatalf("kind = %q, want platform", got) |
| + } |
| + if got := DomainKindFor(d, "seapointish.com"); got != KindCustom { |
| + t.Fatalf("kind = %q, want custom", got) |
| + } |
| + // A lookalike must NOT be treated as ours — suffix matching has to be |
| + // label-aware, or "evil-oncarlos.com" would have CARLOS try to write |
| + // DNS into a zone it does not own. |
| + if got := DomainKindFor(d, "evil-oncarlos.com"); got != KindCustom { |
| + t.Fatalf("kind for a lookalike = %q, want custom", got) |
| + } |
| +} |
| + |
| +// A platform domain is published for the member: CARLOS owns the zone, so |
| +// the walk writes every record itself and the member does nothing. |
| +func TestEnsureDomainPublishesPlatformDNS(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + |
| + rec, pending, err := EnsureDomain(ctx, s, p, w, testDeployment(), |
| + "bag", "seapointish", "seapointish.bag.oncarlos.com", now) |
| + if err != nil { |
| + t.Fatalf("EnsureDomain: %v", err) |
| + } |
| + if rec.Kind != KindPlatform { |
| + t.Fatalf("kind = %q, want platform", rec.Kind) |
| + } |
| + if len(pending) != 0 { |
| + t.Fatalf("a platform domain must leave nothing for the member to publish, got %d", len(pending)) |
| + } |
| + // 3 DKIM + MX + SPF + DMARC |
| + if len(w.Records) != 6 { |
| + t.Fatalf("published %d records, want 6: %+v", len(w.Records), w.Records) |
| + } |
| + st := rec.Regions["eu-west-1"] |
| + if st.Status != StatusPending { |
| + t.Fatalf("status = %q, want pending — DNS is written, SES has not converged", st.Status) |
| + } |
| + if st.MailFrom != "mail-eu-west-1.seapointish.bag.oncarlos.com" { |
| + t.Fatalf("MailFrom = %q", st.MailFrom) |
| + } |
| +} |
| + |
| +// A custom domain is EMITTED for the customer: CARLOS cannot write their |
| +// zone, so the same record set comes back for them to publish and nothing |
| +// is written. |
| +func TestEnsureDomainEmitsCustomRecordsAndWritesNothing(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + |
| + rec, pending, err := EnsureDomain(ctx, s, p, w, testDeployment(), |
| + "bag", "seapointish", "seapointish.com", now) |
| + if err != nil { |
| + t.Fatalf("EnsureDomain: %v", err) |
| + } |
| + if rec.Kind != KindCustom { |
| + t.Fatalf("kind = %q, want custom", rec.Kind) |
| + } |
| + if len(pending) != 6 { |
| + t.Fatalf("emitted %d records for the customer, want 6", len(pending)) |
| + } |
| + if len(w.Records) != 0 { |
| + t.Fatal("wrote into a zone CARLOS does not own") |
| + } |
| +} |
| + |
| +// Re-running is a no-op that returns the SAME tokens. This is the crash |
| +// repair path: a walk that half-failed is fixed by running it again, and |
| +// fresh tokens would orphan the DNS the first run published. |
| +func TestEnsureDomainIsIdempotent(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + d := testDeployment() |
| + |
| + first, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "seapointish", "seapointish.bag.oncarlos.com", now) |
| + if err != nil { |
| + t.Fatalf("first: %v", err) |
| + } |
| + second, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "seapointish", "seapointish.bag.oncarlos.com", now) |
| + if err != nil { |
| + t.Fatalf("second: %v", err) |
| + } |
| + f, sec := first.Regions["eu-west-1"], second.Regions["eu-west-1"] |
| + if f.DKIMTokens[0] != sec.DKIMTokens[0] || f.IdentityARN != sec.IdentityARN { |
| + t.Fatal("re-running changed the identity — published DNS would be orphaned") |
| + } |
| + if len(w.Records) != 6 { |
| + t.Fatalf("re-running duplicated records: %d", len(w.Records)) |
| + } |
| +} |
| + |
| +// A second app on one domain joins the apps set rather than re-creating the |
| +// identity. This is the whole reason the record is account-scoped. |
| +func TestEnsureDomainAddsASecondAppWithoutRecreating(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + d := testDeployment() |
| + |
| + EnsureDomain(ctx, s, p, w, d, "bag", "seapointish", "shared.com", now) |
| + rec, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "marketing", "shared.com", now) |
| + if err != nil { |
| + t.Fatalf("second app: %v", err) |
| + } |
| + if len(rec.Apps) != 2 { |
| + t.Fatalf("apps = %v, want both", rec.Apps) |
| + } |
| +} |
| + |
| +// A half-failure after the identity exists but before DNS is published must |
| +// be repaired by a re-run, not left stuck. |
| +func TestEnsureDomainRepairsAHalfFailedPublish(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + d := testDeployment() |
| + |
| + w.FailNext["EnsureRecord"] = errors.New("route53 down") |
| + if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "seapointish", "seapointish.bag.oncarlos.com", now); err == nil { |
| + t.Fatal("EnsureDomain succeeded despite a DNS write failure") |
| + } |
| + if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "seapointish", "seapointish.bag.oncarlos.com", now); err != nil { |
| + t.Fatalf("re-run did not repair: %v", err) |
| + } |
| + if len(w.Records) != 6 { |
| + t.Fatalf("after repair, published %d records, want 6", len(w.Records)) |
| + } |
| +} |