carlos / platform Public

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.
Paul Campbell c293ff1bdfd911ea710b19609b485d509b7e943d parent 47dbfff
2 files changed, +295 −0
  • internal/mail/walk.go +144 −0
  • internal/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))
+ }
+}