carlos / platform Public

mail: SenderOfCaller parses first and reads the index only for a slug (plan 2, task 6)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 6fbedc274cff7a227922e265cb149a277099a3ef parent 91cf688
2 files changed, +706 −0
  • internal/mail/sender.go +412 −0
  • internal/mail/sender_test.go +294 −0
diff --git a/internal/mail/sender.go b/internal/mail/sender.go
new file mode 100644
index 000000000..6c99c6967
--- /dev/null
+++ b/internal/mail/sender.go
@@ -0,0 +1,412 @@
+// The sender (senders design section 5): a From address, a credential, a
+// cap, a pause and a set of counts, owned by one record.
+//
+// Before this file an app had exactly one implicit sender and everything was
+// keyed by (account, app): email.json, the IAM user, the DailyCap, the pause,
+// the mail_counts rows. Two requests do not fit that shape (#147 sends as an
+// address under an identity CARLOS does not own, #148 gives one instance per
+// customer its own domain, credential, cap and pause), so the sender becomes
+// a thing with a name. This step gives every app exactly ONE sender, "app",
+// which is what it already had. Nothing here creates a second one.
+package mail
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base32"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The two scopes and the two sources a sender can have. Scope says what the
+// sender belongs to; Source says where its authority to send comes from. An
+// app sender on a domain the account verified is the only combination this
+// release produces; the others are named here because SenderNames, the
+// status reply and the caller index all branch on them, and a closed set
+// that is written down once is a set nothing invents a third member of.
+const (
+ ScopeApp = "app"
+ ScopeInstance = "instance"
+
+ SourceDomain = "domain"
+ SourceAddress = "address"
+)
+
+// SenderAppID is the id of the sender every app has always had. It is a
+// constant rather than a literal because it is written into every migrated
+// record, read back by SenderOfCaller and compared in a dozen places.
+const SenderAppID = "app"
+
+// PauseKindPerInstance is the pause an app sender carries once its app has
+// moved to per-instance senders (design section 6.3). It is never a cap pause and
+// never auto-resumes: resumeStaleCapPause reactivates every key it touches,
+// and re-arming the app credential that every instance still holds is
+// exactly what per-instance mode exists to prevent. Nothing in this release
+// sets it; the refusals that read it are in place so step 5 cannot land
+// without them.
+const PauseKindPerInstance = "per-instance"
+
+// Sender is apps/<acct>/<app>/email/senders/<id>.json, one object per
+// sender, each with its own CAS etag so two writers never contend over a
+// sibling's state.
+type Sender struct {
+ Schema int `json:"schema"`
+ ID string `json:"id"` // "app", or provision.InstanceID(host)
+ Slug string `json:"slug"` // the AWS-safe name component, see SenderNames
+ Scope string `json:"scope"` // ScopeApp | ScopeInstance
+ Host string `json:"host,omitempty"`
+ Source string `json:"source"` // SourceDomain | SourceAddress
+
+ // The four blocks keep their current shapes and move inside the sender
+ // unchanged, which is what lets a legacy email.json decode straight into
+ // one and what keeps every walk in walk.go compiling against Record.
+ Declared *Declared `json:"declared,omitempty"`
+ Grant *GrantBlock `json:"grant,omitempty"`
+ Provisioned *Provisioned `json:"provisioned,omitempty"`
+ Standalone []*Standalone `json:"standalone,omitempty"`
+
+ Identity string `json:"identity,omitempty"` // external identity ARN (#147, step 3)
+ Address string `json:"address,omitempty"` // the exact address, lowercased (#147, step 3)
+ TenantARN string `json:"tenant_arn,omitempty"` // from CreateTenant
+
+ // PerInstance marks an app sender whose app has moved to per-instance
+ // senders (design section 6.3). Nothing in this release sets it. It lives on
+ // the app sender rather than being derived from a prefix listing
+ // because a listing cannot decide the question without racing an
+ // app-sender enable, and the transition has to be one CAS write.
+ PerInstance bool `json:"per_instance,omitempty"`
+
+ // Salt disambiguates a slug collision. The slug is 40 bits of a hash, so
+ // two senders can in principle want the same IAM user name; the caller
+ // index detects that and ResolveSenderNames recomputes the slug with a
+ // fresh salt, which has to be stored or the name would not be stable
+ // across the next enable.
+ Salt string `json:"salt,omitempty"`
+}
+
+// SendersPrefix is where an app's senders live. Inside apps/<acct>/<app>/ on
+// purpose: release.PurgeApp's prefix sweep reaches it, so a purged app
+// leaves no sender behind.
+func SendersPrefix(acct, app string) string {
+ return release.AppPrefix(acct, app) + "email/senders/"
+}
+
+func SenderKey(acct, app, id string) string { return SendersPrefix(acct, app) + id + ".json" }
+
+// senderIDRe is deliberately stricter than a host name: this string becomes
+// a bucket key segment and half an IAM user name, and the only safe place to
+// refuse a separator or a traversal is the one gate every caller passes
+// through. It accepts a host (provision.InstanceID returns the host itself
+// when it is short enough) and the underscore-prefixed digest form
+// InstanceID falls back to for a long one.
+var senderIDRe = regexp.MustCompile(`^_?[a-z0-9]([a-z0-9.-]{0,62}[a-z0-9])?$`)
+
+// ValidSenderID reports whether id is usable as a sender id. ".json" is
+// refused along with every other dotted suffix that would collide with the
+// key's own extension only incidentally: what actually refuses it is the
+// check below, which is why it is spelled out.
+func ValidSenderID(id string) bool {
+ if id == "" || len(id) > 64 || id == "." || id == ".." {
+ return false
+ }
+ if !senderIDRe.MatchString(id) {
+ return false
+ }
+ return !hasJSONSuffix(id)
+}
+
+func hasJSONSuffix(id string) bool { return len(id) > 5 && id[len(id)-5:] == ".json" }
+
+// The AWS bounds this file exists to respect. IAM caps a user name at 64
+// characters and SES caps a tenant name and a configuration-set name at 64
+// of [A-Za-z0-9_-]. The first draft of the senders design got this wrong:
+// carlos-mail-<acct>-<app>+i+<instance-id> reaches 104 characters when the
+// instance id is the host itself, which it is for any host of 40 characters
+// or less.
+const (
+ iamUserNameMax = 64
+ sesNameMax = 64
+ // senderAppComponentMax truncates the app name inside a slugged sender's
+ // names. 12 + 16 + 1 + 24 + 3 + 8 = 64 exactly, at the longest legal
+ // account and app. Uniqueness comes from the hash, not from the app
+ // component, so truncating it costs nothing but readability.
+ senderAppComponentMax = 24
+ // senderSlugLen is 40 bits of base32. Short enough to fit, long enough
+ // that a collision is rare, and detected either way by the caller index.
+ senderSlugLen = 8
+)
+
+// ErrNameTooLong is the refusal at create for a name no AWS bound can hold.
+// It is refused rather than truncated because a truncated name would
+// collide silently with its neighbour, and because the only name this can
+// happen to today is a standalone credential's, whose name the member chose
+// and can choose differently.
+var ErrNameTooLong = errors.New("mail: name exceeds the AWS bound")
+
+// SenderNameSet is every AWS name one sender owns.
+type SenderNameSet struct {
+ Slug string // "" for the app sender, 8 base32 characters otherwise
+ IAMUser string
+ Tenant string
+ ConfigSet string
+}
+
+// SenderNames is THE rule for what a sender's AWS resources are called, and
+// there is exactly one of it so that a bounds test can enumerate every form.
+//
+// The app sender's names are unchanged, deliberately and permanently:
+// carlos-mail-<acct>-<app>, tenant and configuration set carlos-<acct>.
+// Renaming an SES tenant means destroying it and recreating it along with
+// every association it carries, and rewriting an IAM user name means a new
+// principal with the old one left live and stale. So the rule is that
+// existing shapes never change and a name that would exceed a bound is
+// refused at create.
+//
+// Configuration sets stay per account even for a slugged sender: an identity
+// has only one default set, and SES has no tenant-level default, so
+// per-sender sets on a shared identity are impossible (design ยง3).
+func SenderNames(acct, app, senderID, salt string) (SenderNameSet, error) {
+ if !release.ValidAccount(acct) {
+ return SenderNameSet{}, fmt.Errorf("mail: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return SenderNameSet{}, fmt.Errorf("mail: invalid app %q", app)
+ }
+ if !ValidSenderID(senderID) {
+ return SenderNameSet{}, fmt.Errorf("mail: invalid sender id %q", senderID)
+ }
+ n := SenderNameSet{Tenant: TenantName(acct), ConfigSet: ConfigSetName(acct)}
+ if senderID == SenderAppID {
+ n.IAMUser = UserName(acct, app)
+ return n, checkNames(n)
+ }
+ n.Slug = senderSlug(acct, app, senderID, salt)
+ short := app
+ if len(short) > senderAppComponentMax {
+ short = short[:senderAppComponentMax]
+ }
+ n.IAMUser = "carlos-mail-" + acct + "-" + short + "+i+" + n.Slug
+ n.Tenant = "carlos-" + acct + "-" + short + "-" + n.Slug
+ return n, checkNames(n)
+}
+
+// StandaloneUser is the name of one displayed credential belonging to this
+// sender, bound-checked. The rendered string is byte-identical to
+// StandaloneUserName for an app sender, because RefreshPolicies recomputes
+// the name rather than reading sa.IAMUser (walk.go:364): a rule that
+// rendered anything else would write a policy for a principal that does not
+// exist and leave the live one stale.
+func (n SenderNameSet) StandaloneUser(acct, app, credential string) (string, error) {
+ if !validStandaloneName(credential) {
+ return "", fmt.Errorf("mail: invalid credential name %q", credential)
+ }
+ name := StandaloneUserName(acct, app, credential)
+ if n.Slug != "" {
+ name = n.IAMUser + "+s+" + credential
+ }
+ if len(name) > iamUserNameMax {
+ return "", fmt.Errorf("mail: credential name %q makes the IAM user %d characters, over IAM's %d: choose a shorter name: %w",
+ credential, len(name), iamUserNameMax, ErrNameTooLong)
+ }
+ return name, nil
+}
+
+func checkNames(n SenderNameSet) error {
+ if len(n.IAMUser) > iamUserNameMax {
+ return fmt.Errorf("mail: IAM user %q is %d characters, over IAM's %d: %w", n.IAMUser, len(n.IAMUser), iamUserNameMax, ErrNameTooLong)
+ }
+ for _, name := range []string{n.Tenant, n.ConfigSet} {
+ if len(name) > sesNameMax {
+ return fmt.Errorf("mail: SES name %q is %d characters, over SES's %d: %w", name, len(name), sesNameMax, ErrNameTooLong)
+ }
+ }
+ return nil
+}
+
+// senderSlug is 40 bits of base32 over (account, app, sender, salt),
+// lowercased so it reads like every other name this package renders. Nothing
+// parses it back, which is why the caller index exists.
+func senderSlug(acct, app, id, salt string) string {
+ sum := sha256.Sum256([]byte(acct + "/" + app + "/" + id + "/" + salt))
+ enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:])
+ return strings.ToLower(enc[:senderSlugLen])
+}
+
+// callersPrefix is the reverse index from an IAM caller name to the sender
+// that owns it. GLOBAL, not under apps/<acct>/<app>/, because SES hands the
+// ingest a bare user name with no account attached and the ingest has
+// nowhere to start looking. The price of that placement is that
+// release.PurgeApp's prefix sweep never reaches it, so every entry has an
+// explicit delete: without one, a stale entry would attribute SES events to
+// a purged app and insert counts for it.
+const callersPrefix = "email/callers/"
+
+func CallerKey(iamUser string) string { return callersPrefix + iamUser + ".json" }
+
+// CallerIndex is the wire shape at CallerKey(IAMUser).
+type CallerIndex struct {
+ Schema int `json:"schema"` // 1
+ IAMUser string `json:"iam_user"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Sender string `json:"sender"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// ErrCallerClaimed means this IAM name already belongs to a different
+// sender. For a slugged name that is a hash collision and
+// ResolveSenderNames recomputes with a salt; for an unslugged one it is a
+// genuine conflict and the caller surfaces it.
+var ErrCallerClaimed = errors.New("mail: caller name belongs to another sender")
+
+// GetCallerIndex reads one entry. Absent is (nil, nil), matching every other
+// read in this package.
+func GetCallerIndex(ctx context.Context, s release.Store, iamUser string) (*CallerIndex, error) {
+ raw, _, err := s.Get(ctx, CallerKey(iamUser))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var c CallerIndex
+ if err := json.Unmarshal(raw, &c); err != nil {
+ return nil, fmt.Errorf("mail: caller index %s: %w", iamUser, err)
+ }
+ if c.Schema > 1 {
+ return nil, fmt.Errorf("mail: caller index %s: schema %d is newer than this binary understands, upgrade carlos", iamUser, c.Schema)
+ }
+ return &c, nil
+}
+
+// PutCallerIndex claims iamUser for one sender. Create-only, with
+// claimDomain's rule (domainclaim.go:119): the same triple is idempotent
+// success, any other triple is ErrCallerClaimed.
+//
+// Written right AFTER EnsureUser, which runs on every enable, so a
+// re-enable converges it. Writing it before MintKey would be no better and
+// writing it before EnsureUser would be worse: a crash between the index
+// write and the user's creation would strand the name against a principal
+// that does not exist.
+func PutCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string, now time.Time) error {
+ body, err := json.Marshal(CallerIndex{Schema: 1, IAMUser: iamUser, Account: acct, App: app, Sender: sender, CreatedAt: now})
+ if err != nil {
+ return err
+ }
+ for attempt := 0; ; attempt++ {
+ cur, err := GetCallerIndex(ctx, s, iamUser)
+ if err != nil {
+ return err
+ }
+ if cur == nil {
+ if _, err := s.Put(ctx, CallerKey(iamUser), body, ""); err == nil {
+ return nil
+ } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
+ continue // lost the create race, re-read and judge the winner
+ } else {
+ return err
+ }
+ }
+ if cur.Account != acct || cur.App != app || cur.Sender != sender {
+ return ErrCallerClaimed
+ }
+ return nil // already ours
+ }
+}
+
+// DeleteCallerIndex removes an entry, but only when it is held by this
+// sender, the same owner gate releaseDomainClaim applies. An entry already
+// absent is a quiet no-op, so a retried teardown is not an error.
+func DeleteCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string) error {
+ cur, err := GetCallerIndex(ctx, s, iamUser)
+ if err != nil || cur == nil {
+ return err
+ }
+ if cur.Account != acct || cur.App != app || cur.Sender != sender {
+ return nil
+ }
+ return s.Delete(ctx, CallerKey(iamUser))
+}
+
+// ResolveSenderNames renders a sender's names and claims them in the caller
+// index, recomputing a slugged name with a fresh salt on a collision.
+//
+// It reports whether the salt moved, because a moved salt has to be written
+// back to the sender record under its own CAS or the next enable computes a
+// different name again and the estate grows an orphan.
+func ResolveSenderNames(ctx context.Context, s release.Store, acct, app string, sn *Sender, now time.Time) (SenderNameSet, bool, error) {
+ salted := false
+ for attempt := 0; attempt < 4; attempt++ {
+ names, err := SenderNames(acct, app, sn.ID, sn.Salt)
+ if err != nil {
+ return SenderNameSet{}, salted, err
+ }
+ err = PutCallerIndex(ctx, s, names.IAMUser, acct, app, sn.ID, now)
+ if err == nil {
+ sn.Slug = names.Slug
+ return names, salted, nil
+ }
+ if !errors.Is(err, ErrCallerClaimed) {
+ return SenderNameSet{}, salted, err
+ }
+ if names.Slug == "" {
+ // An unslugged name is deterministic, so a conflict here is not
+ // a hash collision: another account or app is genuinely using
+ // this principal, and a salt would not help.
+ return SenderNameSet{}, salted, fmt.Errorf("mail: IAM user %s is already claimed by another sender: %w", names.IAMUser, err)
+ }
+ buf := make([]byte, 4)
+ if _, err := rand.Read(buf); err != nil {
+ return SenderNameSet{}, salted, err
+ }
+ sn.Salt = hex.EncodeToString(buf)
+ salted = true
+ }
+ return SenderNameSet{}, salted, fmt.Errorf("mail: could not find a free IAM name for sender %s/%s/%s after four attempts", acct, app, sn.ID)
+}
+
+// SenderOfCaller resolves the IAM identity SES stamps on an event
+// (mail.tags["ses:caller-identity"]) to the sender that owns it.
+//
+// PARSE FIRST, in two steps, and the order is load-bearing. AppOfCaller
+// answers for carlos-mail-<acct>-<app> and its +s+ standalones, which is
+// every sender that exists today, and it costs nothing. Only a slugged +i+
+// name, which is a hash and unparseable by construction, reaches the index
+// Get. Every app enabled today short-circuits Enable before MintKey
+// (walk.go:677), so it has no index entry until it next rotates: a Get-only
+// resolver would silently stop writing the by_sender log rows that
+// sesevents.go writes now, for every app in the fleet, on the roll.
+//
+// An unreadable index entry is logged and answers "unknown", which puts the
+// event back on the domain-wide fallback rather than dropping it.
+func SenderOfCaller(ctx context.Context, s release.Store, caller string) (string, string, string, bool) {
+ if acct, app, ok := AppOfCaller(caller); ok {
+ return acct, app, SenderAppID, true
+ }
+ if !strings.Contains(caller, "+i+") {
+ return "", "", "", false
+ }
+ name := caller
+ if i := strings.LastIndex(name, "/"); i >= 0 {
+ name = name[i+1:]
+ }
+ idx, err := GetCallerIndex(ctx, s, name)
+ if err != nil {
+ log.Printf("mail: caller index %s -> %v", name, err)
+ return "", "", "", false
+ }
+ if idx == nil || !release.ValidAccount(idx.Account) || !release.ValidApp(idx.App) || !ValidSenderID(idx.Sender) {
+ return "", "", "", false
+ }
+ return idx.Account, idx.App, idx.Sender, true
+}
diff --git a/internal/mail/sender_test.go b/internal/mail/sender_test.go
new file mode 100644
index 000000000..f9c39ac1f
--- /dev/null
+++ b/internal/mail/sender_test.go
@@ -0,0 +1,294 @@
+package mail
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The app sender is the shape every app has had since before senders
+// existed, written under its own key. The four blocks move inside it
+// unchanged, so a Sender decodes the same Declared/Grant/Provisioned/
+// Standalone an email.json object carries.
+func TestSenderCarriesTheFourBlocksUnchanged(t *testing.T) {
+ now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
+ sn := &Sender{
+ Schema: Schema, ID: SenderAppID, Slug: "", Scope: ScopeApp, Source: SourceDomain,
+ Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "owner@example.test"},
+ Grant: &GrantBlock{Domains: []string{"x.com"}, DailyCap: 50, Source: "tier:free", At: now, By: "tier"},
+ Provisioned: &Provisioned{IAMUser: "carlos-mail-bag-hm", KeyID: "AKIA1", Active: true, At: now},
+ Standalone: []*Standalone{{Name: "laptop", IAMUser: "carlos-mail-bag-hm+s+laptop", KeyID: "AKIA2", At: now, By: "owner@example.test"}},
+ }
+ body, err := json.Marshal(sn)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Nothing this sender does not carry appears on the wire: per_instance,
+ // host, address, identity, tenant_arn and salt are all omitempty, so an
+ // app sender's object is the four blocks plus its own identity.
+ for _, absent := range []string{"per_instance", "host", "address", "identity", "tenant_arn", "salt"} {
+ if strings.Contains(string(body), absent) {
+ t.Errorf("an app sender's wire form carries %q: %s", absent, body)
+ }
+ }
+ var back Sender
+ if err := json.Unmarshal(body, &back); err != nil {
+ t.Fatal(err)
+ }
+ if back.Declared.EnvPrefix != "HARBOURMAIL_SMTP" || back.Grant.DailyCap != 50 ||
+ back.Provisioned.KeyID != "AKIA1" || len(back.Standalone) != 1 || back.Standalone[0].Name != "laptop" {
+ t.Fatalf("round trip lost a block: %+v", back)
+ }
+ if back.ID != SenderAppID || back.Scope != ScopeApp || back.Source != SourceDomain {
+ t.Fatalf("round trip lost the sender's identity: %+v", back)
+ }
+}
+
+func TestSenderKeysSitUnderTheAppsOwnPrefix(t *testing.T) {
+ if got := SendersPrefix("bag", "harbourmail"); got != "apps/bag/harbourmail/email/senders/" {
+ t.Fatalf("SendersPrefix = %q", got)
+ }
+ if got := SenderKey("bag", "harbourmail", SenderAppID); got != "apps/bag/harbourmail/email/senders/app.json" {
+ t.Fatalf("SenderKey = %q", got)
+ }
+ // An id is a bucket key segment, so a traversal or a separator is
+ // refused at the one gate every caller passes through.
+ for _, bad := range []string{"", "..", "a/b", "A", "x.json", strings.Repeat("a", 65)} {
+ if ValidSenderID(bad) {
+ t.Errorf("ValidSenderID(%q) = true", bad)
+ }
+ }
+ for _, good := range []string{"app", "book.example.com", "_carlos-1-abc123"} {
+ if !ValidSenderID(good) {
+ t.Errorf("ValidSenderID(%q) = false", good)
+ }
+ }
+}
+
+// The bounds, enumerated. SES caps a tenant name and a configuration-set
+// name at 64 characters of [A-Za-z0-9_-]; IAM caps a user name at 64. An
+// account sqid is at most 16 lowercase letters and an app name at most 32
+// of [a-z0-9-], so the app sender always fits and the instance sender only
+// fits because the app component is truncated to 24 with the hash carrying
+// uniqueness.
+func TestSenderNamesEnumeratesTheBounds(t *testing.T) {
+ const longAcct = "abcdefghijklmnop" // 16, the longest legal account
+ const longApp = "a123456789012345678901234567890b" // 32, the longest legal app
+ if len(longAcct) != 16 || len(longApp) != 32 {
+ t.Fatalf("the fixture names are not at the bound: %d %d", len(longAcct), len(longApp))
+ }
+
+ // The app sender's names are today's names, byte for byte.
+ app, err := SenderNames(longAcct, longApp, SenderAppID, "")
+ if err != nil {
+ t.Fatalf("app sender at the bound: %v", err)
+ }
+ if app.Slug != "" {
+ t.Errorf("app sender slug = %q, want empty", app.Slug)
+ }
+ if app.IAMUser != UserName(longAcct, longApp) {
+ t.Errorf("IAMUser = %q, want %q โ€” an existing name must never change", app.IAMUser, UserName(longAcct, longApp))
+ }
+ if app.Tenant != TenantName(longAcct) || app.ConfigSet != ConfigSetName(longAcct) {
+ t.Errorf("tenant/set = %q/%q, want %q/%q", app.Tenant, app.ConfigSet, TenantName(longAcct), ConfigSetName(longAcct))
+ }
+ if len(app.IAMUser) > 64 || len(app.Tenant) > 64 || len(app.ConfigSet) > 64 {
+ t.Errorf("app sender over a bound: %d/%d/%d", len(app.IAMUser), len(app.Tenant), len(app.ConfigSet))
+ }
+
+ // The instance sender at the same bound. 12 + 16 + 1 + 24 + 3 + 8 = 64.
+ inst, err := SenderNames(longAcct, longApp, "book.customer.example.com", "")
+ if err != nil {
+ t.Fatalf("instance sender at the bound: %v", err)
+ }
+ if len(inst.Slug) != 8 {
+ t.Fatalf("slug = %q, want 8 characters", inst.Slug)
+ }
+ if len(inst.IAMUser) != 64 {
+ t.Fatalf("IAMUser = %q (%d), want exactly 64 at the bound", inst.IAMUser, len(inst.IAMUser))
+ }
+ if !strings.Contains(inst.IAMUser, "+i+"+inst.Slug) {
+ t.Errorf("IAMUser = %q, want it to end in +i+<slug>", inst.IAMUser)
+ }
+ if len(inst.Tenant) > 64 || !strings.HasSuffix(inst.Tenant, "-"+inst.Slug) {
+ t.Errorf("tenant = %q (%d)", inst.Tenant, len(inst.Tenant))
+ }
+ for _, name := range []string{inst.IAMUser, inst.Tenant, inst.ConfigSet} {
+ for _, r := range name {
+ if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyz0123456789-_+.", r) {
+ t.Errorf("name %q holds %q, which SES or IAM does not accept", name, r)
+ }
+ }
+ }
+
+ // The slug is a function of (account, app, sender, salt) and nothing
+ // else, so a re-enable computes the same name.
+ again, _ := SenderNames(longAcct, longApp, "book.customer.example.com", "")
+ if again.Slug != inst.Slug {
+ t.Fatalf("slug is not stable: %q then %q", inst.Slug, again.Slug)
+ }
+ salted, _ := SenderNames(longAcct, longApp, "book.customer.example.com", "0f0f")
+ if salted.Slug == inst.Slug {
+ t.Fatal("a salt did not move the slug, so a collision could never be resolved")
+ }
+
+ // A standalone credential's name is unchanged where it fits and refused
+ // where it does not. carlos-mail- (12) + 16 + 1 + 32 + +s+ (3) + 31
+ // reaches 95 against IAM's 64, which nothing checked before this.
+ if _, err := app.StandaloneUser(longAcct, longApp, "a123456789012345678901234567890"); !errors.Is(err, ErrNameTooLong) {
+ t.Fatalf("the longest standalone name on the longest app = %v, want ErrNameTooLong", err)
+ }
+ short, err := SenderNames("bag", "hm", SenderAppID, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := short.StandaloneUser("bag", "hm", "laptop")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != StandaloneUserName("bag", "hm", "laptop") {
+ t.Fatalf("StandaloneUser = %q, want %q โ€” RefreshPolicies recomputes this name and must get the live one",
+ got, StandaloneUserName("bag", "hm", "laptop"))
+ }
+}
+
+// The caller index is create-only with the domain claim's idempotence rule:
+// the same (account, app, sender) is success, a different one is a
+// collision. It is what makes a slugged IAM user name resolvable back to
+// the sender that owns it, since nothing parses a hash.
+func TestCallerIndexIsCreateOnlyAndIdempotentForItsOwnSender(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
+ const user = "carlos-mail-bag-hm+i+abcd1234"
+
+ if err := PutCallerIndex(ctx, s, user, "bag", "hm", "book.example.com", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := PutCallerIndex(ctx, s, user, "bag", "hm", "book.example.com", now); err != nil {
+ t.Fatalf("a re-enable of the same sender was refused by the index it holds: %v", err)
+ }
+ if err := PutCallerIndex(ctx, s, user, "bag", "hm", "other.example.com", now); !errors.Is(err, ErrCallerClaimed) {
+ t.Fatalf("a different sender at the same name = %v, want ErrCallerClaimed", err)
+ }
+ got, err := GetCallerIndex(ctx, s, user)
+ if err != nil || got == nil || got.Account != "bag" || got.App != "hm" || got.Sender != "book.example.com" {
+ t.Fatalf("GetCallerIndex = %+v %v", got, err)
+ }
+
+ // Deletion is owner-gated, the way releaseDomainClaim is: a stranger
+ // cannot remove an entry it does not hold.
+ if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "someone.else"); err != nil {
+ t.Fatalf("a mismatched delete should be a quiet no-op: %v", err)
+ }
+ if got, _ := GetCallerIndex(ctx, s, user); got == nil {
+ t.Fatal("a mismatched delete removed the entry")
+ }
+ if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "book.example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := GetCallerIndex(ctx, s, user); got != nil {
+ t.Fatalf("the entry survived its own delete: %+v", got)
+ }
+ // Deleting again is a quiet no-op, so a retried teardown is not an error.
+ if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "book.example.com"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// A 40-bit slug needs collision detection, and the index is where it
+// happens: the losing sender recomputes its slug with a salt that is stored
+// on its record so the name stays stable across the next enable.
+func TestResolveSenderNamesRetriesACollisionWithASalt(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
+
+ sn := &Sender{ID: "book.example.com", Scope: ScopeInstance, Source: SourceDomain, Host: "book.example.com"}
+ first, err := SenderNames("bag", "hm", sn.ID, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Somebody else is already at the unsalted name.
+ if err := PutCallerIndex(ctx, s, first.IAMUser, "bag", "other", "book.example.com", now); err != nil {
+ t.Fatal(err)
+ }
+
+ names, salted, err := ResolveSenderNames(ctx, s, "bag", "hm", sn, now)
+ if err != nil {
+ t.Fatalf("ResolveSenderNames: %v", err)
+ }
+ if !salted {
+ t.Fatal("the collision did not report that the record must be written back with its new salt")
+ }
+ if sn.Salt == "" {
+ t.Fatal("no salt was stored on the record, so the next enable would collide again")
+ }
+ if names.IAMUser == first.IAMUser {
+ t.Fatalf("the slug did not move: %q", names.IAMUser)
+ }
+ got, err := GetCallerIndex(ctx, s, names.IAMUser)
+ if err != nil || got == nil || got.App != "hm" {
+ t.Fatalf("the index at the new name = %+v %v", got, err)
+ }
+ // The app sender takes the same path and never needs a salt.
+ appSender := &Sender{ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain}
+ appNames, saltedApp, err := ResolveSenderNames(ctx, s, "bag", "hm", appSender, now)
+ if err != nil || saltedApp || appSender.Salt != "" {
+ t.Fatalf("the app sender was salted: %+v %v %v", appNames, saltedApp, err)
+ }
+ if appNames.IAMUser != UserName("bag", "hm") {
+ t.Fatalf("the app sender's name changed: %q", appNames.IAMUser)
+ }
+}
+
+// Parse first, index second. Every app enabled today short-circuits Enable
+// before MintKey, so it has no index entry until it next rotates: a
+// Get-only resolver would silently stop attributing their events the moment
+// it shipped.
+func TestSenderOfCallerParsesBeforeItReads(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
+
+ // No index entry exists at all, and the delivered and standalone names
+ // still resolve, to the app sender.
+ for _, caller := range []string{
+ UserName("bag", "harbourmail"),
+ StandaloneUserName("bag", "harbourmail", "laptop"),
+ "arn:aws:iam::123456789012:user/carlos-mail/carlos-mail-bag-harbourmail",
+ } {
+ acct, app, sender, ok := SenderOfCaller(ctx, s, caller)
+ if !ok || acct != "bag" || app != "harbourmail" || sender != SenderAppID {
+ t.Errorf("SenderOfCaller(%q) = %q %q %q %v", caller, acct, app, sender, ok)
+ }
+ }
+
+ // A name nobody CARLOS minted stays unknown, and costs no read.
+ for _, caller := range []string{"", "someone-else", "carlos-mail-bag-harbourmail+s+"} {
+ if _, _, _, ok := SenderOfCaller(ctx, s, caller); ok {
+ t.Errorf("SenderOfCaller(%q) claimed to know it", caller)
+ }
+ }
+
+ // A slugged name is unparseable by construction and comes from the index.
+ names, err := SenderNames("bag", "harbourmail", "book.example.com", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, _, ok := SenderOfCaller(ctx, s, names.IAMUser); ok {
+ t.Fatal("a slugged name resolved before it was ever indexed")
+ }
+ if err := PutCallerIndex(ctx, s, names.IAMUser, "bag", "harbourmail", "book.example.com", now); err != nil {
+ t.Fatal(err)
+ }
+ acct, app, sender, ok := SenderOfCaller(ctx, s, names.IAMUser)
+ if !ok || acct != "bag" || app != "harbourmail" || sender != "book.example.com" {
+ t.Fatalf("SenderOfCaller(slug) = %q %q %q %v", acct, app, sender, ok)
+ }
+}