carlos / platform Public

mail,console: the console's own sending domain survives any app action in bab

Teardown of the ops account's console-mail referrer keeps its domain
record, claim, identity and DNS (credentials and records still go);
RemoveDomain refuses it, including its orphan claim. Rename shares
claimApp's reservedAppName predicate, so neither door lands on the name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev a747866dc8ef8d85b029e6d8ae15435cd2847394 parent 4689789
7 files changed, +236 −17
  • internal/console/appsettings.go +6 −4
  • internal/console/consolemail.go +4 −3
  • internal/console/consolemail_test.go +44 −0
  • internal/console/server.go +17 −10
  • internal/mail/consolemail.go +25 −0
  • internal/mail/consolemail_test.go +118 −0
  • internal/mail/walk.go +22 −0
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index 3f03d1ad1..064c98c6b 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -771,16 +771,18 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) {
return
}
// Normalized exactly as the create doors normalize (normalizeAppName),
- // and refused exactly where claimApp refuses: "new" is the create form's
- // own literal route, and an app renamed onto it would be unreachable —
- // the mux prefers the literal, so no console page could ever show it.
+ // and refused exactly where claimApp refuses (reservedAppName): "new" is
+ // the create form's own literal route, and an app renamed onto it would
+ // be unreachable — the mux prefers the literal, so no console page could
+ // ever show it; consoleMailApp in the ops account is the console's own
+ // mail referrer.
to := normalizeAppName(r.FormValue("to"))
if !release.ValidApp(to) {
s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general",
"App names are lowercase letters, digits and dashes.", map[string]string{"rename": r.FormValue("to")})
return
}
- if to == "new" {
+ if reservedAppName(acct, to) {
s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general",
"That name is reserved.", map[string]string{"rename": r.FormValue("to")})
return
diff --git a/internal/console/consolemail.go b/internal/console/consolemail.go
index 102ac6063..b0c309d3b 100644
--- a/internal/console/consolemail.go
+++ b/internal/console/consolemail.go
@@ -19,9 +19,10 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-// consoleMailApp is the referrer name on the console's domain record. claimApp
-// refuses it in the ops account, so no app delete can tear the identity down.
-const consoleMailApp = "console-mail"
+// consoleMailApp is the referrer name on the console's domain record.
+// reservedAppName refuses it in the ops account, and the mail layer refuses
+// to tear down its domain, so no app delete can take the identity with it.
+const consoleMailApp = mail.ConsoleMailApp
// consoleMailPendingRecheck is how long a not-yet-verified answer is trusted.
const consoleMailPendingRecheck = 5 * time.Minute
diff --git a/internal/console/consolemail_test.go b/internal/console/consolemail_test.go
index 7b5103fc2..777e1a4f9 100644
--- a/internal/console/consolemail_test.go
+++ b/internal/console/consolemail_test.go
@@ -3,6 +3,9 @@ package console
import (
"context"
"errors"
+ "net/http"
+ "net/url"
+ "strings"
"testing"
"time"
@@ -191,3 +194,44 @@ func TestConsoleMailAppIsReservedInOps(t *testing.T) {
t.Fatalf("claimApp(bab, %q) = %v, want claimReserved", consoleMailApp, got)
}
}
+
+// Final review Important #1: rename was the other door onto the reserved
+// name. Refused in the ops account, the same words as "new".
+func TestRenameOntoConsoleMailIsRefusedInOps(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, _, body := postForm(t, c, fx.u("/apps/sprout/rename"),
+ url.Values{"csrf": {csrf}, "to": {consoleMailApp}, "confirmed": {"1"}})
+ if status != http.StatusUnprocessableEntity || !strings.Contains(body, "That name is reserved.") {
+ t.Fatalf("rename onto %q = %d, want 422 with the reserved refusal:\n%s", consoleMailApp, status, body)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, consoleMailApp); err != nil || has {
+ t.Fatalf("HasAppIn(%s) = %v %v, want no rename", consoleMailApp, has, err)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || !has {
+ t.Fatalf("HasAppIn(sprout) = %v %v, want the app untouched", has, err)
+ }
+}
+
+func TestReservedAppName(t *testing.T) {
+ ops := &Account{Sqid: release.OpsAccount}
+ other := &Account{Sqid: "xyz"}
+ for _, tc := range []struct {
+ acct *Account
+ name string
+ want bool
+ }{
+ {ops, "new", true},
+ {other, "new", true},
+ {ops, consoleMailApp, true},
+ {other, consoleMailApp, false},
+ {ops, "blog", false},
+ } {
+ if got := reservedAppName(tc.acct, tc.name); got != tc.want {
+ t.Errorf("reservedAppName(%s, %q) = %v, want %v", tc.acct.Sqid, tc.name, got, tc.want)
+ }
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 959a6dbe2..46c7903d5 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -3683,20 +3683,27 @@ func ownerExempt(prefix, ownPrefix string) bool { return prefix == ownPrefix }
//
// held is set only with claimApexHeld: who holds the name's platform apex, so
// a door can say so without asking again.
+// reservedAppName reports whether no app in acct may take name, by create or
+// by rename.
+//
+// "new" is the literal path segment GET /a/{sqid}/apps/new resolves to —
+// Go 1.22's mux prefers it over apps/{app}, so an app named "new" could
+// never be viewed.
+//
+// The console's own mail identity hangs off consoleMailApp in the ops
+// account (consolemail.go). The mail layer refuses to tear that domain down
+// whatever app holds the name, but an app wearing it would still read as
+// the domain's owner everywhere else.
+func reservedAppName(acct *Account, name string) bool {
+ return name == "new" || (acct.Sqid == release.OpsAccount && name == consoleMailApp)
+}
+
func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (outcome claimOutcome, placed string, held *apexHold) {
if !release.ValidApp(name) {
return claimInvalid, "", nil
}
- // "new" is the literal path segment GET /a/{sqid}/apps/new resolves to —
- // Go 1.22's mux prefers it over apps/{app}, so an app named "new" could
- // never be viewed. Reserved before any store write, same as an invalid name.
- if name == "new" {
- return claimReserved, "", nil
- }
- // The console's own mail identity hangs off this name in the ops
- // account (consolemail.go). An app so named could be deleted, and an app
- // delete tears down every domain whose last referrer it was.
- if acct.Sqid == release.OpsAccount && name == consoleMailApp {
+ // Reserved before any store write, same as an invalid name.
+ if reservedAppName(acct, name) {
return claimReserved, "", nil
}
// Taken means taken IN THIS ACCOUNT — app names are unique per account,
diff --git a/internal/mail/consolemail.go b/internal/mail/consolemail.go
new file mode 100644
index 000000000..5f3c4a0ed
--- /dev/null
+++ b/internal/mail/consolemail.go
@@ -0,0 +1,25 @@
+package mail
+
+import (
+ "errors"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ConsoleMailApp is the referrer the console's own sending domain hangs off
+// in the ops account (internal/console/consolemail.go). It is not a real app:
+// the console claims the domain under this name so that no app's estate
+// owns it. The console refuses to create or rename an app onto it, and the
+// release walks here refuse to tear its domain down, so an app that reaches
+// the name some other way still cannot take the console's identity with it.
+const ConsoleMailApp = "console-mail"
+
+// ErrConsoleMailReserved is RemoveDomain's refusal of the console's own
+// sending domain: the ops account's ConsoleMailApp referrer, or a claim that
+// referrer made.
+var ErrConsoleMailReserved = errors.New("mail: the console's own sending domain cannot be removed")
+
+// isConsoleMail reports whether acct/app is the console's own referrer.
+func isConsoleMail(acct, app string) bool {
+ return acct == release.OpsAccount && app == ConsoleMailApp
+}
diff --git a/internal/mail/consolemail_test.go b/internal/mail/consolemail_test.go
new file mode 100644
index 000000000..648764e6c
--- /dev/null
+++ b/internal/mail/consolemail_test.go
@@ -0,0 +1,118 @@
+package mail
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/dnsrec"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const consoleMailDomain = "console-mail.bab.oncarlos.com"
+
+// consoleMailFixture ensures the console's own sending domain the way the
+// console does: ops account, referrer ConsoleMailApp, a platform name.
+func consoleMailFixture(t *testing.T) (context.Context, release.Store, *MemProvisioner, *dnsrec.MemWriter) {
+ t.Helper()
+ ctx := context.Background()
+ s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("shared-aws-account"), dnsrec.NewMemWriter()
+ now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
+ if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), release.OpsAccount, ConsoleMailApp, consoleMailDomain, now); err != nil {
+ t.Fatalf("EnsureDomain: %v", err)
+ }
+ return ctx, s, p, w
+}
+
+func assertConsoleMailIntact(t *testing.T, ctx context.Context, s release.Store, p *MemProvisioner) {
+ t.Helper()
+ rec, _, err := GetDomain(ctx, s, release.OpsAccount, consoleMailDomain)
+ if err != nil || rec == nil {
+ t.Fatalf("the console's domain record is gone: %+v (err %v)", rec, err)
+ }
+ if len(rec.Apps) != 1 || rec.Apps[0] != ConsoleMailApp {
+ t.Fatalf("referrers = %v, want [%s]", rec.Apps, ConsoleMailApp)
+ }
+ if _, ok := p.Identities[identityKey("eu-west-1", consoleMailDomain)]; !ok {
+ t.Fatal("the console's SES identity was deleted")
+ }
+ if claim, err := getDomainClaim(ctx, s, consoleMailDomain); err != nil || claim == nil {
+ t.Fatalf("the console's domain claim was released: %+v (err %v)", claim, err)
+ }
+}
+
+// An app that lands on the reserved name in the ops account (a move, a
+// migrate, a row from before the reservation) is still an app, and deleting
+// it runs Teardown. That must not take the console's own sending domain
+// with it.
+func TestTeardownKeepsTheConsoleMailDomain(t *testing.T) {
+ ctx, s, p, w := consoleMailFixture(t)
+ if err := Teardown(ctx, s, p, w, testDeployment(), release.OpsAccount, ConsoleMailApp); err != nil {
+ t.Fatalf("Teardown: %v", err)
+ }
+ assertConsoleMailIntact(t, ctx, s, p)
+}
+
+// The same name in any other account is an ordinary app: its Teardown
+// releases its domains as usual.
+func TestTeardownOfConsoleMailNameElsewhereIsOrdinary(t *testing.T) {
+ ctx := context.Background()
+ s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("shared-aws-account"), dnsrec.NewMemWriter()
+ now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
+ if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "aaa", ConsoleMailApp, "console-mail.aaa.oncarlos.com", now); err != nil {
+ t.Fatalf("EnsureDomain: %v", err)
+ }
+ if err := Teardown(ctx, s, p, w, testDeployment(), "aaa", ConsoleMailApp); err != nil {
+ t.Fatalf("Teardown: %v", err)
+ }
+ if rec, _, err := GetDomain(ctx, s, "aaa", "console-mail.aaa.oncarlos.com"); err != nil || rec != nil {
+ t.Fatalf("an ordinary app's domain survived its teardown: %+v (err %v)", rec, err)
+ }
+}
+
+func TestRemoveDomainRefusesTheConsoleMailDomain(t *testing.T) {
+ ctx, s, p, w := consoleMailFixture(t)
+ out, err := RemoveDomain(ctx, s, p, w, testDeployment(), release.OpsAccount, ConsoleMailApp, consoleMailDomain)
+ if !errors.Is(err, ErrConsoleMailReserved) {
+ t.Fatalf("RemoveDomain = %+v, %v; want ErrConsoleMailReserved", out, err)
+ }
+ assertConsoleMailIntact(t, ctx, s, p)
+}
+
+// A claim with no record (an ensure that failed after claiming) is released
+// by RemoveDomain for any app in the account. The console's claim is not:
+// the console re-runs its own ensure and picks it back up.
+func TestRemoveDomainRefusesTheConsoleMailOrphanClaim(t *testing.T) {
+ ctx, s, p, w := consoleMailFixture(t)
+ if err := s.Delete(ctx, DomainKey(release.OpsAccount, consoleMailDomain)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), release.OpsAccount, "blog", consoleMailDomain); !errors.Is(err, ErrConsoleMailReserved) {
+ t.Fatalf("RemoveDomain(orphan) = %v, want ErrConsoleMailReserved", err)
+ }
+ if claim, err := getDomainClaim(ctx, s, consoleMailDomain); err != nil || claim == nil {
+ t.Fatalf("the console's orphan claim was released: %+v (err %v)", claim, err)
+ }
+ if _, ok := p.Identities[identityKey("eu-west-1", consoleMailDomain)]; !ok {
+ t.Fatal("the console's SES identity was deleted")
+ }
+}
+
+// Teardown's orphan-claim sweep is scoped to the app that made the claim,
+// so it too must skip the console's.
+func TestTeardownKeepsTheConsoleMailOrphanClaim(t *testing.T) {
+ ctx, s, p, w := consoleMailFixture(t)
+ if err := s.Delete(ctx, DomainKey(release.OpsAccount, consoleMailDomain)); err != nil {
+ t.Fatal(err)
+ }
+ if err := Teardown(ctx, s, p, w, testDeployment(), release.OpsAccount, ConsoleMailApp); err != nil {
+ t.Fatalf("Teardown: %v", err)
+ }
+ if claim, err := getDomainClaim(ctx, s, consoleMailDomain); err != nil || claim == nil {
+ t.Fatalf("the console's orphan claim was swept: %+v (err %v)", claim, err)
+ }
+ if _, ok := p.Identities[identityKey("eu-west-1", consoleMailDomain)]; !ok {
+ t.Fatal("the console's SES identity was deleted")
+ }
+}
diff --git a/internal/mail/walk.go b/internal/mail/walk.go
index 13a19511b..adb114bbb 100644
--- a/internal/mail/walk.go
+++ b/internal/mail/walk.go
@@ -1490,6 +1490,14 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writ
}
}
+ // The console's own sending domain (ConsoleMailApp) is never torn down
+ // with an app: its record, claim, identity and DNS are the console's,
+ // and an app that reached the reserved name by a move or a migrate is
+ // not their owner. Its credentials above and its records below still go.
+ if isConsoleMail(acct, app) {
+ return deleteMailRecords(ctx, s, acct, app, rec)
+ }
+
domains, err := ListDomains(ctx, s, acct)
if err != nil {
return err
@@ -1589,6 +1597,11 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writ
}
}
+ return deleteMailRecords(ctx, s, acct, app, rec)
+}
+
+// deleteMailRecords is Teardown's last step: the app's own email records.
+func deleteMailRecords(ctx context.Context, s release.Store, acct, app string, rec *Record) error {
if rec != nil {
// Both objects: the sender the app is on, and the legacy one the
// migration deliberately leaves standing until the sweep. Leaving
@@ -1686,6 +1699,10 @@ func RemoveDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.
if !ValidDomain(domain) {
return nil, fmt.Errorf("mail: invalid domain %q", domain)
}
+ // The console's own referrer never releases its domain (see Teardown).
+ if isConsoleMail(acct, app) {
+ return nil, fmt.Errorf("mail: %s: %w", domain, ErrConsoleMailReserved)
+ }
// The From check runs FIRST, before anything is read about the domain
// and long before anything is written: a refusal must cost the caller
@@ -1775,6 +1792,11 @@ func removeOrphanedClaim(ctx context.Context, s release.Store, p Provisioner,
if claim == nil || claim.Account != acct {
return nil, fmt.Errorf("mail: no sending domain %s for this account: %w", domain, ErrDomainNotReferenced)
}
+ // The console's own claim with no record is an ensure the console will
+ // retry; releasing it here would delete the identity from under it.
+ if isConsoleMail(acct, claim.App) {
+ return nil, fmt.Errorf("mail: %s: %w", domain, ErrConsoleMailReserved)
+ }
// Only an identity this claim's walk created, as in Teardown's sweep.
if claim.createdIdentityIn(d.Region) {
if err := p.DeleteIdentity(ctx, d.Region, domain); err != nil {