tenant email: final review fix wave (C1, C2, I1-I6)
Seven fixes from the final whole-branch review, in one pass: - C1: EnsureDomain now binds an identity to its configuration set (SetIdentityConfigSet, new on the Provisioner seam) whenever an SNS topic is configured — without it SES never emits a send/bounce/ complaint event, so counts, the daily cap and the abuse pause were all permanently dead. - C2: Enable's idempotence short-circuit no longer skips EnsureUser — the IAM policy is now rewritten from the current granted-domain set on every call, so attaching (or losing) a domain takes effect on a plain re-enable, matching spec §4. - I1: EnvDelivered now compares _FROM's delivered value against Declared.From, the same way _USER is compared against KeyID, so a re-declared From address is re-delivered instead of drifting stale forever. - I2: mail.Teardown is now wired into both places objstore.Teardown already was (ownership.go's permanent delete, appsettings.go's store destroy) — a purged app no longer strands its global domain claim. - I3: a daily-cap pause now auto-resumes on the UTC day roll (Provisioned.PauseKind distinguishes it from a rate/manual pause, which still never auto-resumes); the CLI no longer points a paused member at the operator-only resume verb. - I4: the console now computes and carries an app's default sending host (<app>.<sqid>.<platform-suffix>) on every email reply, so `carlos email enable` with no -from is a real one-command story. - I5: sesevents_test.go's stale "certificate chain" comments now match the actual (chain-free) trust model. - I6: event attribution also matches a domain's recorded MAIL FROM subdomain, not just its own name, so SES reporting the rewritten envelope no longer drops the event. Mutation-tested C1, C2, I1, I3 and I6 by hand: each fix broken, confirmed the relevant test fails, restored. Full gate (build, vet, all 30 packages, GOOS=darwin build, gofmt) green throughout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
20 files changed,
+1050
−76
internal/console/apimail.go+52 −10internal/console/apimail_test.go+57 −0internal/console/appsettings.go+41 −0internal/console/deletion_test.go+57 −0internal/console/mailcounts.go+57 −10internal/console/mailcounts_test.go+79 −5internal/console/ownership.go+52 −0internal/console/sesevents.go+24 −1internal/console/sesevents_test.go+93 −4internal/mail/aws.go+19 −0internal/mail/aws_test.go+53 −0internal/mail/mail.go+25 −5internal/mail/memprovisioner.go+22 −0internal/mail/provisioner.go+10 −0internal/mail/walk.go+82 −16internal/mail/walk_domain_test.go+50 −0internal/mail/walk_enable_test.go+134 −1internal/mail/walk_lifecycle_test.go+10 −10mail_cli.go+62 −13mail_cli_test.go+71 −1
diff --git a/internal/console/apimail.go b/internal/console/apimail.go| index 3eaa8152d..948cc0093 100644 |
| --- a/internal/console/apimail.go |
| +++ b/internal/console/apimail.go |
| @@ -254,6 +254,29 @@ func mailEffectivePrefix(rec *mail.Record) string { |
| return mail.DefaultEnvPrefix |
| } |
| +// mailDefaultHost is the app's own host — <app>.<sqid>.<platform-suffix> — |
| +// the default identity spec §3 promises (the same shape |
| +// internal/domains/reconcile.go mints a route for) and the one thing that |
| +// makes `carlos email enable` with no -from a one-command story (I4, |
| +// review follow-up). The CLI cannot compute this itself: it never learns |
| +// CARLOS_MAIL_PLATFORM_SUFFIX, only this console does — so it has to ride |
| +// along in the reply. Empty when this deployment has no platform suffix |
| +// configured; the CLI keeps its explicit "no From address" refusal in that |
| +// case, exactly as before this fix. |
| +// |
| +// DELIBERATELY INDEPENDENT OF Config.AppsDomain, the same caution |
| +// mailDeployment's own doc comment (cmd/console/main.go) already applies: |
| +// conflating the edge's routing zone with the zone SES identities are |
| +// verified under would repeat the class of bug #167 already cost this |
| +// codebase once. This uses ONLY s.cfg.MailDeployment.PlatformSuffix. |
| +func (s *Server) mailDefaultHost(acct *Account, app string) string { |
| + suffix := s.cfg.MailDeployment.PlatformSuffix |
| + if suffix == "" { |
| + return "" |
| + } |
| + return app + "." + acct.Sqid + "." + suffix |
| +} |
| + |
| // mailEffectiveCap mirrors mailcounts.go's enforceMailLimits exactly — the |
| // cap a status reply names must be the SAME cap the limiter enforces, or a |
| // member reading "cap: 1000" while running on the free tier's 50 would have |
| @@ -362,7 +385,12 @@ func (s *Server) emailReply(ctx context.Context, acct *Account, app string, rec |
| missing := []string{} |
| if rec.Provisioned != nil { |
| wantKeyID := rec.Provisioned.KeyID |
| - got, err := mail.EnvDelivered(ctx, s.cfg.Releases, acct.Sqid, app, prefix, wantKeyID) |
| + // rec.Declared is guaranteed non-nil here: Provisioned is only ever |
| + // set by Enable, which itself requires Declared != nil — so |
| + // rec.Declared.From is always the record's CURRENT From (I1, |
| + // review follow-up: EnvDelivered compares _FROM's delivered value |
| + // against it the same way _USER is compared against KeyID). |
| + got, err := mail.EnvDelivered(ctx, s.cfg.Releases, acct.Sqid, app, prefix, wantKeyID, rec.Declared.From) |
| if err != nil { |
| return nil, err |
| } |
| @@ -378,13 +406,14 @@ func (s *Server) emailReply(ctx context.Context, acct *Account, app string, rec |
| standalone = []*mail.Standalone{} |
| } |
| return map[string]any{ |
| - "app": app, |
| - "declared": rec.Declared, |
| - "grant": rec.Grant, |
| - "provisioned": rec.Provisioned, |
| - "standalone": standalone, |
| - "domains": domains, |
| - "env_names": names, |
| + "app": app, |
| + "declared": rec.Declared, |
| + "grant": rec.Grant, |
| + "provisioned": rec.Provisioned, |
| + "standalone": standalone, |
| + "default_host": s.mailDefaultHost(acct, app), |
| + "domains": domains, |
| + "env_names": names, |
| // A rotation that minted a new key but has not yet deleted the old |
| // one — the member-visible half of the two-key overlap. |
| "pending_finish": rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "", |
| @@ -545,7 +574,15 @@ func (s *Server) handleAPIEmailStatus(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| if rec == nil { |
| - jsonError(w, http.StatusNotFound, "no email is declared for this app — declare it first") |
| + // Still carries default_host (I4, review follow-up), even on this |
| + // 404: `carlos email enable` with no -from makes its FIRST call |
| + // here, before anything has ever been declared, and that is |
| + // exactly when the app's default sending host needs to reach the |
| + // CLI for the one-command story to work at all. |
| + writeJSON(w, http.StatusNotFound, map[string]any{ |
| + "error": "no email is declared for this app — declare it first", |
| + "default_host": s.mailDefaultHost(acct, app), |
| + }) |
| return |
| } |
| out, err := s.emailReply(ctx, acct, app, rec) |
| @@ -1012,7 +1049,12 @@ func (s *Server) handleAPIEmailPause(w http.ResponseWriter, r *http.Request) { |
| // writing one — it must not touch Active at all. |
| var rec *mail.Record |
| if req.Paused != nil { |
| - rec, err = mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct.Sqid, app, *req.Paused, time.Now()) |
| + // "" — this is the MANUAL (operator) pause/resume door, not an |
| + // automatic one; mail.PauseKindCap/PauseKindRate are stamped only |
| + // by mailcounts.go's enforceMailLimits (I3, review follow-up). An |
| + // empty PauseKind is exactly what makes a manual pause never |
| + // auto-resume on the UTC day roll, same as a rate pause. |
| + rec, err = mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct.Sqid, app, *req.Paused, "", time.Now()) |
| if errors.Is(err, mail.ErrNotProvisioned) { |
| jsonError(w, http.StatusConflict, "no provisioned email credential to pause — enable email first") |
| return |
diff --git a/internal/console/apimail_test.go b/internal/console/apimail_test.go| index 611578d46..bc5225c6a 100644 |
| --- a/internal/console/apimail_test.go |
| +++ b/internal/console/apimail_test.go |
| @@ -322,6 +322,63 @@ func TestAPIEmailDeclareWritesTheRecord(t *testing.T) { |
| auditedBy(t, fx, app, "email-declare", "alice@keymail.dev", "hello@titogo.example") |
| } |
| +// TestAPIEmailStatusCarriesTheDefaultHostEvenWhenNotDeclared (I4, review |
| +// follow-up): `carlos email enable` with no -from needs the app's default |
| +// sending host on its VERY FIRST call — before anything has ever been |
| +// declared, which is exactly the 404 handleAPIEmailStatus gives. The field |
| +// must ride along on that 404, not only on a 200. |
| +func TestAPIEmailStatusCarriesTheDefaultHostEvenWhenNotDeclared(t *testing.T) { |
| + fx, _, _, token := emailArmed(t, "code-email-default-host-404") |
| + app := "titogo" |
| + if rec := emailRecord(t, fx, app); rec != nil { |
| + t.Fatalf("a record exists before any declaration: %+v", rec) |
| + } |
| + |
| + status, out := getJSON(t, emailURL(fx, app), token) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("status = %d %v, want 404 for an undeclared app", status, out) |
| + } |
| + want := "titogo.bab.oncarlos.com" |
| + if got, _ := out["default_host"].(string); got != want { |
| + t.Fatalf("default_host = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +// TestAPIEmailStatusCarriesTheDefaultHostOnceDeclared: the same field on |
| +// the ordinary 200 path, once something has actually been declared. |
| +func TestAPIEmailStatusCarriesTheDefaultHostOnceDeclared(t *testing.T) { |
| + fx, _, _, token := emailArmed(t, "code-email-default-host-200") |
| + app := "titogo" |
| + declareEmail(t, fx, token, app, "hello@titogo.example") |
| + |
| + status, out := getJSON(t, emailURL(fx, app), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("status = %d %v", status, out) |
| + } |
| + want := "titogo.bab.oncarlos.com" |
| + if got, _ := out["default_host"].(string); got != want { |
| + t.Fatalf("default_host = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +// TestAPIEmailStatusDefaultHostEmptyWithNoPlatformSuffix: a deployment with |
| +// no CARLOS_MAIL_PLATFORM_SUFFIX configured has no zone to default into — |
| +// default_host must stay empty, which is what keeps the CLI's explicit |
| +// "no From address" refusal intact rather than defaulting to a bogus host. |
| +func TestAPIEmailStatusDefaultHostEmptyWithNoPlatformSuffix(t *testing.T) { |
| + fx, _, _, token := emailArmed(t, "code-email-default-host-empty") |
| + fx.srv.cfg.MailDeployment.PlatformSuffix = "" |
| + app := "titogo" |
| + |
| + status, out := getJSON(t, emailURL(fx, app), token) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("status = %d %v, want 404 for an undeclared app", status, out) |
| + } |
| + if got, _ := out["default_host"].(string); got != "" { |
| + t.Fatalf("default_host = %q, want empty with no platform suffix configured", got) |
| + } |
| +} |
| + |
| // TestAPIEmailDeclareRefusesAnUnaddressedFrom: a From with no "@" is refused |
| // before anything is written. |
| func TestAPIEmailDeclareRefusesAnUnaddressedFrom(t *testing.T) { |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index 35d55cb6b..d5ada86bb 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -38,6 +38,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -599,6 +600,34 @@ func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) { |
| "this deployment has to tear it down.") |
| return |
| } |
| + // An email estate is torn down alongside the object store here too (I2, |
| + // review follow-up): mail.Teardown is the ONLY place that releases this |
| + // app's global domain claims (spec §4a), and this button — like |
| + // ownership.go's handlePurgeApp, which already does the same — is one |
| + // of the two backend-destroying surfaces objstore.Teardown was called |
| + // from with no email counterpart. Checked BEFORE requireFreshAuth, same |
| + // as the store's own backend check above: a doomed request must not |
| + // spend the passkey. |
| + mrec, _, mrerr := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if mrerr != nil { |
| + log.Printf("console: store destroy mail read %s/%s: %v", acct.Sqid, app, mrerr) |
| + fail("Could not read “" + app + "”'s email record — nothing was destroyed. Try again.") |
| + return |
| + } |
| + mailEstate := mrec != nil && (mrec.Provisioned != nil || len(mrec.Standalone) > 0) |
| + if !mailEstate { |
| + // A domain claim can exist with no delivered credential at all — |
| + // `carlos email domains add` alone, never followed by `enable` — |
| + // and mail.Teardown releases that claim too. |
| + if doms, derr := s.mailAppDomains(ctx, acct, app); derr == nil && len(doms) > 0 { |
| + mailEstate = true |
| + } |
| + } |
| + if mailEstate && s.cfg.MailProvisioner == nil { |
| + fail("This console has no SES backend configured, so it cannot delete “" + app + "”'s email " + |
| + "credentials — nothing was destroyed. An operator of this deployment has to tear it down.") |
| + return |
| + } |
| if !s.requireFreshAuth(w, r, addr, raw) { |
| return |
| } |
| @@ -612,6 +641,18 @@ func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) { |
| "backend is reachable.") |
| return |
| } |
| + if s.cfg.MailProvisioner != nil { |
| + mdep, mwriter := s.mailDeploymentFor() |
| + if terr := mail.Teardown(ctx, s.cfg.Releases, s.cfg.MailProvisioner, mwriter, mdep, acct.Sqid, app); terr != nil { |
| + log.Printf("console: store destroy mail teardown %s/%s: %v", acct.Sqid, app, terr) |
| + fail("Destroyed the object store, but could not tear down “" + app + "”'s email estate — " + |
| + "the store is already gone; try again to finish tearing down email.") |
| + return |
| + } |
| + if mailEstate { |
| + s.audit(acct, app, addr, "email-teardown", "tore down the email estate alongside the object store") |
| + } |
| + } |
| what, flash := "the store declaration", "Withdrew “"+app+"”'s store declaration." |
| if estate { |
| what = "bucket " + want |
diff --git a/internal/console/deletion_test.go b/internal/console/deletion_test.go| index 2bb37f40f..b7805cdad 100644 |
| --- a/internal/console/deletion_test.go |
| +++ b/internal/console/deletion_test.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "net/http" |
| "net/url" |
| @@ -11,7 +12,9 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -737,3 +740,57 @@ func TestPurgeAdviceMapsAStoreRefusalWithoutOverclaiming(t *testing.T) { |
| "(the guard wraps this sentinel for an unreadable record too)", msg) |
| } |
| } |
| + |
| +// ---- purge and the email estate (I2, review follow-up) -------------------- |
| +// |
| +// mail.Teardown had NO production caller anywhere before this fix. A purged |
| +// app left a live IAM user holding an active key, its SES identity, its |
| +// published DNS, and — worst — the create-only GLOBAL domain claim at |
| +// email/domain-claims/<domain>.json. Because release.PurgeApp removes |
| +// email.json while DomainRecord.Apps still names the deleted app, the |
| +// last-referrer branch inside mail.Teardown could never fire again for that |
| +// domain once the app was gone — the domain was then denied to every CARLOS |
| +// account, including its rightful owner, permanently. |
| +// |
| +// The proof used here is the claim's own semantics, not an internal peek at |
| +// bucket keys: a SECOND, unrelated account trying to ensure the SAME domain |
| +// is refused with ErrDomainClaimed before the purge (the claim is doing its |
| +// job), and succeeds after (the purge released it). |
| +func TestPurgeReleasesTheDomainClaim(t *testing.T) { |
| + fx, c, csrf := trashPageFixture(t) |
| + fake := mail.NewMemProvisioner("1") |
| + writer := dnsrec.NewMemWriter() |
| + fx.srv.cfg.MailProvisioner = fake |
| + fx.srv.cfg.DNSWriter = writer |
| + dep := mail.Deployment{Region: "eu-west-1"} // no PlatformSuffix: a custom |
| + // domain keeps this test about the claim gate alone, not the SEPARATE |
| + // platform-name-ownership gate (walk.go's platformNameOwnedBy), which |
| + // would otherwise refuse the second account for an unrelated reason. |
| + |
| + ctx := context.Background() |
| + now := time.Now() |
| + domain := "shared-claim.example.com" |
| + // Deliberately no mail.Declare/Enable — kass has a domain record (and |
| + // the claim it holds) but no Provisioned credential, exactly the shape |
| + // `carlos email domains add` alone leaves. mail.Teardown must still |
| + // find and release it: the domain-referrer set, not just a delivered |
| + // credential, is what the claim traces back to. |
| + if _, _, err := mail.EnsureDomain(ctx, fx.rel, fake, writer, dep, fx.sqid, "kass", domain, now); err != nil { |
| + t.Fatalf("EnsureDomain for kass: %v", err) |
| + } |
| + |
| + if _, _, err := mail.EnsureDomain(ctx, fx.rel, fake, writer, dep, "zzzz", "other", domain, now); !errors.Is(err, mail.ErrDomainClaimed) { |
| + t.Fatalf("a second account's EnsureDomain before the purge = %v, want ErrDomainClaimed", err) |
| + } |
| + |
| + status, _, body := postGated(t, fx, c, fx.u("/trash/delete"), |
| + url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("purge = %d:\n%s", status, body) |
| + } |
| + auditedBy(t, fx, "kass", "email-teardown", "paul@keymail.dev", "email estate") |
| + |
| + if _, _, err := mail.EnsureDomain(ctx, fx.rel, fake, writer, dep, "zzzz", "other", domain, now); err != nil { |
| + t.Fatalf("EnsureDomain after the purge = %v, want the claim released for anyone to take", err) |
| + } |
| +} |
diff --git a/internal/console/mailcounts.go b/internal/console/mailcounts.go| index a6cfd9a34..2d3ceebbb 100644 |
| --- a/internal/console/mailcounts.go |
| +++ b/internal/console/mailcounts.go |
| @@ -141,13 +141,49 @@ func (s *Server) enforceMailLimits(ctx context.Context, acct, app string, now ti |
| if err != nil { |
| return false, "", err |
| } |
| - // Nothing provisioned, or already paused: there is nothing to stop. |
| - // Re-pausing would rewrite the record and re-audit on every event, and |
| - // — more importantly — would lift a pause that a human has not decided |
| - // to lift. See "NOTHING auto-resumes" below. |
| - if rec == nil || rec.Provisioned == nil || !rec.Provisioned.Active { |
| + if rec == nil || rec.Provisioned == nil { |
| return false, "", nil |
| } |
| + if !rec.Provisioned.Active { |
| + // I3 (review follow-up): a CAP pause is a PER-DAY limit, because |
| + // the counts it was computed from are per-day — it MUST clear on |
| + // the UTC day roll, or a free-tier member's ordinary first day |
| + // over 50 messages stops their email forever, with resume gated |
| + // behind an operator allowlist that ships empty |
| + // (CARLOS_MAIL_OPERATORS, fail-closed). A RATE pause or a MANUAL |
| + // (operator) pause — PauseKind is anything other than |
| + // mail.PauseKindCap, including "" — still never auto-resumes: see |
| + // "RATE AND MANUAL PAUSES NEVER AUTO-RESUME" below, which still |
| + // holds for those two. |
| + if rec.Provisioned.PauseKind != mail.PauseKindCap { |
| + return false, "", nil |
| + } |
| + pausedDay := rec.Provisioned.At.UTC().Format("2006-01-02") |
| + today := now.UTC().Format("2006-01-02") |
| + if pausedDay >= today { |
| + // Paused earlier TODAY (or a clock oddity puts the pause in |
| + // the future) — not a day roll yet. |
| + return false, "", nil |
| + } |
| + if _, err := mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct, app, false, "", now); err != nil { |
| + return false, "", err |
| + } |
| + if a, err := s.store.AccountBySqid(acct); err != nil { |
| + log.Printf("console: mail resume audit: resolving account %s: %v", acct, err) |
| + } else if a != nil { |
| + s.audit(a, app, "ses", "mail.resumed", fmt.Sprintf("%s: daily cap pause cleared on the UTC day roll", app)) |
| + } |
| + // Re-read: SetPaused just wrote a fresh record (Active, PauseKind |
| + // and At all moved), and everything below reads rec fresh rather |
| + // than patching the in-memory copy by hand. |
| + rec, _, err = mail.Get(ctx, s.cfg.Releases, acct, app) |
| + if err != nil { |
| + return false, "", err |
| + } |
| + if rec == nil || rec.Provisioned == nil || !rec.Provisioned.Active { |
| + return false, "", nil |
| + } |
| + } |
| day := now.UTC().Format("2006-01-02") |
| c, err := s.store.MailCounts(ctx, acct, app, day) |
| if err != nil { |
| @@ -158,23 +194,26 @@ func (s *Server) enforceMailLimits(ctx context.Context, acct, app string, now ti |
| cap = rec.Grant.DailyCap |
| } |
| - var reason string |
| + var reason, kind string |
| switch { |
| case c.Sends >= cap: |
| reason = fmt.Sprintf("daily cap %d reached (%d sent)", cap, c.Sends) |
| + kind = mail.PauseKindCap |
| // The send floor is what stops one bounce on a two-message morning |
| // reading as a 50% bounce rate and pausing a healthy tenant. |
| case c.Sends >= MinSendsForRates && c.BounceRate() > BounceThreshold: |
| reason = fmt.Sprintf("bounce rate %.1f%% over %.0f%% (%d of %d)", |
| c.BounceRate()*100, BounceThreshold*100, c.Bounces, c.Sends) |
| + kind = mail.PauseKindRate |
| case c.Sends >= MinSendsForRates && c.ComplaintRate() > ComplaintThreshold: |
| reason = fmt.Sprintf("complaint rate %.2f%% over %.1f%% (%d of %d)", |
| c.ComplaintRate()*100, ComplaintThreshold*100, c.Complaints, c.Sends) |
| + kind = mail.PauseKindRate |
| default: |
| return false, "", nil |
| } |
| - if _, err := mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct, app, true, now); err != nil { |
| + if _, err := mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct, app, true, kind, now); err != nil { |
| return false, "", err |
| } |
| // Audit AFTER the write it announces, and never before — a pause the |
| @@ -188,6 +227,14 @@ func (s *Server) enforceMailLimits(ctx context.Context, acct, app string, now ti |
| return true, reason, nil |
| } |
| -// NOTHING auto-resumes. A pause that lifted itself at midnight would let an |
| -// abusive tenant send its cap again every day forever, which is not a limit |
| -// — it is a schedule. Resume is a decision, taken through the door. |
| +// RATE AND MANUAL PAUSES NEVER AUTO-RESUME. A RATE pause that lifted itself |
| +// at midnight would let an abusive tenant breach SES's account-review |
| +// thresholds again every day forever, which is not a limit — it is a |
| +// schedule. A MANUAL (operator) pause is a human's deliberate decision, |
| +// which only a human — through the door — gets to undo. Resume for either |
| +// is a decision, taken through the door. |
| +// |
| +// A CAP pause is different (I3, review follow-up, amending the rule this |
| +// comment used to state without exception): it is computed from PER-DAY |
| +// counts, so "never auto-resumes" turned an ordinary tier limit into a |
| +// permanent one — see the day-roll check above. |
diff --git a/internal/console/mailcounts_test.go b/internal/console/mailcounts_test.go| index d43f8a38a..ac27f01fe 100644 |
| --- a/internal/console/mailcounts_test.go |
| +++ b/internal/console/mailcounts_test.go |
| @@ -196,22 +196,96 @@ func TestSESThresholdConstants(t *testing.T) { |
| // A pause must not lift itself. Re-running enforce on a paused app with a |
| // clean slate must leave it paused — resume is a decision, not a timer. |
| -func TestEnforceNeverAutoResumes(t *testing.T) { |
| +// I3 (review follow-up): a CAP pause is a PER-DAY limit — the counts it was |
| +// computed from are per-day — so it MUST clear on the UTC day roll, or a |
| +// free-tier member's ordinary first day over 50 messages stops their email |
| +// forever, with resume gated behind an operator allowlist that ships empty |
| +// (CARLOS_MAIL_OPERATORS, fail-closed). This test used to assert the |
| +// OPPOSITE of what it asserts now — see the note SetPaused's own doc |
| +// comment carries about the day-roll check this behaviour depends on. |
| +func TestEnforceCapPauseResumesOnTheNextUTCDay(t *testing.T) { |
| fx, now := mailFixture(t) |
| seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 50}) |
| - fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "seapointish", now) |
| + if _, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "seapointish", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec, _, _ := mail.Get(context.Background(), fx.rel, release.OpsAccount, "seapointish") |
| + if rec.Provisioned.Active { |
| + t.Fatal("did not pause on the cap breach") |
| + } |
| + if rec.Provisioned.PauseKind != mail.PauseKindCap { |
| + t.Fatalf("PauseKind = %q, want %q", rec.Provisioned.PauseKind, mail.PauseKindCap) |
| + } |
| - next := now.AddDate(0, 0, 1) // a fresh day, no counts at all |
| + next := now.AddDate(0, 0, 1) // a fresh UTC day, no counts at all |
| paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "seapointish", next) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if paused { |
| - t.Fatal("enforce re-paused an already-paused app — it should have returned early") |
| + t.Fatal("enforce reports a NEW pause on the day-roll resume — nothing should trip on an empty day") |
| + } |
| + rec, _, _ = mail.Get(context.Background(), fx.rel, release.OpsAccount, "seapointish") |
| + if !rec.Provisioned.Active { |
| + t.Fatal("a cap pause from yesterday did not resume on the UTC day roll") |
| + } |
| + if rec.Provisioned.PauseKind != "" { |
| + t.Fatalf("PauseKind = %q after the auto-resume, want cleared", rec.Provisioned.PauseKind) |
| + } |
| + |
| + entries, err := fx.st.Events(OpsAccountID, "seapointish", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, e := range entries { |
| + if strings.Contains(e.Action, "mail.resumed") { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatal("no audit entry for the automatic day-roll resume") |
| + } |
| +} |
| + |
| +// The symmetric case: a RATE pause (a bounce/complaint threshold breach) |
| +// never auto-resumes, day roll or not — SES's account-review thresholds |
| +// are not a daily allowance, and lifting the brake on a schedule would let |
| +// an abusive tenant breach it again every day forever. |
| +func TestEnforceRatePauseNeverAutoResumes(t *testing.T) { |
| + fx, now := mailFixture(t) |
| + // Cap is 50, so raise it first: this test is about the RATE pause, and |
| + // a cap breach would mask which kind actually paused it. |
| + if _, err := mail.ApplyGrant(context.Background(), fx.rel, release.OpsAccount, "seapointish", |
| + []string{"x.com"}, 100000, "operator", "paul@rslw.com", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 1000, Bounces: 60}) |
| + if _, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "seapointish", now); err != nil { |
| + t.Fatal(err) |
| } |
| rec, _, _ := mail.Get(context.Background(), fx.rel, release.OpsAccount, "seapointish") |
| if rec.Provisioned.Active { |
| - t.Fatal("the pause lifted itself at midnight") |
| + t.Fatal("did not pause on the bounce-rate breach") |
| + } |
| + if rec.Provisioned.PauseKind != mail.PauseKindRate { |
| + t.Fatalf("PauseKind = %q, want %q", rec.Provisioned.PauseKind, mail.PauseKindRate) |
| + } |
| + |
| + next := now.AddDate(0, 0, 1) // a fresh UTC day |
| + paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "seapointish", next) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if paused { |
| + t.Fatal("enforce re-paused an already-paused app — it should have returned early") |
| + } |
| + rec, _, _ = mail.Get(context.Background(), fx.rel, release.OpsAccount, "seapointish") |
| + if rec.Provisioned.Active { |
| + t.Fatal("a rate pause auto-resumed on the UTC day roll — it must not") |
| + } |
| + if rec.Provisioned.PauseKind != mail.PauseKindRate { |
| + t.Fatalf("PauseKind = %q after a no-op day roll, want it to survive as %q", rec.Provisioned.PauseKind, mail.PauseKindRate) |
| } |
| } |
diff --git a/internal/console/ownership.go b/internal/console/ownership.go| index 353ad31e8..4d2a05aec 100644 |
| --- a/internal/console/ownership.go |
| +++ b/internal/console/ownership.go |
| @@ -23,6 +23,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -376,6 +377,57 @@ func (s *Server) handlePurgeApp(w http.ResponseWriter, r *http.Request) { |
| fail("Could not read “" + app + "”'s object-store record — nothing was deleted; try again.") |
| return |
| } |
| + // An email estate is torn down BEFORE the prefix sweep too, for the |
| + // identical reason the store is above (I2, review follow-up): |
| + // mail.Teardown is the ONLY place that releases this app's domain |
| + // claims — the create-only global gate at |
| + // email/domain-claims/<domain>.json (spec §4a) — and release.PurgeApp |
| + // below deletes email.json out from under it. Without this, a purged |
| + // app leaves a live IAM user with an active key, its SES identity, its |
| + // published DNS, and — worst — a domain claim that DomainRecord.Apps |
| + // can never again name this (now-deleted) app to release: the domain |
| + // is denied to every CARLOS account, including its rightful owner, |
| + // permanently. |
| + // |
| + // mail.Teardown is nil-record-safe (it sweeps this account's domain |
| + // records and this app's ORPHANED claims even when the app never |
| + // declared email at all — its own doc comment), so it runs whenever a |
| + // MailProvisioner is configured, not only when a Provisioned/Standalone |
| + // estate is already known to exist; the estate check below only decides |
| + // whether a MISSING provisioner has to refuse the whole delete. |
| + mrec, _, mrerr := mail.Get(r.Context(), s.cfg.Releases, acct.Sqid, app) |
| + if mrerr != nil { |
| + log.Printf("console: purge mail read %s/%s: %v", acct.Sqid, app, mrerr) |
| + fail("Could not read “" + app + "”'s email record — nothing was deleted; try again.") |
| + return |
| + } |
| + mailEstate := mrec != nil && (mrec.Provisioned != nil || len(mrec.Standalone) > 0) |
| + if !mailEstate { |
| + // A domain claim can exist with no delivered credential at all — |
| + // `carlos email domains add` alone, never followed by `enable` — |
| + // and mail.Teardown releases that claim too. The audit trail |
| + // below should say so. |
| + if doms, derr := s.mailAppDomains(r.Context(), acct, app); derr == nil && len(doms) > 0 { |
| + mailEstate = true |
| + } |
| + } |
| + if mailEstate && s.cfg.MailProvisioner == nil { |
| + fail("“" + app + "” has a provisioned email credential, and this console has no SES backend configured — it cannot be deleted here until email is torn down.") |
| + return |
| + } |
| + if s.cfg.MailProvisioner != nil { |
| + mctx, mcancel := context.WithTimeout(r.Context(), storeTeardownTimeout) |
| + defer mcancel() |
| + mdep, mwriter := s.mailDeploymentFor() |
| + if terr := mail.Teardown(mctx, s.cfg.Releases, s.cfg.MailProvisioner, mwriter, mdep, acct.Sqid, app); terr != nil { |
| + log.Printf("console: purge mail teardown %s/%s: %v", acct.Sqid, app, terr) |
| + fail("Could not tear down “" + app + "”'s email estate — nothing was deleted; try again.") |
| + return |
| + } |
| + if mailEstate { |
| + s.audit(acct, app, addr, "email-teardown", "tore down the email estate (credentials, IAM principal, domain claims) during permanent deletion") |
| + } |
| + } |
| if err := release.PurgeApp(r.Context(), s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, time.Now()); err != nil { |
| log.Printf("console: purge %s/%s: %v", acct.Sqid, app, err) |
| fail(purgeAdvice(err, app)) |
diff --git a/internal/console/sesevents.go b/internal/console/sesevents.go| index 53925acfd..c9618b0a8 100644 |
| --- a/internal/console/sesevents.go |
| +++ b/internal/console/sesevents.go |
| @@ -427,6 +427,29 @@ func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request) { |
| // bounded number of accounts; this is the same shape ListDomains' own |
| // callers (mail.Teardown, mail.grantedDomains) already accept for a single |
| // account, one level up. |
| +// domainMatchesEvent reports whether ev's domain attributes to d — either |
| +// the identity's own domain (the sender-supplied From SES usually reports |
| +// as mail.source) or, for ANY region, d's recorded custom MAIL FROM |
| +// subdomain (I6, review follow-up). SES may report the REWRITTEN envelope |
| +// — mail-<region>.<domain>, the custom MAIL FROM MX target (spec §7) — |
| +// rather than the sender-supplied From, and when it does, a match against |
| +// d.Domain alone attributes the event to nothing: every count for that |
| +// send silently drops, which is a second, independent way the whole of |
| +// spec §9 (caps, the abuse pause) stops working. Matched against RECORDED |
| +// values only, never a prefix-strip heuristic — MailFromDomain's shape |
| +// could change, and this must not assume it. |
| +func domainMatchesEvent(d *mail.DomainRecord, domain string) bool { |
| + if d.Domain == domain { |
| + return true |
| + } |
| + for _, st := range d.Regions { |
| + if st != nil && st.MailFrom != "" && st.MailFrom == domain { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| func (s *Server) recordAndEnforceMail(ctx context.Context, ev mail.Event) { |
| accts, err := s.store.Accounts() |
| if err != nil { |
| @@ -452,7 +475,7 @@ func (s *Server) recordAndEnforceMail(ctx context.Context, ev mail.Event) { |
| continue |
| } |
| for _, d := range domains { |
| - if d.Domain != ev.Domain { |
| + if !domainMatchesEvent(d, ev.Domain) { |
| continue |
| } |
| for _, app := range d.Apps { |
diff --git a/internal/console/sesevents_test.go b/internal/console/sesevents_test.go| index af1c90a6d..eac01babc 100644 |
| --- a/internal/console/sesevents_test.go |
| +++ b/internal/console/sesevents_test.go |
| @@ -5,9 +5,11 @@ package console |
| // in member — so what stands between a forged bounce and an automatic pause |
| // on a stranger's say-so is exactly what these tests pin: the TopicArn |
| // allowlist, the SigningCertURL/SubscribeURL host check (pinned to this |
| -// deployment's own region when known), certificate chain and subject |
| -// verification, a redirect guard on both outbound fetches, and the ordering |
| -// that runs all of it before any network call or trusted field. |
| +// deployment's own region when known), HTTPS to that pinned host with |
| +// redirects refused on both outbound fetches, the certificate's subject |
| +// assertion — NOT a chain build; sesevents.go's file comment records why |
| +// there is no chain to build for SNS's own signing certificate — and the |
| +// ordering that runs all of it before any network call or trusted field. |
| import ( |
| "context" |
| @@ -29,6 +31,9 @@ import ( |
| "strings" |
| "testing" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/mail" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // errFakeSignature stands in for a real crypto failure in the seam tests — |
| @@ -63,7 +68,9 @@ func notReached(t *testing.T) *httptest.Server { |
| } |
| // The SigningCertURL check is the ENTIRETY of SNS signature security — half |
| -// of it, along with fetchVerifiedCertKey's chain/subject check. If an |
| +// of it, along with fetchVerifiedCertKey's subject check (no chain build — |
| +// sesevents.go's file comment records why there is none to build for SNS's |
| +// own signing certificate). If an |
| // attacker can point it at a host they control, they sign their own message |
| // with their own certificate and it verifies — which means forging the |
| // bounce events that trigger an automatic pause, on any tenant. |
| @@ -586,3 +593,85 @@ func TestVerifiedCertKeyServesFromCache(t *testing.T) { |
| t.Fatal("verifiedCertKey returned a different key than the one cached") |
| } |
| } |
| + |
| +// ---- event-to-domain attribution (I6, review follow-up) -------------------- |
| +// |
| +// ParseEvent attributes an event by the domain of mail.source. If SES |
| +// reports the REWRITTEN envelope — mail-<region>.<domain>, the custom MAIL |
| +// FROM MX target (spec §7) — rather than the sender-supplied From, an exact |
| +// match against DomainRecord.Domain alone attributes the event to nothing, |
| +// and it is silently dropped from the counts that enforce the cap and trip |
| +// the abuse pause: a second, independent way spec §9 stops working. |
| + |
| +func TestDomainMatchesEventAcceptsTheIdentitysOwnDomain(t *testing.T) { |
| + d := &mail.DomainRecord{Domain: "seapointish.bag.oncarlos.com"} |
| + if !domainMatchesEvent(d, "seapointish.bag.oncarlos.com") { |
| + t.Fatal("the identity's own domain must match") |
| + } |
| +} |
| + |
| +func TestDomainMatchesEventAcceptsTheRecordedMailFromForAnyRegion(t *testing.T) { |
| + d := &mail.DomainRecord{ |
| + Domain: "seapointish.bag.oncarlos.com", |
| + Regions: map[string]*mail.RegionState{ |
| + "eu-west-1": {MailFrom: "mail-eu-west-1.seapointish.bag.oncarlos.com"}, |
| + "us-east-1": {MailFrom: "mail-us-east-1.seapointish.bag.oncarlos.com"}, |
| + }, |
| + } |
| + if !domainMatchesEvent(d, "mail-eu-west-1.seapointish.bag.oncarlos.com") { |
| + t.Fatal("the eu-west-1 MAIL FROM subdomain must match") |
| + } |
| + if !domainMatchesEvent(d, "mail-us-east-1.seapointish.bag.oncarlos.com") { |
| + t.Fatal("a second region's MAIL FROM subdomain must match too") |
| + } |
| +} |
| + |
| +func TestDomainMatchesEventRefusesAnUnrelatedDomain(t *testing.T) { |
| + d := &mail.DomainRecord{ |
| + Domain: "seapointish.bag.oncarlos.com", |
| + Regions: map[string]*mail.RegionState{"eu-west-1": {MailFrom: "mail-eu-west-1.seapointish.bag.oncarlos.com"}}, |
| + } |
| + if domainMatchesEvent(d, "unrelated.example.com") { |
| + t.Fatal("an unrelated domain must not match — this must stay a match against RECORDED values, never a prefix-strip heuristic") |
| + } |
| +} |
| + |
| +// TestRecordAndEnforceMailAttributesTheRewrittenMailFromEnvelope is the |
| +// end-to-end proof: an SES event reporting the rewritten MAIL FROM envelope |
| +// (not the identity's own domain) must still land in this app's counts. |
| +func TestRecordAndEnforceMailAttributesTheRewrittenMailFromEnvelope(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "seapointish", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := &mail.DomainRecord{ |
| + Domain: "seapointish.bag.oncarlos.com", |
| + Kind: mail.KindPlatform, |
| + Apps: []string{"seapointish"}, |
| + Regions: map[string]*mail.RegionState{ |
| + "eu-west-1": { |
| + IdentityARN: "arn:aws:ses:eu-west-1:1:identity/seapointish.bag.oncarlos.com", |
| + MailFrom: "mail-eu-west-1.seapointish.bag.oncarlos.com", |
| + Status: mail.StatusVerified, |
| + }, |
| + }, |
| + CreatedAt: now, |
| + } |
| + if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + ev := mail.Event{Type: mail.EventSend, Domain: "mail-eu-west-1.seapointish.bag.oncarlos.com", At: now} |
| + fx.srv.recordAndEnforceMail(ctx, ev) |
| + |
| + day := now.UTC().Format("2006-01-02") |
| + counts, err := fx.srv.store.MailCounts(ctx, release.OpsAccount, "seapointish", day) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if counts.Sends != 1 { |
| + t.Fatalf("Sends = %d, want 1 — the rewritten MAIL FROM envelope must still attribute to the app", counts.Sends) |
| + } |
| +} |
diff --git a/internal/mail/aws.go b/internal/mail/aws.go| index 2e5b933de..cf03fe7f3 100644 |
| --- a/internal/mail/aws.go |
| +++ b/internal/mail/aws.go |
| @@ -59,6 +59,7 @@ type sesAPI interface { |
| DeleteEmailIdentity(context.Context, *sesv2.DeleteEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.DeleteEmailIdentityOutput, error) |
| CreateConfigurationSet(context.Context, *sesv2.CreateConfigurationSetInput, ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetOutput, error) |
| CreateConfigurationSetEventDestination(context.Context, *sesv2.CreateConfigurationSetEventDestinationInput, ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetEventDestinationOutput, error) |
| + PutEmailIdentityConfigurationSetAttributes(context.Context, *sesv2.PutEmailIdentityConfigurationSetAttributesInput, ...func(*sesv2.Options)) (*sesv2.PutEmailIdentityConfigurationSetAttributesOutput, error) |
| } |
| // iamAPI is the subset of *iam.Client awsProvisioner calls. |
| @@ -222,6 +223,24 @@ func (p *awsProvisioner) EnsureConfigSet(ctx context.Context, region, name, snsT |
| return nil |
| } |
| +// SetIdentityConfigSet binds domain's identity to configSet — see the |
| +// Provisioner interface's doc comment on why this call, separate from |
| +// EnsureConfigSet, is what actually makes SES emit an event for a send |
| +// (C1, review follow-up). |
| +func (p *awsProvisioner) SetIdentityConfigSet(ctx context.Context, region, domain, configSet string) error { |
| + if err := p.checkRegion(region); err != nil { |
| + return err |
| + } |
| + _, err := p.ses.PutEmailIdentityConfigurationSetAttributes(ctx, &sesv2.PutEmailIdentityConfigurationSetAttributesInput{ |
| + EmailIdentity: aws.String(domain), |
| + ConfigurationSetName: aws.String(configSet), |
| + }) |
| + if err != nil { |
| + return fmt.Errorf("mail: set configuration set %q on identity %q: %w", configSet, domain, err) |
| + } |
| + return nil |
| +} |
| + |
| // EnsureIdentity creates the identity with Easy DKIM and sets the custom |
| // MAIL FROM, idempotently. |
| // |
diff --git a/internal/mail/aws_test.go b/internal/mail/aws_test.go| index ae714ffe9..2fef04dea 100644 |
| --- a/internal/mail/aws_test.go |
| +++ b/internal/mail/aws_test.go |
| @@ -63,6 +63,7 @@ type stubSES struct { |
| createEmailIdentity func(*sesv2.CreateEmailIdentityInput) (*sesv2.CreateEmailIdentityOutput, error) |
| getEmailIdentity func(*sesv2.GetEmailIdentityInput) (*sesv2.GetEmailIdentityOutput, error) |
| putEmailIdentityMailFromAttributes func(*sesv2.PutEmailIdentityMailFromAttributesInput) (*sesv2.PutEmailIdentityMailFromAttributesOutput, error) |
| + putEmailIdentityConfigurationSet func(*sesv2.PutEmailIdentityConfigurationSetAttributesInput) (*sesv2.PutEmailIdentityConfigurationSetAttributesOutput, error) |
| } |
| func (s *stubSES) CreateTenant(_ context.Context, in *sesv2.CreateTenantInput, _ ...func(*sesv2.Options)) (*sesv2.CreateTenantOutput, error) { |
| @@ -105,6 +106,13 @@ func (s *stubSES) CreateConfigurationSetEventDestination(_ context.Context, in * |
| return &sesv2.CreateConfigurationSetEventDestinationOutput{}, nil |
| } |
| +func (s *stubSES) PutEmailIdentityConfigurationSetAttributes(_ context.Context, in *sesv2.PutEmailIdentityConfigurationSetAttributesInput, _ ...func(*sesv2.Options)) (*sesv2.PutEmailIdentityConfigurationSetAttributesOutput, error) { |
| + if s.putEmailIdentityConfigurationSet != nil { |
| + return s.putEmailIdentityConfigurationSet(in) |
| + } |
| + return &sesv2.PutEmailIdentityConfigurationSetAttributesOutput{}, nil |
| +} |
| + |
| var _ sesAPI = (*stubSES)(nil) |
| // TestEnsureIdentityAlreadyExistsReadsBackTokensAndComposesTheARN pins the |
| @@ -170,6 +178,51 @@ func TestEnsureIdentityRefusesNoTokens(t *testing.T) { |
| } |
| } |
| +// TestSetIdentityConfigSetCallsThroughWithTheRightNames (C1, review |
| +// follow-up): this is the ONLY call in the whole package that actually |
| +// binds an identity to the configuration set carrying SES's event |
| +// destination — without it SES never emits an event for that identity, no |
| +// matter how many messages it sends. Pinned here at the AWS-facing layer, |
| +// not just against the in-memory fake, so a call built with the identity |
| +// and set-name arguments swapped (or dropped) would fail this test. |
| +func TestSetIdentityConfigSetCallsThroughWithTheRightNames(t *testing.T) { |
| + var gotIdentity, gotConfigSet string |
| + stub := &stubSES{ |
| + putEmailIdentityConfigurationSet: func(in *sesv2.PutEmailIdentityConfigurationSetAttributesInput) (*sesv2.PutEmailIdentityConfigurationSetAttributesOutput, error) { |
| + gotIdentity = aws.ToString(in.EmailIdentity) |
| + gotConfigSet = aws.ToString(in.ConfigurationSetName) |
| + return &sesv2.PutEmailIdentityConfigurationSetAttributesOutput{}, nil |
| + }, |
| + } |
| + p := &awsProvisioner{ses: stub, account: "123456789012", region: "eu-west-1"} |
| + |
| + if err := p.SetIdentityConfigSet(context.Background(), "eu-west-1", "example.com", "carlos-bag"); err != nil { |
| + t.Fatalf("SetIdentityConfigSet: %v", err) |
| + } |
| + if gotIdentity != "example.com" { |
| + t.Fatalf("EmailIdentity = %q, want %q", gotIdentity, "example.com") |
| + } |
| + if gotConfigSet != "carlos-bag" { |
| + t.Fatalf("ConfigurationSetName = %q, want %q", gotConfigSet, "carlos-bag") |
| + } |
| +} |
| + |
| +// A backend failure (e.g. the identity does not exist) must propagate, not |
| +// be swallowed as though the binding succeeded — a silently-not-bound |
| +// identity is exactly the C1 defect this whole verb exists to close. |
| +func TestSetIdentityConfigSetPropagatesBackendError(t *testing.T) { |
| + stub := &stubSES{ |
| + putEmailIdentityConfigurationSet: func(*sesv2.PutEmailIdentityConfigurationSetAttributesInput) (*sesv2.PutEmailIdentityConfigurationSetAttributesOutput, error) { |
| + return nil, &sesv2types.NotFoundException{Message: aws.String("no such identity")} |
| + }, |
| + } |
| + p := &awsProvisioner{ses: stub, account: "123456789012", region: "eu-west-1"} |
| + |
| + if err := p.SetIdentityConfigSet(context.Background(), "eu-west-1", "example.com", "carlos-bag"); err == nil { |
| + t.Fatal("SetIdentityConfigSet swallowed a backend error") |
| + } |
| +} |
| + |
| // wellFormedIdentityStub returns a stubSES whose CreateEmailIdentity and |
| // PutEmailIdentityMailFromAttributes both succeed, with three non-empty |
| // DKIM tokens. Used by the region-guard tests below so that success or |
diff --git a/internal/mail/mail.go b/internal/mail/mail.go| index 0d4a8611d..946a254d9 100644 |
| --- a/internal/mail/mail.go |
| +++ b/internal/mail/mail.go |
| @@ -55,14 +55,34 @@ type GrantBlock struct { |
| // secret; the password is derived at delivery time and lands only in the |
| // app's sealed config (spec §4). Active is the pause state (spec §9). |
| type Provisioned struct { |
| - IAMUser string `json:"iam_user"` |
| - UserARN string `json:"user_arn"` |
| - KeyID string `json:"key_id"` |
| - PrevKeyID string `json:"prev_key_id,omitempty"` |
| - Active bool `json:"active"` |
| + IAMUser string `json:"iam_user"` |
| + UserARN string `json:"user_arn"` |
| + KeyID string `json:"key_id"` |
| + PrevKeyID string `json:"prev_key_id,omitempty"` |
| + Active bool `json:"active"` |
| + // PauseKind distinguishes an automatic CAP pause from an automatic RATE |
| + // pause and from a manual operator pause (I3, review follow-up) — |
| + // additive field, so a record written before it existed decodes it as |
| + // "", which correctly reads as "not a cap pause, do not auto-resume |
| + // it". A CAP pause (PauseKindCap) is a PER-DAY limit and clears itself |
| + // on the UTC day roll (internal/console/mailcounts.go's |
| + // enforceMailLimits); a RATE pause (PauseKindRate) or a manual pause |
| + // (PauseKind == "") never auto-resumes — SES's account-review |
| + // thresholds are not a daily allowance, and a manual pause is an |
| + // operator's decision to undo, not a timer's. Always cleared (set back |
| + // to "") on resume, whatever resumed it. |
| + PauseKind string `json:"pause_kind,omitempty"` |
| At time.Time `json:"at"` |
| } |
| +// The two automatic pause kinds SetPaused records (spec §9, I3 review |
| +// follow-up). A manual (operator-driven) pause carries neither — see |
| +// Provisioned.PauseKind's own doc comment. |
| +const ( |
| + PauseKindCap = "cap" |
| + PauseKindRate = "rate" |
| +) |
| + |
| // Standalone is a displayed credential for something not running on CARLOS. |
| // |
| // It gets its OWN IAM principal, and that is forced rather than stylistic: |
diff --git a/internal/mail/memprovisioner.go b/internal/mail/memprovisioner.go| index f8e693e39..feb900b24 100644 |
| --- a/internal/mail/memprovisioner.go |
| +++ b/internal/mail/memprovisioner.go |
| @@ -30,6 +30,11 @@ type MemIdentity struct { |
| DKIMTokens []string |
| MailFrom string |
| Status Status |
| + // ConfigSet is stamped by SetIdentityConfigSet, mirroring |
| + // PutEmailIdentityConfigurationSetAttributes (C1, review follow-up). |
| + // Empty until then — including right after EnsureIdentity, which never |
| + // sets it itself. |
| + ConfigSet string |
| } |
| type MemProvisioner struct { |
| @@ -127,6 +132,23 @@ func (p *MemProvisioner) EnsureIdentity(_ context.Context, region, domain, mailF |
| return Identity{ARN: id.ARN, DKIMTokens: id.DKIMTokens}, nil |
| } |
| +// SetIdentityConfigSet models PutEmailIdentityConfigurationSetAttributes: |
| +// it stamps ConfigSet on an EXISTING identity and errors if there is none — |
| +// the real backend would 404 too (C1, review follow-up). |
| +func (p *MemProvisioner) SetIdentityConfigSet(_ context.Context, region, domain, configSet string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("SetIdentityConfigSet"); err != nil { |
| + return err |
| + } |
| + got, ok := p.Identities[identityKey(region, domain)] |
| + if !ok { |
| + return fmt.Errorf("mail: no identity for %s in %s to set a configuration set on", domain, region) |
| + } |
| + got.ConfigSet = configSet |
| + return nil |
| +} |
| + |
| func (p *MemProvisioner) IdentityStatus(_ context.Context, region, domain string) (Status, error) { |
| p.mu.Lock() |
| defer p.mu.Unlock() |
diff --git a/internal/mail/provisioner.go b/internal/mail/provisioner.go| index 1889f3a27..9ab392ed4 100644 |
| --- a/internal/mail/provisioner.go |
| +++ b/internal/mail/provisioner.go |
| @@ -43,6 +43,16 @@ type Provisioner interface { |
| // EnsureConfigSet creates the event destination that feeds bounce and |
| // complaint ingestion (spec §9). |
| EnsureConfigSet(ctx context.Context, region, name, snsTopicARN string) error |
| + // SetIdentityConfigSet binds an identity to the configuration set that |
| + // carries SES's event destination (spec §9; C1, review follow-up). |
| + // EnsureConfigSet alone creates the SET — it does not touch any |
| + // identity. SES only emits events (send, bounce, complaint) for a |
| + // message sent WITH a configuration set, so an identity that never |
| + // gets this call generates NO events, ever, no matter how many |
| + // messages it sends: every count stays zero forever, the daily cap |
| + // never enforces, and the automatic abuse pause never fires. Called |
| + // once per (region, domain) — idempotent, like every verb here. |
| + SetIdentityConfigSet(ctx context.Context, region, domain, configSet string) error |
| // EnsureIdentity creates the domain identity, enables Easy DKIM and |
| // sets the custom MAIL FROM subdomain, all idempotently. |
diff --git a/internal/mail/walk.go b/internal/mail/walk.go| index b222d9e8c..6ba3cf7c6 100644 |
| --- a/internal/mail/walk.go |
| +++ b/internal/mail/walk.go |
| @@ -211,6 +211,22 @@ func EnsureDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec. |
| 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) |
| } |
| + // C1 (review follow-up): EnsureConfigSet only creates the SET and |
| + // its SNS event destination — nothing before this bound the |
| + // IDENTITY just ensured above to it, and SES only emits events for |
| + // a message sent WITH a configuration set. Without this call every |
| + // send/bounce/complaint count for this identity stays zero |
| + // forever: the daily cap never enforces and the automatic abuse |
| + // pause — the one thing stopping a single tenant's stale list |
| + // putting the whole shared SES account under review — never |
| + // fires. Bound HERE, inside the `d.SNSTopicARN != ""` branch: the |
| + // SNS-less deployment deliberately records an empty configSet |
| + // (this function's own doc comment on Deployment), and calling |
| + // this with an empty name would either error at a real backend or |
| + // silently bind to nothing. |
| + if err := p.SetIdentityConfigSet(ctx, d.Region, domain, configSet); err != nil { |
| + return nil, nil, fmt.Errorf("mail: bind identity %s to config set %s: %w", domain, configSet, err) |
| + } |
| } |
| records := RecordSet(domain, d.Region, ident.DKIMTokens, d.DMARCRua) |
| @@ -410,7 +426,18 @@ func grantedDomains(ctx context.Context, s release.Store, acct, app string) ([]* |
| // regardless — a disagreement that is permanent, because nothing else ever |
| // re-checks it. _PASS is sealed, so its value cannot be compared this way; |
| // Kind is the best this function can do for it. |
| -func EnvDelivered(ctx context.Context, s release.Store, acct, app, prefix, wantKeyID string) ([]string, error) { |
| +// |
| +// wantFrom is the From address delivery is supposed to be carrying in |
| +// _FROM — normally rec.Declared.From. Pass "" to skip the value check, the |
| +// same convention wantKeyID uses. _FROM's VALUE is compared for the |
| +// identical reason _USER's is (I1, review follow-up): Declare freely |
| +// overwrites Declared.From at any time (only the env PREFIX is locked once |
| +// delivered), and before this check nothing ever re-verified that a |
| +// changed From actually reached delivery — a re-declare followed by a |
| +// re-enable left the record and the delivered value disagreeing |
| +// permanently, the same failure class the _USER check exists to catch, |
| +// just for the one delivered variable it had not been applied to yet. |
| +func EnvDelivered(ctx context.Context, s release.Store, acct, app, prefix, wantKeyID, wantFrom string) ([]string, error) { |
| man, err := appconfig.Get(ctx, s, acct, app) |
| if err != nil { |
| return nil, err |
| @@ -425,23 +452,31 @@ func EnvDelivered(ctx context.Context, s release.Store, acct, app, prefix, wantK |
| } |
| check(host, "env") |
| check(port, "env") |
| - check(from, "env") |
| check(pass, "secret") |
| if v, ok := man.Vars[user]; !ok || v.Kind != "env" || (wantKeyID != "" && v.Value != wantKeyID) { |
| missing = append(missing, user) |
| } |
| + if v, ok := man.Vars[from]; !ok || v.Kind != "env" || (wantFrom != "" && v.Value != wantFrom) { |
| + missing = append(missing, from) |
| + } |
| sort.Strings(missing) |
| return missing, nil |
| } |
| // Enable provisions the declared app's credential and delivers it. |
| // |
| -// Idempotent: a healthy provisioned app makes this a no-op — no fresh key, |
| -// no rewrite — because minting on every pass would burn IAM's two-key |
| -// ceiling and churn every instance's config. "Healthy" means the record |
| -// says provisioned AND every delivered variable is present, of the right |
| -// kind; anything less and the walk runs, because a missing variable means |
| -// the app cannot send. |
| +// Idempotent, but NOT all-or-nothing about it (C2, review follow-up): the |
| +// IAM user's policy is rewritten from the CURRENT granted domain set on |
| +// EVERY call, wholesale, cheaply — that is what makes attaching (or |
| +// losing) a domain take effect on a plain re-`enable` with no rotation. |
| +// Only the expensive half — mint a fresh key and re-deliver config — is |
| +// skipped when a healthy provisioned app makes it a no-op, because minting |
| +// on every pass would burn IAM's two-key ceiling and churn every |
| +// instance's config. "Healthy" means the record says provisioned AND every |
| +// delivered variable is present, of the right kind AND value where one is |
| +// checked; anything less and the rest of the walk runs too, because a |
| +// missing or stale variable means the app cannot send what the record |
| +// promises. |
| func Enable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, |
| d Deployment, acct, app string, now time.Time) (*Record, error) { |
| @@ -478,9 +513,31 @@ func Enable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P |
| rec.Declared.From, strings.Join(names, ", ")) |
| } |
| - // Idempotence short-circuit. |
| + // C2 (review follow-up): EnsureUser runs UNCONDITIONALLY, before the |
| + // idempotence short-circuit below — it is no longer gated behind it. |
| + // EnsureUser is the ONLY place the delivered credential's policy is |
| + // written (PutUserPolicy overwrites wholesale, spec §4), and arns/names |
| + // above are recomputed from the CURRENT granted domain set on every |
| + // call. Before this fix, the short-circuit returned before EnsureUser |
| + // ever ran again: adding and verifying a second domain, then |
| + // re-enabling, left the policy naming only the first domain forever — |
| + // spec §4 promises the opposite ("attaching a custom domain adds an ARN |
| + // and rewrites the policy without rotating a credential") — and the |
| + // symmetric shrink (a domain leaving the granted set) left a departed |
| + // ARN standing in the policy just as permanently. EnsureUser is cheap |
| + // to re-run (idempotent CreateUser, one PutUserPolicy) — it is the reap |
| + // and mint below that spend IAM's two-key ceiling, and THOSE are what |
| + // the short-circuit still has to gate. |
| + user := UserName(acct, app) |
| + arn, err := p.EnsureUser(ctx, user, UserPath, arns, names) |
| + if err != nil { |
| + return nil, fmt.Errorf("mail: ensure user %s: %w", user, err) |
| + } |
| + |
| + // Idempotence short-circuit: governs ONLY the expensive mint-and-deliver |
| + // half from here down. |
| if rec.Provisioned != nil { |
| - missing, err := EnvDelivered(ctx, s, acct, app, rec.Declared.EnvPrefix, rec.Provisioned.KeyID) |
| + missing, err := EnvDelivered(ctx, s, acct, app, rec.Declared.EnvPrefix, rec.Provisioned.KeyID, rec.Declared.From) |
| if err != nil { |
| return nil, err |
| } |
| @@ -500,11 +557,6 @@ func Enable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P |
| active = rec.Provisioned.Active |
| } |
| - user := UserName(acct, app) |
| - arn, err := p.EnsureUser(ctx, user, UserPath, arns, names) |
| - if err != nil { |
| - return nil, fmt.Errorf("mail: ensure user %s: %w", user, err) |
| - } |
| // Reap before minting: reaching here means delivery is missing, so any |
| // current key is either undelivered (its secret died with a failed |
| // config write) or stranded by a crash between mint and record. No |
| @@ -753,8 +805,17 @@ func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app |
| // is its OWN IAM user, so there is no two-key ceiling interaction, and |
| // skipping them would leave a tenant paused for abuse still sending from the |
| // same verified domain through, say, a laptop credential. |
| +// |
| +// kind records WHICH automatic pause this is (mail.PauseKindCap or |
| +// mail.PauseKindRate) so mailcounts.go's enforceMailLimits can tell a |
| +// per-day cap pause — which it auto-resumes on the UTC day roll — from a |
| +// rate pause or a manual one, which it never does (I3, review follow-up). |
| +// Pass "" for a manual pause (the operator door) and for every resume |
| +// (paused == false): PauseKind is always cleared back to "" on resume, |
| +// whatever resumed it, so a record never carries a stale kind from a pause |
| +// that is already lifted. |
| func SetPaused(ctx context.Context, s release.Store, p Provisioner, |
| - acct, app string, paused bool, now time.Time) (*Record, error) { |
| + acct, app string, paused bool, kind string, now time.Time) (*Record, error) { |
| rec, etag, err := Get(ctx, s, acct, app) |
| if err != nil { |
| @@ -778,6 +839,11 @@ func SetPaused(ctx context.Context, s release.Store, p Provisioner, |
| } |
| } |
| rec.Provisioned.Active = !paused |
| + if paused { |
| + rec.Provisioned.PauseKind = kind |
| + } else { |
| + rec.Provisioned.PauseKind = "" |
| + } |
| rec.Provisioned.At = now |
| if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| return nil, err |
diff --git a/internal/mail/walk_domain_test.go b/internal/mail/walk_domain_test.go| index 013992c94..50a9edf08 100644 |
| --- a/internal/mail/walk_domain_test.go |
| +++ b/internal/mail/walk_domain_test.go |
| @@ -218,6 +218,56 @@ func TestEnsureDomainLeavesConfigSetEmptyWithoutSNS(t *testing.T) { |
| } |
| } |
| +// C1 (review follow-up): recording a config set NAME in the domain record |
| +// is not the same as SES actually emitting events for that identity — SES |
| +// only does that for a send carrying a configuration set, and nothing |
| +// before this task ever bound the identity to one. A domain ensured |
| +// against a deployment WITH an SNS topic must have its identity's default |
| +// configuration set stamped AT THE PROVISIONER, not just recorded in the |
| +// bucket. |
| +func TestEnsureDomainWithSNSStampsTheIdentitysConfigSet(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() // carries an SNSTopicARN |
| + |
| + if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "seapointish", "seapointish.bag.oncarlos.com", now); err != nil { |
| + t.Fatalf("EnsureDomain: %v", err) |
| + } |
| + id, ok := p.Identities[identityKey(d.Region, "seapointish.bag.oncarlos.com")] |
| + if !ok { |
| + t.Fatal("no identity recorded at the provisioner") |
| + } |
| + if want := ConfigSetName("bag"); id.ConfigSet != want { |
| + t.Fatalf("identity ConfigSet = %q, want %q — SetIdentityConfigSet was never called, so SES "+ |
| + "would never emit an event for this identity and the daily cap/abuse pause would never fire", |
| + id.ConfigSet, want) |
| + } |
| +} |
| + |
| +// The symmetric SNS-less case: without a configuration set to bind to, |
| +// SetIdentityConfigSet must not be called at all — calling it with an |
| +// empty name would either error at a real backend or silently bind the |
| +// identity to nothing. |
| +func TestEnsureDomainWithoutSNSStampsNoIdentityConfigSet(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() |
| + d.SNSTopicARN = "" |
| + |
| + if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "seapointish", "seapointish.bag.oncarlos.com", now); err != nil { |
| + t.Fatalf("EnsureDomain: %v", err) |
| + } |
| + id, ok := p.Identities[identityKey(d.Region, "seapointish.bag.oncarlos.com")] |
| + if !ok { |
| + t.Fatal("no identity recorded at the provisioner") |
| + } |
| + if id.ConfigSet != "" { |
| + t.Fatalf("identity ConfigSet = %q, want empty — no SNS topic means no config set to bind to", id.ConfigSet) |
| + } |
| +} |
| + |
| // acct reaches EnsureTenant — a real backend call — before anything checks |
| // it, unless validation gates the walk before that call. app is persisted |
| // into the record and later turned into an IAM principal name by other |
diff --git a/internal/mail/walk_enable_test.go b/internal/mail/walk_enable_test.go| index 11bfddf9f..9e861d953 100644 |
| --- a/internal/mail/walk_enable_test.go |
| +++ b/internal/mail/walk_enable_test.go |
| @@ -3,6 +3,7 @@ package mail |
| import ( |
| "context" |
| "crypto/ecdh" |
| + "strings" |
| "testing" |
| "time" |
| @@ -263,7 +264,7 @@ func TestEnvDeliveredAndEnableCatchAStaleRecordedKeyID(t *testing.T) { |
| t.Fatalf("Put: %v", err) |
| } |
| - missing, err := EnvDelivered(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", "AKIASTALE00000000") |
| + missing, err := EnvDelivered(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", "AKIASTALE00000000", "") |
| if err != nil { |
| t.Fatalf("EnvDelivered: %v", err) |
| } |
| @@ -282,3 +283,135 @@ func TestEnvDeliveredAndEnableCatchAStaleRecordedKeyID(t *testing.T) { |
| t.Fatal("re-walk should mint a fresh key, not silently reuse the originally delivered one") |
| } |
| } |
| + |
| +// C2 (review follow-up): the idempotence short-circuit used to return |
| +// BEFORE EnsureUser — the only place the delivered credential's policy is |
| +// written — so a second domain added and verified after the first enable |
| +// never reached the policy on a plain re-`enable`. Spec §4 states the |
| +// opposite explicitly: "attaching a custom domain adds an ARN and rewrites |
| +// the policy without rotating a credential." |
| +func TestEnableRewritesThePolicyWhenASecondDomainIsAdded(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + first, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("first Enable: %v", err) |
| + } |
| + firstKeyID := first.Provisioned.KeyID |
| + |
| + second := "second.bag.oncarlos.com" |
| + if _, _, err := EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), |
| + "bag", "seapointish", second, now); err != nil { |
| + t.Fatalf("EnsureDomain second: %v", err) |
| + } |
| + p.SetIdentityStatus("eu-west-1", second, Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}) |
| + if _, err := VerifyDomain(ctx, s, p, "bag", second, "eu-west-1", now); err != nil { |
| + t.Fatalf("VerifyDomain second: %v", err) |
| + } |
| + |
| + rewalked, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("re-enable: %v", err) |
| + } |
| + if rewalked.Provisioned.KeyID != firstKeyID { |
| + t.Fatal("re-enable minted a fresh key — the short-circuit must still govern the expensive half") |
| + } |
| + policy := p.Users[UserName("bag", "seapointish")].Policy |
| + for _, want := range []string{ |
| + "seapointish.bag.oncarlos.com", second, "*@seapointish.bag.oncarlos.com", "*@" + second, |
| + } { |
| + if !strings.Contains(policy, want) { |
| + t.Fatalf("policy after re-enable missing %q:\n%s", want, policy) |
| + } |
| + } |
| +} |
| + |
| +// The symmetric shrink case: a domain that WAS granted and verified stops |
| +// being verified (e.g. SES reports its DKIM/MAIL FROM failed), narrowing |
| +// grantedDomains on the NEXT Enable call. The surviving policy must not go |
| +// on naming the departed ARN and From pattern — the same defect C2 fixes, |
| +// approached from the other direction. |
| +func TestEnableRewritesThePolicyWhenADomainStopsVerifying(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + second := "second.bag.oncarlos.com" |
| + if _, _, err := EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), |
| + "bag", "seapointish", second, now); err != nil { |
| + t.Fatalf("EnsureDomain second: %v", err) |
| + } |
| + p.SetIdentityStatus("eu-west-1", second, Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}) |
| + if _, err := VerifyDomain(ctx, s, p, "bag", second, "eu-west-1", now); err != nil { |
| + t.Fatalf("VerifyDomain second: %v", err) |
| + } |
| + |
| + first, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("first Enable: %v", err) |
| + } |
| + policy := p.Users[UserName("bag", "seapointish")].Policy |
| + if !strings.Contains(policy, second) { |
| + t.Fatalf("policy before the regression missing %q:\n%s", second, policy) |
| + } |
| + |
| + // second regresses: SES now reports it FAILED. |
| + p.SetIdentityStatus("eu-west-1", second, Status{DKIM: "FAILED", MailFrom: "FAILED", Sending: "PENDING"}) |
| + if _, err := VerifyDomain(ctx, s, p, "bag", second, "eu-west-1", now); err != nil { |
| + t.Fatalf("VerifyDomain regression: %v", err) |
| + } |
| + |
| + rewalked, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("re-enable after regression: %v", err) |
| + } |
| + if rewalked.Provisioned.KeyID != first.Provisioned.KeyID { |
| + t.Fatal("re-enable minted a fresh key — the short-circuit must still govern the expensive half") |
| + } |
| + policy = p.Users[UserName("bag", "seapointish")].Policy |
| + if strings.Contains(policy, second) { |
| + t.Fatalf("policy after the regression still names the departed domain %q:\n%s", second, policy) |
| + } |
| + if !strings.Contains(policy, "seapointish.bag.oncarlos.com") { |
| + t.Fatalf("policy after the regression lost the SURVIVING domain too:\n%s", policy) |
| + } |
| +} |
| + |
| +// I1 (review follow-up): the exact failure class the _USER staleness check |
| +// was added to catch, applied to the OTHER unsealed variable it was never |
| +// extended to. Declare freely overwrites Declared.From at any time (only |
| +// the env prefix is locked once delivered); a re-declare followed by a |
| +// re-enable must re-deliver the new From, not leave the stale one standing |
| +// forever because nothing ever compared _FROM's VALUE. |
| +func TestEnableRedeliversAChangedFromAddress(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + first, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("first Enable: %v", err) |
| + } |
| + |
| + newFrom := "admin@seapointish.bag.oncarlos.com" |
| + if _, err := Declare(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", newFrom, "paul@rslw.com", now); err != nil { |
| + t.Fatalf("re-declare: %v", err) |
| + } |
| + |
| + missing, err := EnvDelivered(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", first.Provisioned.KeyID, newFrom) |
| + if err != nil { |
| + t.Fatalf("EnvDelivered: %v", err) |
| + } |
| + if !containsString(missing, "SEAPOINTISH_SMTP_FROM") { |
| + t.Fatalf("missing = %v, want SEAPOINTISH_SMTP_FROM reported after a re-declare changed it", missing) |
| + } |
| + |
| + second, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("re-enable: %v", err) |
| + } |
| + if second.Declared.From != newFrom { |
| + t.Fatalf("record's Declared.From = %q, want %q", second.Declared.From, newFrom) |
| + } |
| + man, err := appconfig.Get(ctx, s, "bag", "seapointish") |
| + if err != nil { |
| + t.Fatalf("appconfig.Get: %v", err) |
| + } |
| + fromVar, ok := man.Vars["SEAPOINTISH_SMTP_FROM"] |
| + if !ok || fromVar.Value != newFrom { |
| + t.Fatalf("delivered SEAPOINTISH_SMTP_FROM = %+v, want value %q", fromVar, newFrom) |
| + } |
| +} |
diff --git a/internal/mail/walk_lifecycle_test.go b/internal/mail/walk_lifecycle_test.go| index dabdc6f48..5c6b25528 100644 |
| --- a/internal/mail/walk_lifecycle_test.go |
| +++ b/internal/mail/walk_lifecycle_test.go |
| @@ -50,7 +50,7 @@ func TestRotateDoesNotLiftAPauseAtIAM(t *testing.T) { |
| if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now); err != nil { |
| t.Fatalf("Enable: %v", err) |
| } |
| - if _, err := SetPaused(ctx, s, p, "bag", "seapointish", true, now); err != nil { |
| + if _, err := SetPaused(ctx, s, p, "bag", "seapointish", true, "", now); err != nil { |
| t.Fatalf("SetPaused(true): %v", err) |
| } |
| @@ -100,7 +100,7 @@ func TestEnableRepairDoesNotLiftAPauseAtIAM(t *testing.T) { |
| if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now); err != nil { |
| t.Fatalf("first Enable: %v", err) |
| } |
| - if _, err := SetPaused(ctx, s, p, "bag", "seapointish", true, now); err != nil { |
| + if _, err := SetPaused(ctx, s, p, "bag", "seapointish", true, "", now); err != nil { |
| t.Fatalf("SetPaused(true): %v", err) |
| } |
| // Clear a delivered var so the idempotence short-circuit cannot fire — |
| @@ -136,7 +136,7 @@ func TestPauseAndResumeToggleTheKeyNotThePolicy(t *testing.T) { |
| user := UserName("bag", "seapointish") |
| policyBefore := p.Users[user].Policy |
| - paused, err := SetPaused(ctx, s, p, "bag", "seapointish", true, now) |
| + paused, err := SetPaused(ctx, s, p, "bag", "seapointish", true, "", now) |
| if err != nil { |
| t.Fatalf("SetPaused(true): %v", err) |
| } |
| @@ -150,7 +150,7 @@ func TestPauseAndResumeToggleTheKeyNotThePolicy(t *testing.T) { |
| t.Fatal("the pause rewrote the policy — it must only toggle the key") |
| } |
| - resumed, err := SetPaused(ctx, s, p, "bag", "seapointish", false, now) |
| + resumed, err := SetPaused(ctx, s, p, "bag", "seapointish", false, "", now) |
| if err != nil { |
| t.Fatalf("SetPaused(false): %v", err) |
| } |
| @@ -175,7 +175,7 @@ func TestPauseTogglesBothKeysDuringARotationOverlap(t *testing.T) { |
| t.Fatal("fixture broken: expected a PrevKeyID after Rotate") |
| } |
| - paused, err := SetPaused(ctx, s, p, "bag", "seapointish", true, now) |
| + paused, err := SetPaused(ctx, s, p, "bag", "seapointish", true, "", now) |
| if err != nil { |
| t.Fatalf("SetPaused(true): %v", err) |
| } |
| @@ -217,7 +217,7 @@ func TestPauseReachesStandaloneCredentials(t *testing.T) { |
| t.Fatal("fixture broken: standalone entry missing from record") |
| } |
| - if _, err := SetPaused(ctx, s, p, "bag", "seapointish", true, now); err != nil { |
| + if _, err := SetPaused(ctx, s, p, "bag", "seapointish", true, "", now); err != nil { |
| t.Fatalf("SetPaused(true): %v", err) |
| } |
| su, ok := p.Users[standaloneUser] |
| @@ -232,7 +232,7 @@ func TestPauseReachesStandaloneCredentials(t *testing.T) { |
| t.Fatal("pause did not reach the standalone credential — it can still send") |
| } |
| - if _, err := SetPaused(ctx, s, p, "bag", "seapointish", false, now); err != nil { |
| + if _, err := SetPaused(ctx, s, p, "bag", "seapointish", false, "", now); err != nil { |
| t.Fatalf("SetPaused(false): %v", err) |
| } |
| su, ok = p.Users[standaloneUser] |
| @@ -259,7 +259,7 @@ func TestCreateStandaloneDoesNotBypassAPause(t *testing.T) { |
| if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now); err != nil { |
| t.Fatalf("Enable: %v", err) |
| } |
| - if _, err := SetPaused(ctx, s, p, "bag", "seapointish", true, now); err != nil { |
| + if _, err := SetPaused(ctx, s, p, "bag", "seapointish", true, "", now); err != nil { |
| t.Fatalf("SetPaused(true): %v", err) |
| } |
| @@ -290,7 +290,7 @@ func TestCreateStandaloneReMintWhilePausedStaysInactiveAndResumeReactivates(t *t |
| if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now); err != nil { |
| t.Fatalf("Enable: %v", err) |
| } |
| - if _, err := SetPaused(ctx, s, p, "bag", "seapointish", true, now); err != nil { |
| + if _, err := SetPaused(ctx, s, p, "bag", "seapointish", true, "", now); err != nil { |
| t.Fatalf("SetPaused(true): %v", err) |
| } |
| if _, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "seapointish", |
| @@ -316,7 +316,7 @@ func TestCreateStandaloneReMintWhilePausedStaysInactiveAndResumeReactivates(t *t |
| t.Fatal("a re-mint while paused produced a LIVE key") |
| } |
| - if _, err := SetPaused(ctx, s, p, "bag", "seapointish", false, now); err != nil { |
| + if _, err := SetPaused(ctx, s, p, "bag", "seapointish", false, "", now); err != nil { |
| t.Fatalf("SetPaused(false): %v", err) |
| } |
| su, ok = p.Users[standaloneUser] |
diff --git a/mail_cli.go b/mail_cli.go| index 12f72ab8a..b4b123557 100644 |
| --- a/mail_cli.go |
| +++ b/mail_cli.go |
| @@ -179,11 +179,17 @@ type apiEmailGrant struct { |
| } |
| type apiEmailProvisioned struct { |
| - IAMUser string `json:"iam_user"` |
| - UserARN string `json:"user_arn"` |
| - KeyID string `json:"key_id"` |
| - PrevKeyID string `json:"prev_key_id,omitempty"` |
| - Active bool `json:"active"` |
| + IAMUser string `json:"iam_user"` |
| + UserARN string `json:"user_arn"` |
| + KeyID string `json:"key_id"` |
| + PrevKeyID string `json:"prev_key_id,omitempty"` |
| + Active bool `json:"active"` |
| + // PauseKind distinguishes a per-day CAP pause (clears itself on the |
| + // UTC day roll) from a RATE pause or a manual operator pause (neither |
| + // ever auto-resumes) — mail.Provisioned.PauseKind's wire form (I3, |
| + // review follow-up). "" on an active record, on a manual pause, and on |
| + // a record written before this field existed. |
| + PauseKind string `json:"pause_kind,omitempty"` |
| At time.Time `json:"at"` |
| } |
| @@ -235,6 +241,12 @@ type apiEmailReply struct { |
| DailyCap int `json:"daily_cap"` |
| Active bool `json:"active"` |
| Counts apiEmailCounts `json:"counts"` |
| + // DefaultHost is this app's own host — <app>.<sqid>.<platform-suffix> — |
| + // spec §3's default sending identity, computed console-side (I4, |
| + // review follow-up: the CLI itself never learns |
| + // CARLOS_MAIL_PLATFORM_SUFFIX). Empty when this deployment has no |
| + // platform suffix configured. |
| + DefaultHost string `json:"default_host,omitempty"` |
| } |
| type apiDNSRecord struct { |
| @@ -344,7 +356,17 @@ func (s *apiSession) emailStatus(ctx context.Context, a apiAcct, app string) (ap |
| return apiEmailReply{}, err |
| } |
| if status != http.StatusOK { |
| - return apiEmailReply{}, mailAPIError(status, body) |
| + // Even the "not declared" 404 carries the app's default sending |
| + // host (I4, review follow-up) — `carlos email enable` with no |
| + // -from needs it on the very FIRST call, before anything has ever |
| + // been declared, which is exactly when this branch fires. Best |
| + // effort: an unparseable body just leaves DefaultHost empty, same |
| + // as a deployment with no platform suffix configured. |
| + var partial struct { |
| + DefaultHost string `json:"default_host"` |
| + } |
| + json.Unmarshal(body, &partial) |
| + return apiEmailReply{DefaultHost: partial.DefaultHost}, mailAPIError(status, body) |
| } |
| var out apiEmailReply |
| if err := json.Unmarshal(body, &out); err != nil { |
| @@ -631,6 +653,17 @@ func runEmailEnableFlow(ctx context.Context, w io.Writer, sess *apiSession, acct |
| if from == "" && domain != "" { |
| from = "noreply@" + domain |
| } |
| + // Spec §3's default identity — the app's own host — computed |
| + // console-side and carried on cur (I4, review follow-up). Only when |
| + // NEITHER -from NOR -domain named anything, and no prior declaration |
| + // supplied one either: this is what makes `carlos email enable -app |
| + // X`, with no other flags at all, a one-command story. Empty when |
| + // this deployment has no platform suffix configured, in which case |
| + // the explicit error below still fires, exactly as before this fix. |
| + if from == "" && domain == "" && cur.DefaultHost != "" { |
| + domain = cur.DefaultHost |
| + from = "noreply@" + domain |
| + } |
| if from == "" { |
| return errors.New("no From address to declare — pass -from, or -domain (defaults From to noreply@<domain>)") |
| } |
| @@ -802,13 +835,29 @@ func printEmailStatus(w io.Writer, res apiEmailReply) { |
| fmt.Fprintf(w, " sending: %s (key %s)\n", c.ok("active"), res.Provisioned.KeyID) |
| } else { |
| warnf(w, " sending: PAUSED — SMTP auth is failing for this app right now") |
| - // The record does not carry a causal reason (mail.Provisioned has |
| - // no Reason field) — the honest thing to print is the evidence a |
| - // member CAN see (today's counts against the cap), not a |
| - // diagnosis this reply cannot actually back up. |
| - mutedf(w, " today: %d sends, %d bounces, %d complaints, %d rejects against a cap of %d — "+ |
| - "check the console's audit log for exactly why; carlos email resume -app %s once it's addressed", |
| - res.Counts.Sends, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects, res.DailyCap, res.App) |
| + // The record does not carry a causal reason beyond PauseKind |
| + // (mail.Provisioned has no free-text Reason field) — the |
| + // honest thing to print is the evidence a member CAN see |
| + // (today's counts against the cap), not a diagnosis this |
| + // reply cannot actually back up. |
| + mutedf(w, " today: %d sends, %d bounces, %d complaints, %d rejects against a cap of %d", |
| + res.Counts.Sends, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects, res.DailyCap) |
| + // I3 (review follow-up): `carlos email resume` is OPERATOR-only |
| + // and 404s for an ordinary member — pointing every paused |
| + // member at it, regardless of why, was actively wrong advice. |
| + // A CAP pause (PauseKindCap) clears itself on the next UTC day |
| + // roll (mailcounts.go's enforceMailLimits); nothing to run. A |
| + // RATE pause, or a manual pause this reply carries no kind |
| + // for, does not clear on its own and genuinely needs an |
| + // operator. |
| + switch res.Provisioned.PauseKind { |
| + case "cap": |
| + mutedf(w, " daily cap reached — this clears automatically at the next UTC day roll; no action needed") |
| + case "rate": |
| + mutedf(w, " bounce/complaint rate over threshold — this does not clear on its own; ask a deployment operator to resume it") |
| + default: |
| + mutedf(w, " paused by a deployment operator — ask them to resume it") |
| + } |
| } |
| if res.PendingFinish { |
| warnf(w, " a rotation is pending finish — the previous key is still live: carlos email rotate -finish -app %s", res.App) |
diff --git a/mail_cli_test.go b/mail_cli_test.go| index fdb593e55..d4dd2e2c6 100644 |
| --- a/mail_cli_test.go |
| +++ b/mail_cli_test.go |
| @@ -390,6 +390,45 @@ func TestPrintEmailStatusPausedNamesEvidenceNotAGuess(t *testing.T) { |
| } |
| } |
| +// TestPrintEmailStatusCapPausePointsAtDayRollNotResumeVerb (I3, review |
| +// follow-up): `carlos email resume` is operator-only and 404s for an |
| +// ordinary member — a cap-paused member must be told the pause clears |
| +// itself, never sent at a verb that will refuse them. |
| +func TestPrintEmailStatusCapPausePointsAtDayRollNotResumeVerb(t *testing.T) { |
| + var buf bytes.Buffer |
| + printEmailStatus(&buf, apiEmailReply{ |
| + App: "seapointish", DailyCap: 50, Active: false, |
| + Provisioned: &apiEmailProvisioned{KeyID: "AKIA1", PauseKind: "cap"}, |
| + Counts: apiEmailCounts{Sends: 51}, |
| + }) |
| + out := buf.String() |
| + if !strings.Contains(out, "UTC day roll") { |
| + t.Fatalf("cap-pause status does not mention the day roll:\n%s", out) |
| + } |
| + if strings.Contains(out, "carlos email resume") { |
| + t.Fatalf("cap-pause status still points an ordinary member at the operator-only resume verb:\n%s", out) |
| + } |
| +} |
| + |
| +// TestPrintEmailStatusRatePausePointsAtOperator: the symmetric case — a |
| +// rate pause genuinely needs an operator, and the status output must say |
| +// so rather than suggesting the pause clears on its own. |
| +func TestPrintEmailStatusRatePausePointsAtOperator(t *testing.T) { |
| + var buf bytes.Buffer |
| + printEmailStatus(&buf, apiEmailReply{ |
| + App: "seapointish", DailyCap: 100000, Active: false, |
| + Provisioned: &apiEmailProvisioned{KeyID: "AKIA1", PauseKind: "rate"}, |
| + Counts: apiEmailCounts{Sends: 1000, Bounces: 60}, |
| + }) |
| + out := buf.String() |
| + if !strings.Contains(out, "operator") { |
| + t.Fatalf("rate-pause status does not point at an operator:\n%s", out) |
| + } |
| + if strings.Contains(out, "UTC day roll") { |
| + t.Fatalf("rate-pause status wrongly claims it clears on its own:\n%s", out) |
| + } |
| +} |
| + |
| func TestPrintEmailStatusActiveShowsKey(t *testing.T) { |
| var buf bytes.Buffer |
| printEmailStatus(&buf, apiEmailReply{ |
| @@ -526,6 +565,10 @@ type fakeEmailConsole struct { |
| domainConflict bool |
| credRevoked bool |
| lastRevokedName string |
| + // defaultHost is the not-declared GET /email 404's default_host field |
| + // (I4, review follow-up) — "" reproduces a deployment with no platform |
| + // suffix configured. |
| + defaultHost string |
| } |
| func newFakeEmailConsole(t *testing.T, verifiedAfter int) *fakeEmailConsole { |
| @@ -540,7 +583,10 @@ func newFakeEmailConsole(t *testing.T, verifiedAfter int) *fakeEmailConsole { |
| case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/email"): |
| if !fc.declared { |
| w.WriteHeader(http.StatusNotFound) |
| - json.NewEncoder(w).Encode(map[string]any{"error": "no email is declared for this app — declare it first"}) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "error": "no email is declared for this app — declare it first", |
| + "default_host": fc.defaultHost, |
| + }) |
| return |
| } |
| json.NewEncoder(w).Encode(map[string]any{ |
| @@ -689,6 +735,30 @@ func TestRunEmailEnableFlowNoFromNoDomainRefusesCleanly(t *testing.T) { |
| } |
| } |
| +// TestRunEmailEnableFlowNoFromNoDomainDefaultsToTheAppsPlatformHost (I4, |
| +// review follow-up): with neither -from nor -domain given, and nothing |
| +// declared before, the console's own default_host (spec §3's default |
| +// identity, the app's own host) must drive both — the one-command story |
| +// `carlos email enable -app X` promises when the deployment has a |
| +// platform suffix configured. |
| +func TestRunEmailEnableFlowNoFromNoDomainDefaultsToTheAppsPlatformHost(t *testing.T) { |
| + fc := newFakeEmailConsole(t, 1) // verified on the first check |
| + fc.defaultHost = "seapointish.aaa.oncarlos.com" |
| + sess := newTestSession(fc.ts.URL) |
| + var buf bytes.Buffer |
| + |
| + err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "seapointish", "", "", "", 5*time.Second) |
| + if err != nil { |
| + t.Fatalf("runEmailEnableFlow: %v", err) |
| + } |
| + if !fc.enableCalled { |
| + t.Fatal("enable door was never called") |
| + } |
| + if fc.declaredVia.From != "noreply@seapointish.aaa.oncarlos.com" { |
| + t.Fatalf("From = %q, want noreply@ the console's default_host", fc.declaredVia.From) |
| + } |
| +} |
| + |
| // ---- runEmailTestFlow: mint / send / revoke bookkeeping -------------------- |
| // TestRunEmailTestFlowRevokesOnSuccess: the temporary credential is revoked |