carlos / platform Public

mail: the sender record, its scopes, sources and key shape (plan 2, task 1)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 6583f958ad8e719971944286b5bc890794c2fb0a parent 55b2566
2 files changed, +183 −0
  • internal/mail/sender.go +116 −0
  • internal/mail/sender_test.go +67 −0
diff --git a/internal/mail/sender.go b/internal/mail/sender.go
new file mode 100644
index 000000000..70e63ce50
--- /dev/null
+++ b/internal/mail/sender.go
@@ -0,0 +1,116 @@
+// 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 (
+ "regexp"
+
+ "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" }
diff --git a/internal/mail/sender_test.go b/internal/mail/sender_test.go
new file mode 100644
index 000000000..bdc0be20e
--- /dev/null
+++ b/internal/mail/sender_test.go
@@ -0,0 +1,67 @@
+package mail
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+// 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)
+ }
+ }
+}