carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
14package mail
15
16import (
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
30const Schema = 1
31
32// DefaultEnvPrefix names the delivered env set when a declaration does not
33// choose its own.
34const DefaultEnvPrefix = "CARLOS_SMTP"
35
36// Declared is the member's half: the one delivery knob and the From address.
37type 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).
48type 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).
59type 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.
83const (
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.
97type 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.
109type 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.
128func (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.
140func (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.
148func (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.
156func 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.
164func LegacyKey(acct, app string) string { return release.AppPrefix(acct, app) + "email.json" }
165
166var envPrefixRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,31}$`)
167
168func 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).
173func 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.
187func 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.
197func 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.
203func 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
208func 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
216func 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.
266func 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.
288func 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
343func 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
358func 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