carlos / platform Public

mail: the caller reverse index, written after EnsureUser and deleted with the principal (plan 2, task 5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 69c2c89c890aa2b7b3beaa9c7e67baa7ee0e8ac4 parent 7e032cf
3 files changed, +265 −1
  • internal/mail/sender.go +139 −0
  • internal/mail/sender_test.go +93 −0
  • internal/mail/walk.go +33 −1
diff --git a/internal/mail/sender.go b/internal/mail/sender.go
index 2da420d08..cc186bcc1 100644
--- a/internal/mail/sender.go
+++ b/internal/mail/sender.go
@@ -11,12 +11,18 @@
package mail
import (
+ "context"
+ "crypto/rand"
"crypto/sha256"
"encoding/base32"
+ "encoding/hex"
+ "encoding/json"
"errors"
"fmt"
+ "os"
"regexp"
"strings"
+ "time"
"github.com/carlosframework/platform/internal/release"
)
@@ -234,3 +240,136 @@ func senderSlug(acct, app, id, salt string) string {
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)
+}
diff --git a/internal/mail/sender_test.go b/internal/mail/sender_test.go
index d1f22ba6e..245b55464 100644
--- a/internal/mail/sender_test.go
+++ b/internal/mail/sender_test.go
@@ -1,11 +1,14 @@
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
@@ -153,3 +156,93 @@ func TestSenderNamesEnumeratesTheBounds(t *testing.T) {
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)
+ }
+}
diff --git a/internal/mail/walk.go b/internal/mail/walk.go
index 89355d076..a69f85dd9 100644
--- a/internal/mail/walk.go
+++ b/internal/mail/walk.go
@@ -671,6 +671,13 @@ func Enable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P
if err != nil {
return nil, fmt.Errorf("mail: ensure user %s: %w", user, err)
}
+ // The reverse index, right after EnsureUser and before MintKey (design
+ // §5). EnsureUser runs on every enable, so a re-enable converges the
+ // index; putting it after MintKey would strand a sender forever when a
+ // crash landed between the two.
+ if err := PutCallerIndex(ctx, s, user, acct, app, SenderAppID, now); err != nil {
+ return nil, fmt.Errorf("mail: index caller %s: %w", user, err)
+ }
// Idempotence short-circuit: governs ONLY the expensive mint-and-deliver
// half from here down.
@@ -1029,10 +1036,22 @@ func CreateStandalone(ctx context.Context, s release.Store, p Provisioner, d Dep
sort.Strings(configSets)
sort.Strings(names)
- user := StandaloneUserName(acct, app, name)
+ senderNames, err := SenderNames(acct, app, SenderAppID, "")
+ if err != nil {
+ return StandaloneCreds{}, err
+ }
+ user, err := senderNames.StandaloneUser(acct, app, name)
+ if err != nil {
+ return StandaloneCreds{}, err
+ }
if _, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names); err != nil {
return StandaloneCreds{}, err
}
+ // A standalone credential sends for the app sender, so its caller name
+ // resolves to the same sender the delivered credential does.
+ if err := PutCallerIndex(ctx, s, user, acct, app, SenderAppID, now); err != nil {
+ return StandaloneCreds{}, fmt.Errorf("mail: index caller %s: %w", user, err)
+ }
// A re-mint for the same name replaces the credential rather than
// stacking one: two slots is still the ceiling here too.
if err := p.ReapKeys(ctx, user, nil); err != nil {
@@ -1096,6 +1115,9 @@ func RevokeStandalone(ctx context.Context, s release.Store, p Provisioner, acct,
for _, sa := range rec.Standalone {
if sa.Name == name {
found = true
+ if err := DeleteCallerIndex(ctx, s, sa.IAMUser, acct, app, SenderAppID); err != nil {
+ return err
+ }
if err := p.DeleteUser(ctx, sa.IAMUser); err != nil {
return err
}
@@ -1138,11 +1160,21 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writ
}
if rec != nil {
if rec.Provisioned != nil {
+ // The index sits outside apps/<acct>/<app>/, so PurgeApp's
+ // prefix sweep never reaches it. Without this delete a stale
+ // entry would attribute SES events to a purged app and insert
+ // counts for it.
+ if err := DeleteCallerIndex(ctx, s, rec.Provisioned.IAMUser, acct, app, SenderAppID); err != nil {
+ return err
+ }
if err := p.DeleteUser(ctx, rec.Provisioned.IAMUser); err != nil {
return err
}
}
for _, sa := range rec.Standalone {
+ if err := DeleteCallerIndex(ctx, s, sa.IAMUser, acct, app, SenderAppID); err != nil {
+ return err
+ }
if err := p.DeleteUser(ctx, sa.IAMUser); err != nil {
return err
}