// Package mail is the tenant-email primitive: an app declares that it sends // email, the platform provisions an SES identity on a domain it can prove, // mints SMTP credentials scoped to exactly that identity, and delivers the // coordinates as env. // // See docs/superpowers/specs/2026-08-19-tenant-email-design.md — §4 (what // actually isolates one tenant from another: the IAM policy, not the SES // tenant), §6 (these record shapes), §7 (DNS and the three irreversible // choices), §9 (bounces, caps, the pause). // // The shape follows internal/objstore almost verb for verb, because it is // the same problem with a scarcer resource: a bucket spends money, a // sending identity spends reputation shared across every tenant. package mail import ( "context" "encoding/json" "errors" "fmt" "os" "regexp" "sort" "strings" "time" "github.com/carlosframework/platform/internal/release" ) const Schema = 1 // DefaultEnvPrefix names the delivered env set when a declaration does not // choose its own. const DefaultEnvPrefix = "CARLOS_SMTP" // Declared is the member's half: the one delivery knob and the From address. type Declared struct { EnvPrefix string `json:"env_prefix"` From string `json:"from"` At time.Time `json:"at"` By string `json:"by"` } // GrantBlock is the authorisation half. Source records WHY the grant exists // ("tier:free", "tier:pro", "operator") so a cap can be recomputed when a // tier changes without silently discarding an operator's deliberate // override (spec §10). type GrantBlock struct { Domains []string `json:"domains"` DailyCap int `json:"daily_cap"` Source string `json:"source"` At time.Time `json:"at"` By string `json:"by"` } // Provisioned is the walk's half. KeyID is the SMTP USERNAME and is not a // secret; the password is derived at delivery time and lands only in the // app's sealed config (spec §4). Active is the pause state (spec §9). type Provisioned struct { IAMUser string `json:"iam_user"` UserARN string `json:"user_arn"` KeyID string `json:"key_id"` PrevKeyID string `json:"prev_key_id,omitempty"` Active bool `json:"active"` // PauseKind distinguishes an automatic CAP pause from an automatic RATE // pause and from a manual operator pause (I3, review follow-up) — // additive field, so a record written before it existed decodes it as // "", which correctly reads as "not a cap pause, do not auto-resume // it". A CAP pause (PauseKindCap) is a PER-DAY limit and clears itself // on the UTC day roll (internal/console/mailcounts.go's // enforceMailLimits); a RATE pause (PauseKindRate) or a manual pause // (PauseKind == "") never auto-resumes — SES's account-review // thresholds are not a daily allowance, and a manual pause is an // operator's decision to undo, not a timer's. Always cleared (set back // to "") on resume, whatever resumed it. PauseKind string `json:"pause_kind,omitempty"` At time.Time `json:"at"` } // The two automatic pause kinds SetPaused records (spec §9, I3 review // follow-up). A manual (operator-driven) pause carries neither — see // Provisioned.PauseKind's own doc comment. const ( PauseKindCap = "cap" PauseKindRate = "rate" ) // Standalone is a displayed credential for something not running on CARLOS. // // It gets its OWN IAM principal, and that is forced rather than stylistic: // IAM allows two access keys per user, and both slots are already spoken // for by the delivered key plus PrevKeyID during a rotation overlap (the // same ceiling internal/objstore/walk.go's Rotate documents). A standalone // key on the shared user would appear to work and would make the first // rotation impossible — the worst kind of coupling, because it surfaces // only later and elsewhere. type Standalone struct { Name string `json:"name"` IAMUser string `json:"iam_user"` KeyID string `json:"key_id"` At time.Time `json:"at"` By string `json:"by"` } // Record is the view every walk in walk.go works with. Three blocks for // three actors, plus the standalone credentials, which have their own // lifetime. It used to be the object at Key. Now it is the four blocks of // the app sender, read and written through GetSender/PutSender. type Record struct { Schema int `json:"schema"` App string `json:"app"` Declared *Declared `json:"declared,omitempty"` Grant *GrantBlock `json:"grant,omitempty"` Provisioned *Provisioned `json:"provisioned,omitempty"` Standalone []*Standalone `json:"standalone,omitempty"` // from is the sender this Record was decoded from, unexported so it // never reaches the wire and never changes what a Record marshals to. // It exists so a read-modify-write through Put carries forward the // fields Record has no name for: an ordinary rotate must not clear a // PerInstance flag or a TenantARN that something else wrote. from *Sender } // sender renders this record as the app sender, preserving whatever the // record was decoded from. A Record built from nothing (Declare's first // call) gets a fresh app sender. func (r *Record) sender() *Sender { sn := r.from if sn == nil { sn = &Sender{ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain} } sn.Schema = Schema sn.Declared, sn.Grant, sn.Provisioned, sn.Standalone = r.Declared, r.Grant, r.Provisioned, r.Standalone return sn } // record is the inverse: the four blocks as the walks read them, with the // sender kept for the write back. func (s *Sender) record(app string) *Record { return &Record{Schema: Schema, App: app, Declared: s.Declared, Grant: s.Grant, Provisioned: s.Provisioned, Standalone: s.Standalone, from: s} } // legacySender renders a Record decoded from LegacyKey as the app sender: // the pre-senders object IS the app sender, with the identity fields it // never carried filled in. func (r *Record) legacySender() *Sender { return &Sender{Schema: Schema, ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain, Declared: r.Declared, Grant: r.Grant, Provisioned: r.Provisioned, Standalone: r.Standalone} } // Key is the app sender's object. It moved from email.json to // email/senders/app.json when senders got names (design §5); LegacyKey is // still read, and still on disk, for one release. func Key(acct, app string) string { return SenderKey(acct, app, SenderAppID) } // LegacyKey is the pre-senders record. Read-only from here: Get falls back // to it and hands back an EMPTY etag when it did, so the writer's Put is a // create by construction and a lost race is ErrCASConflict exactly as it is // today. It is left in place, unwritten, until SweepLegacyRecords deletes it // a release later. A console rolled back after the first sender write loses // the writes made in between, which is in the roll notes. func LegacyKey(acct, app string) string { return release.AppPrefix(acct, app) + "email.json" } var envPrefixRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,31}$`) func ValidEnvPrefix(p string) bool { return envPrefixRe.MatchString(p) } // EnvNames renders the delivered variable names for a prefix. The five // names are exactly what harbourmail already reads, which is what makes the // v1 criterion a zero-diff proof (spec §1). func EnvNames(prefix string) (host, port, user, pass, from string) { return prefix + "_HOST", prefix + "_PORT", prefix + "_USER", prefix + "_PASS", prefix + "_FROM" } // Get reads the app sender as a Record. It is the view every walk in // walk.go works with. Absent is (nil, "", nil): "no email" is a state, not // an error. // // The returned Record keeps a private handle on the Sender it was decoded // from, so a read-modify-write through Put preserves the fields Record has // no name for (PerInstance, TenantARN, Identity, Address, Salt). Without // that, an ordinary rotate would silently clear a flag step 3 or step 5 // wrote. func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) { sn, etag, err := GetSender(ctx, s, acct, app, SenderAppID) if err != nil || sn == nil { return nil, "", err } return sn.record(app), etag, nil } // Put writes the app sender at the caller's CAS position; etag "" is // create-only. It ALWAYS writes the sender key, never the legacy one. func claimUnserializedPut(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) { return PutSender(ctx, s, acct, app, rec.sender(), etag) } // GetSender reads one sender. The app sender falls back to LegacyKey with an // empty etag; any other sender has no legacy form and is simply absent. func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sender, string, error) { _, strict := s.(ClaimMailStore) return getSender(ctx, s, acct, app, id, strict) } func StrictGet(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) { sn, etag, err := getSender(ctx, s, acct, app, SenderAppID, true) if err != nil || sn == nil { return nil, "", err } return sn.record(app), etag, nil } func getSender(ctx context.Context, s release.Store, acct, app, id string, strict bool) (*Sender, string, error) { if !release.ValidAccount(acct) { return nil, "", fmt.Errorf("mail: invalid account %q", acct) } if !release.ValidApp(app) { return nil, "", fmt.Errorf("mail: invalid app %q", app) } if !ValidSenderID(id) { return nil, "", fmt.Errorf("mail: invalid sender id %q", id) } raw, etag, err := s.Get(ctx, SenderKey(acct, app, id)) if err == nil { var sn Sender if err := json.Unmarshal(raw, &sn); err != nil { return nil, "", fmt.Errorf("mail: %s: %w", SenderKey(acct, app, id), err) } if strict && (sn.Schema < 0 || sn.Schema > Schema || sn.ID != id || (id == SenderAppID && (sn.Scope != ScopeApp || sn.Host != "" || sn.Source != SourceDomain)) || (id != SenderAppID && sn.Scope != ScopeInstance)) { return nil, "", ErrDomainInventory } return &sn, etag, nil } if !errors.Is(err, os.ErrNotExist) { return nil, "", err } if id != SenderAppID { return nil, "", nil } raw, _, err = s.Get(ctx, LegacyKey(acct, app)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, "", nil } return nil, "", err } var rec Record if err := json.Unmarshal(raw, &rec); err != nil { return nil, "", fmt.Errorf("mail: %s: %w", LegacyKey(acct, app), err) } if strict && (rec.Schema < 0 || rec.Schema > Schema || rec.App != app) { return nil, "", ErrDomainInventory } // The empty etag is the whole of the migration's safety: it makes the // caller's Put a create, so two consoles racing the first sender write // cannot both win. return rec.legacySender(), "", nil } // PutSender writes one sender at the caller's CAS position. func claimUnserializedPutSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, etag string) (string, error) { if !ValidSenderID(sn.ID) { return "", fmt.Errorf("mail: invalid sender id %q", sn.ID) } sn.Schema = Schema if sn.Scope == "" { sn.Scope = ScopeApp } if sn.Source == "" { sn.Source = SourceDomain } body, err := json.MarshalIndent(sn, "", " ") if err != nil { return "", err } return s.Put(ctx, SenderKey(acct, app, sn.ID), append(body, '\n'), etag) } // ListSenders reads every sender an app has, app sender first and the rest // by id. An app that has never been migrated answers with the one sender its // legacy record describes, because the status reply reads this on every call // and an unmigrated app must not read as an app with no senders at all. func ListSenders(ctx context.Context, s release.Store, acct, app string) ([]*Sender, error) { keys, err := s.List(ctx, SendersPrefix(acct, app)) if err != nil { return nil, err } appKey := SenderKey(acct, app, SenderAppID) var rest []*Sender var appSender *Sender // appSenderErr is set only when the app sender's OWN key came back from // List (so the object exists) but its Get errored. That is not the same // as the key being absent, and must not be treated as one: falling // through to GetSender's legacy-record fallback below would read // email.json, which after migration is the STALE pre-migration state — // exactly what the status reply and the resume loop must not mistake // for the current Active/PauseKind. A sibling sender's Get error is not // tracked here; only the app sender feeds the legacy fallback. var appSenderErr error for _, k := range keys { if !strings.HasSuffix(k, ".json") { continue } raw, _, err := s.Get(ctx, k) if err != nil { if k == appKey { appSenderErr = err } continue } var sn Sender if json.Unmarshal(raw, &sn) != nil { continue } if sn.ID == SenderAppID { appSender = &sn continue } rest = append(rest, &sn) } if appSender == nil { if appSenderErr != nil { return nil, fmt.Errorf("mail: %s: %w", appKey, appSenderErr) } appSender, _, err = GetSender(ctx, s, acct, app, SenderAppID) if err != nil { return nil, err } } sort.Slice(rest, func(i, j int) bool { return rest[i].ID < rest[j].ID }) out := make([]*Sender, 0, len(rest)+1) if appSender != nil { out = append(out, appSender) } return append(out, rest...), nil } func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedPut(ctx, s, acct, app, rec, etag) } var claimResult0 string claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { var err error claimResult0, err = claimUnserializedPut(ctx, s, acct, app, rec, etag) return err }) }) return claimResult0, claimErr } func PutSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, etag string) (string, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedPutSender(ctx, s, acct, app, sn, etag) } var claimResult0 string claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { var err error claimResult0, err = claimUnserializedPutSender(ctx, s, acct, app, sn, etag) return err }) }) return claimResult0, claimErr }