cmd/console: wire the console's email sender
consoleMailer in main.go resolves CARLOS_CONSOLE_MAIL_FROM against the mail deployment, constructs an SES client, and builds the console's ConsoleMailer (NewConsoleMailer, internal/console/consolemail.go) — the invite fallback for when Keymail cannot deliver. serve() wires it into Config.ConsoleMail (internal/console/server.go) and starts its identity ensure in the background, next to StartAlertWatcher.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+88
−0
cmd/console/mailbackends_test.go+39 −0cmd/console/main.go+31 −0internal/console/consolemail.go+15 −0internal/console/server.go+3 −0
diff --git a/cmd/console/mailbackends_test.go b/cmd/console/mailbackends_test.go| index dd890792a..7835f6b4b 100644 |
| --- a/cmd/console/mailbackends_test.go |
| +++ b/cmd/console/mailbackends_test.go |
| @@ -5,8 +5,11 @@ import ( |
| "errors" |
| "testing" |
| + "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" |
| ) |
| // failIfCalled builds a newProvisioner/newWriter seam that fails the test if |
| @@ -126,3 +129,39 @@ func TestMailBackendsBothConfiguredBothConstructed(t *testing.T) { |
| t.Errorf("writer = %v, want the constructed MemWriter", writer) |
| } |
| } |
| + |
| +// TestConsoleMailerWiring covers consoleMailer's own coordinates (From, |
| +// Provisioner, SES client construction) — distinct from mailBackends' above, |
| +// since the console mailer additionally needs a From address and a working |
| +// SES client, neither of which mailBackends' Provisioner/Writer pair cover. |
| +func TestConsoleMailerWiring(t *testing.T) { |
| + sesOK := func(context.Context, string) (mail.EmailSender, error) { return fakeSESClient{}, nil } |
| + dep := mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.test"} |
| + store := release.NewDirStore(t.TempDir()) |
| + prov := mail.NewMemProvisioner("1") |
| + |
| + t.Setenv("CARLOS_CONSOLE_MAIL_FROM", "") |
| + if m, _ := consoleMailer(dep, store, prov, nil, sesOK); m == nil { |
| + t.Error("suffix set, no explicit From: want a mailer on the default address") |
| + } |
| + if m, _ := consoleMailer(mail.Deployment{Region: "eu-west-1"}, store, prov, nil, sesOK); m != nil { |
| + t.Error("no suffix, no explicit From: want no mailer") |
| + } |
| + if m, _ := consoleMailer(dep, store, nil, nil, sesOK); m != nil { |
| + t.Error("no provisioner: want no mailer") |
| + } |
| + t.Setenv("CARLOS_CONSOLE_MAIL_FROM", "invites@tito.io") |
| + if m, _ := consoleMailer(mail.Deployment{Region: "eu-west-1"}, store, prov, nil, sesOK); m == nil { |
| + t.Error("explicit From, no suffix: want a mailer") |
| + } |
| + sesFail := func(context.Context, string) (mail.EmailSender, error) { return nil, errors.New("no creds") } |
| + if m, _ := consoleMailer(dep, store, prov, nil, sesFail); m != nil { |
| + t.Error("SES client construction failed: want no mailer, not a crash") |
| + } |
| +} |
| + |
| +type fakeSESClient struct{} |
| + |
| +func (fakeSESClient) SendEmail(context.Context, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { |
| + return &sesv2.SendEmailOutput{}, nil |
| +} |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 1e6ad2364..b0a97bbdd 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -269,6 +269,7 @@ func serve() { |
| // One release.Store, named, because the senders sweep below needs the |
| // same one the server got. |
| rel := openStore() |
| + consoleMail, ensureConsoleMail := consoleMailer(mailDep, rel, mailProvisioner, dnsWriter, mail.NewSESEmailSender) |
| dbBucket, dbRegion, dbPrefix, placement := dbDoorWiring() |
| hostClaim := hostClaimEnabled(os.Getenv) |
| var dbVersions lease.VersionedStore |
| @@ -350,6 +351,11 @@ func serve() { |
| MailOperators: mailOperators(), |
| MailProvisioner: mailProvisioner, |
| DNSWriter: dnsWriter, |
| + // ConsoleMail is the console's own email sender, the invite fallback |
| + // when Keymail cannot deliver (consoleMailer above). nil on every |
| + // deployment with no From address or no mail Provisioner — invites |
| + // then fall through to the hand-off link. |
| + ConsoleMail: consoleMail, |
| // The STUN/TURN relay (relay spec §5). The region list is the whole |
| // switch: unset is every deployment until an operator arms one, and |
| // it leaves the relay doors refusing and no background loop running. |
| @@ -368,6 +374,11 @@ func serve() { |
| // here rather than in NewServer so constructing a Server — which every |
| // console test does — never starts a goroutine or sends mail. |
| go srv.StartAlertWatcher(context.Background()) |
| + // The console mailer's identity ensure (consoleMailer above) — makes or |
| + // re-reads the sending domain's SES/DKIM identity. Started here, not in |
| + // NewServer, same reasoning as StartAlertWatcher: constructing a Server |
| + // never starts a goroutine or sends mail. |
| + go ensureConsoleMail(context.Background()) |
| // The senders migration's tail (tenant-email senders design §5): once a |
| // release has passed with every console writing senders/app.json, an |
| // operator sets CARLOS_MAIL_LEGACY_SWEEP=1 and the next start deletes the |
| @@ -858,6 +869,26 @@ func mailBackends( |
| return provisioner, writer |
| } |
| +// consoleMailer builds the console's own email sender, the invite fallback |
| +// when Keymail cannot deliver (internal/console/consolemail.go). From is |
| +// CARLOS_CONSOLE_MAIL_FROM, else noreply on the ops account's platform name. |
| +// Like mailBackends, nothing here is fatal: no From, no provisioner or no |
| +// SES client leaves invites on Keymail and the hand-off link. |
| +func consoleMailer(dep mail.Deployment, releases release.Store, p mail.Provisioner, w dnsrec.Writer, |
| + newSES func(context.Context, string) (mail.EmailSender, error)) (console.ConsoleMailer, func(context.Context)) { |
| + from := console.ConsoleMailFrom(os.Getenv("CARLOS_CONSOLE_MAIL_FROM"), dep.PlatformSuffix) |
| + if from == "" || p == nil { |
| + log.Printf("console: no console email (set CARLOS_CONSOLE_MAIL_FROM or CARLOS_MAIL_PLATFORM_SUFFIX, and CARLOS_MAIL_REGION); invites go by Keymail or by hand") |
| + return nil, func(context.Context) {} |
| + } |
| + ses, err := newSES(context.Background(), dep.Region) |
| + if err != nil { |
| + log.Printf("console: no console email, SES client unavailable: %v", err) |
| + return nil, func(context.Context) {} |
| + } |
| + return console.NewConsoleMailer(from, releases, p, w, dep, ses) |
| +} |
| + |
| // edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the |
| // agent's domains reconcile loop reads, so the console's point-your-DNS-here |
| // guidance and the agent's DNS health checks can never name different |
diff --git a/internal/console/consolemail.go b/internal/console/consolemail.go| index 499c9e689..102ac6063 100644 |
| --- a/internal/console/consolemail.go |
| +++ b/internal/console/consolemail.go |
| @@ -67,6 +67,21 @@ func newSESConsoleMailer(from string, s release.Store, p mail.Provisioner, w dns |
| return &sesConsoleMailer{from: from, domain: domain, store: s, prov: p, dns: w, dep: d, ses: ses, now: time.Now} |
| } |
| +// NewConsoleMailer builds the console's mailer, or nil when this deployment |
| +// has no From address or no mail provisioner. The returned func runs the |
| +// identity ensure; serve() starts it in the background. |
| +func NewConsoleMailer(from string, s release.Store, p mail.Provisioner, w dnsrec.Writer, d mail.Deployment, ses mail.EmailSender) (ConsoleMailer, func(context.Context)) { |
| + if from == "" || p == nil || ses == nil || s == nil { |
| + return nil, func(context.Context) {} |
| + } |
| + m := newSESConsoleMailer(from, s, p, w, d, ses) |
| + return m, func(ctx context.Context) { |
| + if err := m.Ensure(ctx); err != nil { |
| + log.Printf("console mail: %v (retried on the next send)", err) |
| + } |
| + } |
| +} |
| + |
| // 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 { |
diff --git a/internal/console/server.go b/internal/console/server.go| index 9e9211867..6d61f0638 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -707,6 +707,9 @@ type Config struct { |
| // unaffected either way: it calls mail.SetPaused directly, never |
| // through this door. |
| MailOperators []string |
| + // ConsoleMail is the console's own email sender (consolemail.go), the |
| + // invite fallback when Keymail cannot deliver. nil: no email fallback. |
| + ConsoleMail ConsoleMailer |
| // RelayRegions are the AWS regions this deployment runs a STUN/TURN |
| // relay in (CARLOS_RELAY_REGIONS, comma-separated), IN THE ORDER an |
| // enabled app's CARLOS_RELAY lists them — the order every browser will |