console: its own email sender on the platform's mail layer
Adds the SESv2 send seam (internal/mail/sessend.go) and the console's sesConsoleMailer (internal/console/consolemail.go): an SES identity made through mail.EnsureDomain under the ops account (bab), with consoleMailApp reserved from claimApp so an app delete can never tear the identity down. ready() now clears m.ensured when mail.VerifyDomain errors (the domain record or claim is gone, e.g. an unverified claim lapsed per mail #216's ClaimLapse), so the next recheck re-runs EnsureDomain instead of refusing forever on a stale latch. Covered by TestConsoleMailerRecoversFromALapsedDomainRecord.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+383
−0
internal/console/consolemail.go+139 −0internal/console/consolemail_test.go+193 −0internal/console/server.go+6 −0internal/mail/sessend.go+45 −0
diff --git a/internal/console/consolemail.go b/internal/console/consolemail.go| new file mode 100644 |
| index 000000000..499c9e689 |
| --- /dev/null |
| +++ b/internal/console/consolemail.go |
| @@ -0,0 +1,139 @@ |
| +// The console's own email, for when Keymail cannot carry a message (today: |
| +// invites to someone with no Keymail mailbox). The From identity is made by |
| +// the platform's own mail layer, mail.EnsureDomain, owned by the ops |
| +// account under the reserved referrer app consoleMailApp. Sends go out on |
| +// the console's own AWS role, so no SMTP credential exists to store. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/mail" |
| + "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" |
| + |
| +// consoleMailPendingRecheck is how long a not-yet-verified answer is trusted. |
| +const consoleMailPendingRecheck = 5 * time.Minute |
| + |
| +// ErrConsoleMailNotReady: the identity is not verified (or could not be |
| +// made). The caller falls back to the next channel. |
| +var ErrConsoleMailNotReady = errors.New("console mail: sending identity not verified yet") |
| + |
| +type ConsoleMailer interface { |
| + Send(ctx context.Context, to, subject, body string) error |
| +} |
| + |
| +// ConsoleMailFrom resolves the From address: the operator's explicit choice, |
| +// else noreply on the ops account's platform name, else "" (no email). |
| +func ConsoleMailFrom(explicit, platformSuffix string) string { |
| + if e := strings.ToLower(strings.TrimSpace(explicit)); e != "" { |
| + return e |
| + } |
| + if platformSuffix == "" { |
| + return "" |
| + } |
| + return "noreply@console." + release.OpsAccount + "." + platformSuffix |
| +} |
| + |
| +type sesConsoleMailer struct { |
| + from, domain string |
| + store release.Store |
| + prov mail.Provisioner |
| + dns dnsrec.Writer |
| + dep mail.Deployment |
| + ses mail.EmailSender |
| + now func() time.Time |
| + |
| + mu sync.Mutex |
| + ensured bool |
| + verified bool |
| + configSet string |
| + checkedAt time.Time |
| +} |
| + |
| +func newSESConsoleMailer(from string, s release.Store, p mail.Provisioner, w dnsrec.Writer, d mail.Deployment, ses mail.EmailSender) *sesConsoleMailer { |
| + _, domain, _ := strings.Cut(from, "@") |
| + return &sesConsoleMailer{from: from, domain: domain, store: s, prov: p, dns: w, dep: d, ses: ses, now: time.Now} |
| +} |
| + |
| +// Ensure makes (or re-reads) the identity. Idempotent. Pending DNS records |
| +// for a custom domain are logged for the operator to publish. |
| +func (m *sesConsoleMailer) Ensure(ctx context.Context) error { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + return m.ensureLocked(ctx) |
| +} |
| + |
| +func (m *sesConsoleMailer) ensureLocked(ctx context.Context) error { |
| + if m.ensured { |
| + return nil |
| + } |
| + _, pending, err := mail.EnsureDomain(ctx, m.store, m.prov, m.dns, m.dep, |
| + release.OpsAccount, consoleMailApp, m.domain, m.now()) |
| + if err != nil { |
| + return fmt.Errorf("console mail: ensure %s: %w", m.domain, err) |
| + } |
| + for _, r := range pending { |
| + log.Printf("console mail: publish %s %s %s so %s can verify", r.Type, r.Name, strings.Join(r.Values, " "), m.domain) |
| + } |
| + m.ensured = true |
| + return nil |
| +} |
| + |
| +func (m *sesConsoleMailer) ready(ctx context.Context) (string, error) { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + if m.verified { |
| + return m.configSet, nil |
| + } |
| + if !m.checkedAt.IsZero() && m.now().Sub(m.checkedAt) < consoleMailPendingRecheck { |
| + return "", ErrConsoleMailNotReady |
| + } |
| + m.checkedAt = m.now() |
| + if err := m.ensureLocked(ctx); err != nil { |
| + return "", fmt.Errorf("%w: %v", ErrConsoleMailNotReady, err) |
| + } |
| + rec, err := mail.VerifyDomain(ctx, m.store, m.prov, m.dep, release.OpsAccount, m.domain, m.dep.Region, m.now()) |
| + if err != nil { |
| + // The domain record (or its claim) is gone — e.g. an unverified |
| + // claim lapsed (mail.ClaimLapse) and was taken, or the record was |
| + // otherwise removed from under this mailer. m.ensured must not |
| + // stay true: left set, every future call would short-circuit |
| + // ensureLocked and this mailer would refuse forever, with no |
| + // recheck ever able to repair it. Clearing it here means the NEXT |
| + // recheck (after consoleMailPendingRecheck) re-runs EnsureDomain |
| + // instead. |
| + m.ensured = false |
| + return "", fmt.Errorf("%w: %v", ErrConsoleMailNotReady, err) |
| + } |
| + st := rec.Regions[m.dep.Region] |
| + if st == nil || st.Status != mail.StatusVerified { |
| + return "", ErrConsoleMailNotReady |
| + } |
| + m.verified, m.configSet = true, st.ConfigSet |
| + return m.configSet, nil |
| +} |
| + |
| +func (m *sesConsoleMailer) Send(ctx context.Context, to, subject, body string) error { |
| + configSet, err := m.ready(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = m.ses.SendEmail(ctx, mail.SimpleEmailInput(m.from, to, subject, body, |
| + mail.TenantName(release.OpsAccount), configSet)) |
| + if err != nil { |
| + return fmt.Errorf("console mail: send to %s: %w", to, err) |
| + } |
| + return nil |
| +} |
diff --git a/internal/console/consolemail_test.go b/internal/console/consolemail_test.go| new file mode 100644 |
| index 000000000..7b5103fc2 |
| --- /dev/null |
| +++ b/internal/console/consolemail_test.go |
| @@ -0,0 +1,193 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/service/sesv2" |
| + |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/mail" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type fakeSES struct { |
| + sent []*sesv2.SendEmailInput |
| + err error |
| +} |
| + |
| +func (f *fakeSES) SendEmail(_ context.Context, in *sesv2.SendEmailInput, _ ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { |
| + if f.err != nil { |
| + return nil, f.err |
| + } |
| + f.sent = append(f.sent, in) |
| + return &sesv2.SendEmailOutput{MessageId: aws.String("m-1")}, nil |
| +} |
| + |
| +var verifiedStatus = mail.Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"} |
| + |
| +func testMailDeployment() mail.Deployment { |
| + return mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.test", SNSTopicARN: "arn:aws:sns:eu-west-1:1:t"} |
| +} |
| + |
| +func TestConsoleMailFrom(t *testing.T) { |
| + cases := []struct{ explicit, suffix, want string }{ |
| + {"invites@tito.io", "oncarlos.test", "invites@tito.io"}, |
| + {"", "oncarlos.test", "noreply@console.bab.oncarlos.test"}, |
| + {"", "", ""}, |
| + {" Invites@Tito.io ", "", "invites@tito.io"}, |
| + } |
| + for _, c := range cases { |
| + if got := ConsoleMailFrom(c.explicit, c.suffix); got != c.want { |
| + t.Errorf("ConsoleMailFrom(%q, %q) = %q, want %q", c.explicit, c.suffix, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestConsoleMailerSendsOnceVerified(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + p := mail.NewMemProvisioner("1") |
| + p.StatusOnEnsure = verifiedStatus |
| + ses := &fakeSES{} |
| + m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses) |
| + if err := m.Ensure(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := m.Send(ctx, "bill@gmail.com", "subj", "body"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(ses.sent) != 1 { |
| + t.Fatalf("sent %d, want 1", len(ses.sent)) |
| + } |
| + in := ses.sent[0] |
| + if aws.ToString(in.FromEmailAddress) != "noreply@console.bab.oncarlos.test" || |
| + in.Destination.ToAddresses[0] != "bill@gmail.com" || |
| + aws.ToString(in.TenantName) != mail.TenantName(release.OpsAccount) || |
| + aws.ToString(in.ConfigurationSetName) != mail.ConfigSetName(release.OpsAccount) { |
| + t.Fatalf("SendEmailInput = %+v", in) |
| + } |
| +} |
| + |
| +// Review Focus 4. |
| +func TestConsoleMailerRefusesUntilVerified(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + p := mail.NewMemProvisioner("1") // identities stay PENDING |
| + ses := &fakeSES{} |
| + m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses) |
| + if err := m.Send(ctx, "bill@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) { |
| + t.Fatalf("Send before verification = %v, want ErrConsoleMailNotReady", err) |
| + } |
| + if len(ses.sent) != 0 { |
| + t.Fatal("sent through an unverified identity") |
| + } |
| +} |
| + |
| +func TestConsoleMailerCachesAVerifiedIdentity(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + p := mail.NewMemProvisioner("1") |
| + p.StatusOnEnsure = verifiedStatus |
| + ses := &fakeSES{} |
| + m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses) |
| + if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil { |
| + t.Fatal(err) |
| + } |
| + p.FailNext["IdentityStatus"] = errors.New("SES is down") |
| + if err := m.Send(ctx, "b@gmail.com", "s", "b"); err != nil { |
| + t.Fatalf("second send re-checked SES: %v", err) |
| + } |
| +} |
| + |
| +func TestConsoleMailerRechecksPendingAfterFiveMinutes(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + p := mail.NewMemProvisioner("1") |
| + ses := &fakeSES{} |
| + m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses) |
| + now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) |
| + m.now = func() time.Time { return now } |
| + if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) { |
| + t.Fatalf("first = %v", err) |
| + } |
| + for k := range p.Identities { |
| + p.Identities[k].Status = verifiedStatus |
| + } |
| + if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) { |
| + t.Fatalf("within 5 minutes = %v, want the cached not-ready", err) |
| + } |
| + now = now.Add(5*time.Minute + time.Second) |
| + if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil { |
| + t.Fatalf("after 5 minutes = %v, want sent", err) |
| + } |
| +} |
| + |
| +// TestConsoleMailerRecoversFromALapsedDomainRecord covers the ruling added |
| +// alongside mail #216: ready() must clear m.ensured when VerifyDomain errors |
| +// (the domain record, or its claim, is gone — e.g. an unverified claim |
| +// lapsed, mail.ClaimLapse), so the NEXT recheck re-runs EnsureDomain instead |
| +// of failing forever on a stale "ensured" latch. |
| +func TestConsoleMailerRecoversFromALapsedDomainRecord(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + p := mail.NewMemProvisioner("1") // identities stay PENDING until driven verified below |
| + ses := &fakeSES{} |
| + dep := testMailDeployment() |
| + m := newSESConsoleMailer("noreply@console.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), dep, ses) |
| + now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) |
| + m.now = func() time.Time { return now } |
| + |
| + // A successful Ensure writes the domain record and latches m.ensured, |
| + // same as the console's startup call would. |
| + if err := m.Ensure(ctx); err != nil { |
| + t.Fatalf("ensure: %v", err) |
| + } |
| + |
| + key := mail.DomainKey(release.OpsAccount, m.domain) |
| + if _, _, err := s.Get(ctx, key); err != nil { |
| + t.Fatalf("domain record missing before deletion: %v", err) |
| + } |
| + // The record (or its claim) is gone — the shape an unverified claim's |
| + // lapse (mail.ClaimLapse) leaves behind. m.ensured is still true, so |
| + // without the ruling's fix ready() would call ensureLocked, find |
| + // m.ensured latched, skip EnsureDomain entirely, and fail this way |
| + // FOREVER — no recheck would ever repair it. |
| + if err := s.Delete(ctx, key); err != nil { |
| + t.Fatalf("delete domain record: %v", err) |
| + } |
| + |
| + now = now.Add(consoleMailPendingRecheck + time.Second) |
| + if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) { |
| + t.Fatalf("send after lapse = %v, want ErrConsoleMailNotReady", err) |
| + } |
| + |
| + // Drive the (still-extant, provisioner-side) identity to verified, the |
| + // same seam TestConsoleMailerRechecksPendingAfterFiveMinutes uses. |
| + for k := range p.Identities { |
| + p.Identities[k].Status = verifiedStatus |
| + } |
| + |
| + now = now.Add(consoleMailPendingRecheck + time.Second) |
| + if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil { |
| + t.Fatalf("send after recheck re-ran EnsureDomain: %v", err) |
| + } |
| + if _, _, err := s.Get(ctx, key); err != nil { |
| + t.Fatalf("domain record should exist again after re-ensure: %v", err) |
| + } |
| +} |
| + |
| +// Review Focus 5. |
| +func TestConsoleMailAppIsReservedInOps(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ops, err := fx.st.AccountByID(OpsAccountID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _, _ := fx.srv.claimApp(context.Background(), ops, consoleMailApp, "paul@keymail.dev", "", true, regionReq{}); got != claimReserved { |
| + t.Fatalf("claimApp(bab, %q) = %v, want claimReserved", consoleMailApp, got) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index bcfead4c3..9e9211867 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -3661,6 +3661,12 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placem |
| 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 { |
| + return claimReserved, "", nil |
| + } |
| // Taken means taken IN THIS ACCOUNT — app names are unique per account, |
| // never globally, so another account's "blog" is not a conflict. |
| if has, err := s.store.HasAppIn(acct.ID, name); err != nil { |
diff --git a/internal/mail/sessend.go b/internal/mail/sessend.go| new file mode 100644 |
| index 000000000..ec1132258 |
| --- /dev/null |
| +++ b/internal/mail/sessend.go |
| @@ -0,0 +1,45 @@ |
| +package mail |
| + |
| +import ( |
| + "context" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + "github.com/aws/aws-sdk-go-v2/service/sesv2" |
| + sesv2types "github.com/aws/aws-sdk-go-v2/service/sesv2/types" |
| +) |
| + |
| +// EmailSender is the one SESv2 call the console makes to send as itself |
| +// (its invite fallback), narrowed so tests use a fake. Apps never use it: |
| +// they send by SMTP with their own credential. |
| +type EmailSender interface { |
| + SendEmail(context.Context, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) |
| +} |
| + |
| +// NewSESEmailSender builds a sender on the default credential chain, which |
| +// on a box is the host role. |
| +func NewSESEmailSender(ctx context.Context, region string) (EmailSender, error) { |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return sesv2.NewFromConfig(cfg), nil |
| +} |
| + |
| +// SimpleEmailInput is a plain-text message attributed to tenant and, when |
| +// set, counted through configSet. |
| +func SimpleEmailInput(from, to, subject, body, tenant, configSet string) *sesv2.SendEmailInput { |
| + in := &sesv2.SendEmailInput{ |
| + FromEmailAddress: aws.String(from), |
| + Destination: &sesv2types.Destination{ToAddresses: []string{to}}, |
| + Content: &sesv2types.EmailContent{Simple: &sesv2types.Message{ |
| + Subject: &sesv2types.Content{Data: aws.String(subject), Charset: aws.String("UTF-8")}, |
| + Body: &sesv2types.Body{Text: &sesv2types.Content{Data: aws.String(body), Charset: aws.String("UTF-8")}}, |
| + }}, |
| + TenantName: aws.String(tenant), |
| + } |
| + if configSet != "" { |
| + in.ConfigurationSetName = aws.String(configSet) |
| + } |
| + return in |
| +} |