| new file mode 100644 |
| index 000000000..0d4a8611d |
| --- /dev/null |
| +++ b/internal/mail/mail.go |
| @@ -0,0 +1,142 @@ |
| +// 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" |
| + "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"` |
| + At time.Time `json:"at"` |
| +} |
| + |
| +// 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 apps/<acct>/<app>/email.json — three blocks for three actors, |
| +// plus the standalone credentials, which have their own lifetime. |
| +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"` |
| +} |
| + |
| +func Key(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 seapointish 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 record. Absent is (nil, "", nil) — "no email" is a state, |
| +// not an error, the same idiom objstore.Get uses. |
| +func Get(ctx context.Context, s release.Store, acct, app string) (*Record, 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) |
| + } |
| + raw, etag, err := s.Get(ctx, Key(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", Key(acct, app), err) |
| + } |
| + return &rec, etag, nil |
| +} |
| + |
| +// Put writes the record at the caller's CAS position; etag "" is |
| +// create-only. Every writer is a read-modify-write, so a lost race is |
| +// release.ErrCASConflict for the caller to surface, never to absorb. |
| +func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) { |
| + rec.Schema = Schema |
| + rec.App = app |
| + body, err := json.MarshalIndent(rec, "", " ") |
| + if err != nil { |
| + return "", err |
| + } |
| + return s.Put(ctx, Key(acct, app), append(body, '\n'), etag) |
| +} |