// 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" "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. // 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 // 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 // Ready reports whether a Send would go out now: the sending identity is // verified. It makes no SES call, and gives up after two seconds. Ready(ctx context.Context) bool } // ConsoleMailFrom resolves the From address: the operator's explicit choice, // else noreply at console-mail under the ops account's platform name, else // "" (no email). Not "console.bab": that is also the platform mail name of // an ops app called "console". func ConsoleMailFrom(explicit, platformSuffix string) string { if e := strings.ToLower(strings.TrimSpace(explicit)); e != "" { return e } if platformSuffix == "" { return "" } return "noreply@" + mail.ConsoleMailApp + "." + 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 // lock is a one-slot mutex a waiter can give up on: an ensure stalled // inside an AWS call must not hold a Send past its own deadline. // Everything below it is guarded by it. lock chan struct{} 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, lock: make(chan struct{}, 1)} } // 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 { if err := m.acquire(ctx); err != nil { return err } defer m.unlock() return m.ensureLocked(ctx) } // acquire takes m.lock, or gives up with ErrConsoleMailNotReady when ctx // ends first. func (m *sesConsoleMailer) acquire(ctx context.Context) error { select { case m.lock <- struct{}{}: return nil case <-ctx.Done(): return fmt.Errorf("%w: %w", ErrConsoleMailNotReady, ctx.Err()) } } func (m *sesConsoleMailer) unlock() { <-m.lock } 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) { if err := m.acquire(ctx); err != nil { return "", err } defer m.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 } // Ready is the check a page runs before it promises an email: it shares // ready's cache and its pending-recheck window, and returns false rather // than waiting on a stalled ensure. func (m *sesConsoleMailer) Ready(ctx context.Context) bool { if ctx.Err() != nil { // acquire picks at random when the lock is free and ctx is over. return false } ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() _, err := m.ready(ctx) return err == 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 }