carlos / platform Public

mail: the app record, apps/<acct>/<app>/email.json

Three blocks for three actors plus standalone credentials, CAS
read-modify-write, mirroring objstore. Standalone credentials carry their
own IAM user because IAM's two-keys-per-user ceiling is already spent on
the delivered key and the rotation overlap. A test asserts on the
marshalled bytes that no secret is ever in the record.
Paul Campbell 1c03a192148f48d4135a1f11573cf4eab32fa554 parent 69fbb14
2 files changed, +247 −0
  • internal/mail/mail.go +142 −0
  • internal/mail/mail_test.go +105 −0
diff --git a/internal/mail/mail.go b/internal/mail/mail.go
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)
+}
diff --git a/internal/mail/mail_test.go b/internal/mail/mail_test.go
new file mode 100644
index 000000000..01f2644ca
--- /dev/null
+++ b/internal/mail/mail_test.go
@@ -0,0 +1,105 @@
+package mail
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestKeyIsUnderTheAppPrefix(t *testing.T) {
+ if got, want := Key("bag", "seapointish"), "apps/bag/seapointish/email.json"; got != want {
+ t.Fatalf("Key = %q, want %q", got, want)
+ }
+}
+
+func TestEnvNamesMatchSeapointishsContract(t *testing.T) {
+ // The v1 criterion is zero seapointish code changes, and seapointish
+ // reads exactly these five names (internal/app/signinflow.go). If this
+ // test changes, the criterion is no longer met.
+ host, port, user, pass, from := EnvNames("SEAPOINTISH_SMTP")
+ for _, c := range []struct{ got, want string }{
+ {host, "SEAPOINTISH_SMTP_HOST"}, {port, "SEAPOINTISH_SMTP_PORT"},
+ {user, "SEAPOINTISH_SMTP_USER"}, {pass, "SEAPOINTISH_SMTP_PASS"},
+ {from, "SEAPOINTISH_SMTP_FROM"},
+ } {
+ if c.got != c.want {
+ t.Fatalf("EnvNames gave %q, want %q", c.got, c.want)
+ }
+ }
+}
+
+func TestValidEnvPrefixAcceptsSeapointishAndRefusesJunk(t *testing.T) {
+ for _, ok := range []string{"CARLOS_SMTP", "SEAPOINTISH_SMTP", "A"} {
+ if !ValidEnvPrefix(ok) {
+ t.Fatalf("ValidEnvPrefix(%q) = false, want true", ok)
+ }
+ }
+ for _, bad := range []string{"", "lower", "1LEADING", "HAS-DASH", "THIS_PREFIX_IS_WAY_TOO_LONG_TO_BE_OK"} {
+ if ValidEnvPrefix(bad) {
+ t.Fatalf("ValidEnvPrefix(%q) = true, want false", bad)
+ }
+ }
+}
+
+func TestGetAbsentIsNotAnError(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ rec, etag, err := Get(context.Background(), s, "bag", "seapointish")
+ if err != nil || rec != nil || etag != "" {
+ t.Fatalf("Get on absent = (%v, %q, %v), want (nil, \"\", nil)", rec, etag, err)
+ }
+}
+
+func TestPutThenGetRoundTrips(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
+ in := &Record{Declared: &Declared{
+ EnvPrefix: "SEAPOINTISH_SMTP",
+ From: "noreply@seapointish.bag.oncarlos.com",
+ At: now, By: "paul@rslw.com",
+ }}
+ if _, err := Put(ctx, s, "bag", "seapointish", in, ""); err != nil {
+ t.Fatalf("Put: %v", err)
+ }
+ out, etag, err := Get(ctx, s, "bag", "seapointish")
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if etag == "" {
+ t.Fatal("Get returned an empty etag for a present record")
+ }
+ if out.Schema != Schema || out.App != "seapointish" {
+ t.Fatalf("Put did not stamp schema/app: %+v", out)
+ }
+ if out.Declared.EnvPrefix != "SEAPOINTISH_SMTP" {
+ t.Fatalf("declared did not round-trip: %+v", out.Declared)
+ }
+}
+
+// The secret must never be in the record. This asserts on the marshalled
+// BYTES rather than the struct, because a future field added with the wrong
+// json tag would pass a struct-level check and still leak (spec §4).
+func TestRecordBytesCarryNoSecret(t *testing.T) {
+ rec := &Record{Provisioned: &Provisioned{
+ IAMUser: "carlos-mail-bag-seapointish",
+ KeyID: "AKIAEXAMPLE",
+ Active: true,
+ }}
+ body, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ for _, forbidden := range []string{"secret", "password", "pass"} {
+ if containsFold(string(body), forbidden) {
+ t.Fatalf("record JSON mentions %q: %s", forbidden, body)
+ }
+ }
+}
+
+func containsFold(haystack, needle string) bool {
+ return len(needle) > 0 && len(haystack) >= len(needle) &&
+ strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
+}