carlos / platform Public

mail: SenderNames owns every AWS name and its bound (plan 2, task 2)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 0d82c5475940d5b416066f6bdb258c33edca6015 parent 3ecac24
2 files changed, +208 −0
  • internal/mail/sender.go +120 −0
  • internal/mail/sender_test.go +88 −0
diff --git a/internal/mail/sender.go b/internal/mail/sender.go
index 70e63ce50..2da420d08 100644
--- a/internal/mail/sender.go
+++ b/internal/mail/sender.go
@@ -11,7 +11,12 @@
package mail
import (
+ "crypto/sha256"
+ "encoding/base32"
+ "errors"
+ "fmt"
"regexp"
+ "strings"
"github.com/carlosframework/platform/internal/release"
)
@@ -114,3 +119,118 @@ func ValidSenderID(id string) bool {
}
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])
+}
diff --git a/internal/mail/sender_test.go b/internal/mail/sender_test.go
index bdc0be20e..d1f22ba6e 100644
--- a/internal/mail/sender_test.go
+++ b/internal/mail/sender_test.go
@@ -2,6 +2,7 @@ package mail
import (
"encoding/json"
+ "errors"
"strings"
"testing"
"time"
@@ -65,3 +66,90 @@ func TestSenderKeysSitUnderTheAppsOwnPrefix(t *testing.T) {
}
}
}
+
+// 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"))
+ }
+}