carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 19c4796e5e5b2cbcd482412dc63271fb0ddb1304 parent 9fad909
4 files changed, +88 −0
  • cmd/console/mailbackends_test.go +39 −0
  • cmd/console/main.go +31 −0
  • internal/console/consolemail.go +15 −0
  • internal/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