| 1 | // Package mail is the tenant-email primitive: an app declares that it sends |
| 2 | // email, the platform provisions an SES identity on a domain it can prove, |
| 3 | // mints SMTP credentials scoped to exactly that identity, and delivers the |
| 4 | // coordinates as env. |
| 5 | // |
| 6 | // See docs/superpowers/specs/2026-08-19-tenant-email-design.md — §4 (what |
| 7 | // actually isolates one tenant from another: the IAM policy, not the SES |
| 8 | // tenant), §6 (these record shapes), §7 (DNS and the three irreversible |
| 9 | // choices), §9 (bounces, caps, the pause). |
| 10 | // |
| 11 | // The shape follows internal/objstore almost verb for verb, because it is |
| 12 | // the same problem with a scarcer resource: a bucket spends money, a |
| 13 | // sending identity spends reputation shared across every tenant. |
| 14 | package mail |
| 15 | |
| 16 | import ( |
| 17 | "context" |
| 18 | "encoding/json" |
| 19 | "errors" |
| 20 | "fmt" |
| 21 | "os" |
| 22 | "regexp" |
| 23 | "sort" |
| 24 | "strings" |
| 25 | "time" |
| 26 | |
| 27 | "github.com/carlosframework/platform/internal/release" |
| 28 | ) |
| 29 | |
| 30 | const Schema = 1 |
| 31 | |
| 32 | // DefaultEnvPrefix names the delivered env set when a declaration does not |
| 33 | // choose its own. |
| 34 | const DefaultEnvPrefix = "CARLOS_SMTP" |
| 35 | |
| 36 | // Declared is the member's half: the one delivery knob and the From address. |
| 37 | type Declared struct { |
| 38 | EnvPrefix string `json:"env_prefix"` |
| 39 | From string `json:"from"` |
| 40 | At time.Time `json:"at"` |
| 41 | By string `json:"by"` |
| 42 | } |
| 43 | |
| 44 | // GrantBlock is the authorisation half. Source records WHY the grant exists |
| 45 | // ("tier:free", "tier:pro", "operator") so a cap can be recomputed when a |
| 46 | // tier changes without silently discarding an operator's deliberate |
| 47 | // override (spec §10). |
| 48 | type GrantBlock struct { |
| 49 | Domains []string `json:"domains"` |
| 50 | DailyCap int `json:"daily_cap"` |
| 51 | Source string `json:"source"` |
| 52 | At time.Time `json:"at"` |
| 53 | By string `json:"by"` |
| 54 | } |
| 55 | |
| 56 | // Provisioned is the walk's half. KeyID is the SMTP USERNAME and is not a |
| 57 | // secret; the password is derived at delivery time and lands only in the |
| 58 | // app's sealed config (spec §4). Active is the pause state (spec §9). |
| 59 | type Provisioned struct { |
| 60 | IAMUser string `json:"iam_user"` |
| 61 | UserARN string `json:"user_arn"` |
| 62 | KeyID string `json:"key_id"` |
| 63 | PrevKeyID string `json:"prev_key_id,omitempty"` |
| 64 | Active bool `json:"active"` |
| 65 | // PauseKind distinguishes an automatic CAP pause from an automatic RATE |
| 66 | // pause and from a manual operator pause (I3, review follow-up) — |
| 67 | // additive field, so a record written before it existed decodes it as |
| 68 | // "", which correctly reads as "not a cap pause, do not auto-resume |
| 69 | // it". A CAP pause (PauseKindCap) is a PER-DAY limit and clears itself |
| 70 | // on the UTC day roll (internal/console/mailcounts.go's |
| 71 | // enforceMailLimits); a RATE pause (PauseKindRate) or a manual pause |
| 72 | // (PauseKind == "") never auto-resumes — SES's account-review |
| 73 | // thresholds are not a daily allowance, and a manual pause is an |
| 74 | // operator's decision to undo, not a timer's. Always cleared (set back |
| 75 | // to "") on resume, whatever resumed it. |
| 76 | PauseKind string `json:"pause_kind,omitempty"` |
| 77 | At time.Time `json:"at"` |
| 78 | } |
| 79 | |
| 80 | // The two automatic pause kinds SetPaused records (spec §9, I3 review |
| 81 | // follow-up). A manual (operator-driven) pause carries neither — see |
| 82 | // Provisioned.PauseKind's own doc comment. |
| 83 | const ( |
| 84 | PauseKindCap = "cap" |
| 85 | PauseKindRate = "rate" |
| 86 | ) |
| 87 | |
| 88 | // Standalone is a displayed credential for something not running on CARLOS. |
| 89 | // |
| 90 | // It gets its OWN IAM principal, and that is forced rather than stylistic: |
| 91 | // IAM allows two access keys per user, and both slots are already spoken |
| 92 | // for by the delivered key plus PrevKeyID during a rotation overlap (the |
| 93 | // same ceiling internal/objstore/walk.go's Rotate documents). A standalone |
| 94 | // key on the shared user would appear to work and would make the first |
| 95 | // rotation impossible — the worst kind of coupling, because it surfaces |
| 96 | // only later and elsewhere. |
| 97 | type Standalone struct { |
| 98 | Name string `json:"name"` |
| 99 | IAMUser string `json:"iam_user"` |
| 100 | KeyID string `json:"key_id"` |
| 101 | At time.Time `json:"at"` |
| 102 | By string `json:"by"` |
| 103 | } |
| 104 | |
| 105 | // Record is the view every walk in walk.go works with. Three blocks for |
| 106 | // three actors, plus the standalone credentials, which have their own |
| 107 | // lifetime. It used to be the object at Key. Now it is the four blocks of |
| 108 | // the app sender, read and written through GetSender/PutSender. |
| 109 | type Record struct { |
| 110 | Schema int `json:"schema"` |
| 111 | App string `json:"app"` |
| 112 | Declared *Declared `json:"declared,omitempty"` |
| 113 | Grant *GrantBlock `json:"grant,omitempty"` |
| 114 | Provisioned *Provisioned `json:"provisioned,omitempty"` |
| 115 | Standalone []*Standalone `json:"standalone,omitempty"` |
| 116 | |
| 117 | // from is the sender this Record was decoded from, unexported so it |
| 118 | // never reaches the wire and never changes what a Record marshals to. |
| 119 | // It exists so a read-modify-write through Put carries forward the |
| 120 | // fields Record has no name for: an ordinary rotate must not clear a |
| 121 | // PerInstance flag or a TenantARN that something else wrote. |
| 122 | from *Sender |
| 123 | } |
| 124 | |
| 125 | // sender renders this record as the app sender, preserving whatever the |
| 126 | // record was decoded from. A Record built from nothing (Declare's first |
| 127 | // call) gets a fresh app sender. |
| 128 | func (r *Record) sender() *Sender { |
| 129 | sn := r.from |
| 130 | if sn == nil { |
| 131 | sn = &Sender{ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain} |
| 132 | } |
| 133 | sn.Schema = Schema |
| 134 | sn.Declared, sn.Grant, sn.Provisioned, sn.Standalone = r.Declared, r.Grant, r.Provisioned, r.Standalone |
| 135 | return sn |
| 136 | } |
| 137 | |
| 138 | // record is the inverse: the four blocks as the walks read them, with the |
| 139 | // sender kept for the write back. |
| 140 | func (s *Sender) record(app string) *Record { |
| 141 | return &Record{Schema: Schema, App: app, Declared: s.Declared, Grant: s.Grant, |
| 142 | Provisioned: s.Provisioned, Standalone: s.Standalone, from: s} |
| 143 | } |
| 144 | |
| 145 | // legacySender renders a Record decoded from LegacyKey as the app sender: |
| 146 | // the pre-senders object IS the app sender, with the identity fields it |
| 147 | // never carried filled in. |
| 148 | func (r *Record) legacySender() *Sender { |
| 149 | return &Sender{Schema: Schema, ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain, |
| 150 | Declared: r.Declared, Grant: r.Grant, Provisioned: r.Provisioned, Standalone: r.Standalone} |
| 151 | } |
| 152 | |
| 153 | // Key is the app sender's object. It moved from email.json to |
| 154 | // email/senders/app.json when senders got names (design §5); LegacyKey is |
| 155 | // still read, and still on disk, for one release. |
| 156 | func Key(acct, app string) string { return SenderKey(acct, app, SenderAppID) } |
| 157 | |
| 158 | // LegacyKey is the pre-senders record. Read-only from here: Get falls back |
| 159 | // to it and hands back an EMPTY etag when it did, so the writer's Put is a |
| 160 | // create by construction and a lost race is ErrCASConflict exactly as it is |
| 161 | // today. It is left in place, unwritten, until SweepLegacyRecords deletes it |
| 162 | // a release later. A console rolled back after the first sender write loses |
| 163 | // the writes made in between, which is in the roll notes. |
| 164 | func LegacyKey(acct, app string) string { return release.AppPrefix(acct, app) + "email.json" } |
| 165 | |
| 166 | var envPrefixRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,31}$`) |
| 167 | |
| 168 | func ValidEnvPrefix(p string) bool { return envPrefixRe.MatchString(p) } |
| 169 | |
| 170 | // EnvNames renders the delivered variable names for a prefix. The five |
| 171 | // names are exactly what harbourmail already reads, which is what makes the |
| 172 | // v1 criterion a zero-diff proof (spec §1). |
| 173 | func EnvNames(prefix string) (host, port, user, pass, from string) { |
| 174 | return prefix + "_HOST", prefix + "_PORT", prefix + "_USER", |
| 175 | prefix + "_PASS", prefix + "_FROM" |
| 176 | } |
| 177 | |
| 178 | // Get reads the app sender as a Record. It is the view every walk in |
| 179 | // walk.go works with. Absent is (nil, "", nil): "no email" is a state, not |
| 180 | // an error. |
| 181 | // |
| 182 | // The returned Record keeps a private handle on the Sender it was decoded |
| 183 | // from, so a read-modify-write through Put preserves the fields Record has |
| 184 | // no name for (PerInstance, TenantARN, Identity, Address, Salt). Without |
| 185 | // that, an ordinary rotate would silently clear a flag step 3 or step 5 |
| 186 | // wrote. |
| 187 | func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) { |
| 188 | sn, etag, err := GetSender(ctx, s, acct, app, SenderAppID) |
| 189 | if err != nil || sn == nil { |
| 190 | return nil, "", err |
| 191 | } |
| 192 | return sn.record(app), etag, nil |
| 193 | } |
| 194 | |
| 195 | // Put writes the app sender at the caller's CAS position; etag "" is |
| 196 | // create-only. It ALWAYS writes the sender key, never the legacy one. |
| 197 | func claimUnserializedPut(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) { |
| 198 | return PutSender(ctx, s, acct, app, rec.sender(), etag) |
| 199 | } |
| 200 | |
| 201 | // GetSender reads one sender. The app sender falls back to LegacyKey with an |
| 202 | // empty etag; any other sender has no legacy form and is simply absent. |
| 203 | func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sender, string, error) { |
| 204 | _, strict := s.(ClaimMailStore) |
| 205 | return getSender(ctx, s, acct, app, id, strict) |
| 206 | } |
| 207 | |
| 208 | func StrictGet(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) { |
| 209 | sn, etag, err := getSender(ctx, s, acct, app, SenderAppID, true) |
| 210 | if err != nil || sn == nil { |
| 211 | return nil, "", err |
| 212 | } |
| 213 | return sn.record(app), etag, nil |
| 214 | } |
| 215 | |
| 216 | func getSender(ctx context.Context, s release.Store, acct, app, id string, strict bool) (*Sender, string, error) { |
| 217 | if !release.ValidAccount(acct) { |
| 218 | return nil, "", fmt.Errorf("mail: invalid account %q", acct) |
| 219 | } |
| 220 | if !release.ValidApp(app) { |
| 221 | return nil, "", fmt.Errorf("mail: invalid app %q", app) |
| 222 | } |
| 223 | if !ValidSenderID(id) { |
| 224 | return nil, "", fmt.Errorf("mail: invalid sender id %q", id) |
| 225 | } |
| 226 | raw, etag, err := s.Get(ctx, SenderKey(acct, app, id)) |
| 227 | if err == nil { |
| 228 | var sn Sender |
| 229 | if err := json.Unmarshal(raw, &sn); err != nil { |
| 230 | return nil, "", fmt.Errorf("mail: %s: %w", SenderKey(acct, app, id), err) |
| 231 | } |
| 232 | if strict && (sn.Schema < 0 || sn.Schema > Schema || sn.ID != id || |
| 233 | (id == SenderAppID && (sn.Scope != ScopeApp || sn.Host != "" || sn.Source != SourceDomain)) || |
| 234 | (id != SenderAppID && sn.Scope != ScopeInstance)) { |
| 235 | return nil, "", ErrDomainInventory |
| 236 | } |
| 237 | return &sn, etag, nil |
| 238 | } |
| 239 | if !errors.Is(err, os.ErrNotExist) { |
| 240 | return nil, "", err |
| 241 | } |
| 242 | if id != SenderAppID { |
| 243 | return nil, "", nil |
| 244 | } |
| 245 | raw, _, err = s.Get(ctx, LegacyKey(acct, app)) |
| 246 | if err != nil { |
| 247 | if errors.Is(err, os.ErrNotExist) { |
| 248 | return nil, "", nil |
| 249 | } |
| 250 | return nil, "", err |
| 251 | } |
| 252 | var rec Record |
| 253 | if err := json.Unmarshal(raw, &rec); err != nil { |
| 254 | return nil, "", fmt.Errorf("mail: %s: %w", LegacyKey(acct, app), err) |
| 255 | } |
| 256 | if strict && (rec.Schema < 0 || rec.Schema > Schema || rec.App != app) { |
| 257 | return nil, "", ErrDomainInventory |
| 258 | } |
| 259 | // The empty etag is the whole of the migration's safety: it makes the |
| 260 | // caller's Put a create, so two consoles racing the first sender write |
| 261 | // cannot both win. |
| 262 | return rec.legacySender(), "", nil |
| 263 | } |
| 264 | |
| 265 | // PutSender writes one sender at the caller's CAS position. |
| 266 | func claimUnserializedPutSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, etag string) (string, error) { |
| 267 | if !ValidSenderID(sn.ID) { |
| 268 | return "", fmt.Errorf("mail: invalid sender id %q", sn.ID) |
| 269 | } |
| 270 | sn.Schema = Schema |
| 271 | if sn.Scope == "" { |
| 272 | sn.Scope = ScopeApp |
| 273 | } |
| 274 | if sn.Source == "" { |
| 275 | sn.Source = SourceDomain |
| 276 | } |
| 277 | body, err := json.MarshalIndent(sn, "", " ") |
| 278 | if err != nil { |
| 279 | return "", err |
| 280 | } |
| 281 | return s.Put(ctx, SenderKey(acct, app, sn.ID), append(body, '\n'), etag) |
| 282 | } |
| 283 | |
| 284 | // ListSenders reads every sender an app has, app sender first and the rest |
| 285 | // by id. An app that has never been migrated answers with the one sender its |
| 286 | // legacy record describes, because the status reply reads this on every call |
| 287 | // and an unmigrated app must not read as an app with no senders at all. |
| 288 | func ListSenders(ctx context.Context, s release.Store, acct, app string) ([]*Sender, error) { |
| 289 | keys, err := s.List(ctx, SendersPrefix(acct, app)) |
| 290 | if err != nil { |
| 291 | return nil, err |
| 292 | } |
| 293 | appKey := SenderKey(acct, app, SenderAppID) |
| 294 | var rest []*Sender |
| 295 | var appSender *Sender |
| 296 | // appSenderErr is set only when the app sender's OWN key came back from |
| 297 | // List (so the object exists) but its Get errored. That is not the same |
| 298 | // as the key being absent, and must not be treated as one: falling |
| 299 | // through to GetSender's legacy-record fallback below would read |
| 300 | // email.json, which after migration is the STALE pre-migration state — |
| 301 | // exactly what the status reply and the resume loop must not mistake |
| 302 | // for the current Active/PauseKind. A sibling sender's Get error is not |
| 303 | // tracked here; only the app sender feeds the legacy fallback. |
| 304 | var appSenderErr error |
| 305 | for _, k := range keys { |
| 306 | if !strings.HasSuffix(k, ".json") { |
| 307 | continue |
| 308 | } |
| 309 | raw, _, err := s.Get(ctx, k) |
| 310 | if err != nil { |
| 311 | if k == appKey { |
| 312 | appSenderErr = err |
| 313 | } |
| 314 | continue |
| 315 | } |
| 316 | var sn Sender |
| 317 | if json.Unmarshal(raw, &sn) != nil { |
| 318 | continue |
| 319 | } |
| 320 | if sn.ID == SenderAppID { |
| 321 | appSender = &sn |
| 322 | continue |
| 323 | } |
| 324 | rest = append(rest, &sn) |
| 325 | } |
| 326 | if appSender == nil { |
| 327 | if appSenderErr != nil { |
| 328 | return nil, fmt.Errorf("mail: %s: %w", appKey, appSenderErr) |
| 329 | } |
| 330 | appSender, _, err = GetSender(ctx, s, acct, app, SenderAppID) |
| 331 | if err != nil { |
| 332 | return nil, err |
| 333 | } |
| 334 | } |
| 335 | sort.Slice(rest, func(i, j int) bool { return rest[i].ID < rest[j].ID }) |
| 336 | out := make([]*Sender, 0, len(rest)+1) |
| 337 | if appSender != nil { |
| 338 | out = append(out, appSender) |
| 339 | } |
| 340 | return append(out, rest...), nil |
| 341 | } |
| 342 | |
| 343 | func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) { |
| 344 | if _, enabled := s.(ClaimMailStore); !enabled { |
| 345 | return claimUnserializedPut(ctx, s, acct, app, rec, etag) |
| 346 | } |
| 347 | var claimResult0 string |
| 348 | claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { |
| 349 | return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { |
| 350 | var err error |
| 351 | claimResult0, err = claimUnserializedPut(ctx, s, acct, app, rec, etag) |
| 352 | return err |
| 353 | }) |
| 354 | }) |
| 355 | return claimResult0, claimErr |
| 356 | } |
| 357 | |
| 358 | func PutSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, etag string) (string, error) { |
| 359 | if _, enabled := s.(ClaimMailStore); !enabled { |
| 360 | return claimUnserializedPutSender(ctx, s, acct, app, sn, etag) |
| 361 | } |
| 362 | var claimResult0 string |
| 363 | claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { |
| 364 | return claimMailAdmitted(ctx, s, acct, claimMailPlan{Apps: []string{app}}, func(ctx context.Context) error { |
| 365 | var err error |
| 366 | claimResult0, err = claimUnserializedPutSender(ctx, s, acct, app, sn, etag) |
| 367 | return err |
| 368 | }) |
| 369 | }) |
| 370 | return claimResult0, claimErr |
| 371 | } |
| 372 | |