carlos / platform Public

mail: the app sender is senders/app.json, with email.json as a read fallback (plan 2, task 3)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 9a950b359a18e6a4332b4b7081cf13ee4461078a parent 0d82c54
3 files changed, +262 −17
  • internal/mail/mail.go +157 −16
  • internal/mail/mail_test.go +98 −1
  • internal/mail/walk.go +7 −0
diff --git a/internal/mail/mail.go b/internal/mail/mail.go
index ad4ce8c7a..7a8ba18f1 100644
--- a/internal/mail/mail.go
+++ b/internal/mail/mail.go
@@ -20,6 +20,8 @@ import (
"fmt"
"os"
"regexp"
+ "sort"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/release"
@@ -100,8 +102,10 @@ type Standalone struct {
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.
+// Record is the view every walk in walk.go works with — three blocks for
+// three actors, plus the standalone credentials, which have their own
+// lifetime. It used to be the object at Key; now it is the four blocks of
+// the app sender, read and written through GetSender/PutSender.
type Record struct {
Schema int `json:"schema"`
App string `json:"app"`
@@ -109,9 +113,55 @@ type Record struct {
Grant *GrantBlock `json:"grant,omitempty"`
Provisioned *Provisioned `json:"provisioned,omitempty"`
Standalone []*Standalone `json:"standalone,omitempty"`
+
+ // from is the sender this Record was decoded from, unexported so it
+ // never reaches the wire and never changes what a Record marshals to.
+ // It exists so a read-modify-write through Put carries forward the
+ // fields Record has no name for: an ordinary rotate must not clear a
+ // PerInstance flag or a TenantARN that something else wrote.
+ from *Sender
+}
+
+// sender renders this record as the app sender, preserving whatever the
+// record was decoded from. A Record built from nothing (Declare's first
+// call) gets a fresh app sender.
+func (r *Record) sender() *Sender {
+ sn := r.from
+ if sn == nil {
+ sn = &Sender{ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain}
+ }
+ sn.Schema = Schema
+ sn.Declared, sn.Grant, sn.Provisioned, sn.Standalone = r.Declared, r.Grant, r.Provisioned, r.Standalone
+ return sn
+}
+
+// record is the inverse: the four blocks as the walks read them, with the
+// sender kept for the write back.
+func (s *Sender) record(app string) *Record {
+ return &Record{Schema: Schema, App: app, Declared: s.Declared, Grant: s.Grant,
+ Provisioned: s.Provisioned, Standalone: s.Standalone, from: s}
+}
+
+// legacySender renders a Record decoded from LegacyKey as the app sender:
+// the pre-senders object IS the app sender, with the identity fields it
+// never carried filled in.
+func (r *Record) legacySender() *Sender {
+ return &Sender{Schema: Schema, ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain,
+ Declared: r.Declared, Grant: r.Grant, Provisioned: r.Provisioned, Standalone: r.Standalone}
}
-func Key(acct, app string) string { return release.AppPrefix(acct, app) + "email.json" }
+// Key is the app sender's object. It moved from email.json to
+// email/senders/app.json when senders got names (design §5); LegacyKey is
+// still read, and still on disk, for one release.
+func Key(acct, app string) string { return SenderKey(acct, app, SenderAppID) }
+
+// LegacyKey is the pre-senders record. Read-only from here: Get falls back
+// to it and hands back an EMPTY etag when it did, so the writer's Put is a
+// create by construction and a lost race is ErrCASConflict exactly as it is
+// today. It is left in place, unwritten, until SweepLegacyRecords deletes it
+// a release later. A console rolled back after the first sender write loses
+// the writes made in between, which is in the roll notes.
+func LegacyKey(acct, app string) string { return release.AppPrefix(acct, app) + "email.json" }
var envPrefixRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,31}$`)
@@ -125,16 +175,55 @@ func EnvNames(prefix string) (host, port, user, pass, from string) {
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.
+// Get reads the app sender as a Record — the view every walk in walk.go
+// works with. Absent is (nil, "", nil): "no email" is a state, not an error.
+//
+// The returned Record keeps a private handle on the Sender it was decoded
+// from, so a read-modify-write through Put preserves the fields Record has
+// no name for (PerInstance, TenantARN, Identity, Address, Salt). Without
+// that, an ordinary rotate would silently clear a flag step 3 or step 5
+// wrote.
func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) {
+ sn, etag, err := GetSender(ctx, s, acct, app, SenderAppID)
+ if err != nil || sn == nil {
+ return nil, "", err
+ }
+ return sn.record(app), etag, nil
+}
+
+// Put writes the app sender at the caller's CAS position; etag "" is
+// create-only. It ALWAYS writes the sender key, never the legacy one.
+func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) {
+ return PutSender(ctx, s, acct, app, rec.sender(), etag)
+}
+
+// GetSender reads one sender. The app sender falls back to LegacyKey with an
+// empty etag; any other sender has no legacy form and is simply absent.
+func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sender, 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 !ValidSenderID(id) {
+ return nil, "", fmt.Errorf("mail: invalid sender id %q", id)
+ }
+ raw, etag, err := s.Get(ctx, SenderKey(acct, app, id))
+ if err == nil {
+ var sn Sender
+ if err := json.Unmarshal(raw, &sn); err != nil {
+ return nil, "", fmt.Errorf("mail: %s: %w", SenderKey(acct, app, id), err)
+ }
+ return &sn, etag, nil
+ }
+ if !errors.Is(err, os.ErrNotExist) {
+ return nil, "", err
+ }
+ if id != SenderAppID {
+ return nil, "", nil
+ }
+ raw, _, err = s.Get(ctx, LegacyKey(acct, app))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, "", nil
@@ -143,20 +232,72 @@ func Get(ctx context.Context, s release.Store, acct, app string) (*Record, strin
}
var rec Record
if err := json.Unmarshal(raw, &rec); err != nil {
- return nil, "", fmt.Errorf("mail: %s: %w", Key(acct, app), err)
+ return nil, "", fmt.Errorf("mail: %s: %w", LegacyKey(acct, app), err)
}
- return &rec, etag, nil
+ // The empty etag is the whole of the migration's safety: it makes the
+ // caller's Put a create, so two consoles racing the first sender write
+ // cannot both win.
+ return rec.legacySender(), "", 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, "", " ")
+// PutSender writes one sender at the caller's CAS position.
+func PutSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, etag string) (string, error) {
+ if !ValidSenderID(sn.ID) {
+ return "", fmt.Errorf("mail: invalid sender id %q", sn.ID)
+ }
+ sn.Schema = Schema
+ if sn.Scope == "" {
+ sn.Scope = ScopeApp
+ }
+ if sn.Source == "" {
+ sn.Source = SourceDomain
+ }
+ body, err := json.MarshalIndent(sn, "", " ")
if err != nil {
return "", err
}
- return s.Put(ctx, Key(acct, app), append(body, '\n'), etag)
+ return s.Put(ctx, SenderKey(acct, app, sn.ID), append(body, '\n'), etag)
+}
+
+// ListSenders reads every sender an app has, app sender first and the rest
+// by id. An app that has never been migrated answers with the one sender its
+// legacy record describes, because the status reply reads this on every call
+// and an unmigrated app must not read as an app with no senders at all.
+func ListSenders(ctx context.Context, s release.Store, acct, app string) ([]*Sender, error) {
+ keys, err := s.List(ctx, SendersPrefix(acct, app))
+ if err != nil {
+ return nil, err
+ }
+ var rest []*Sender
+ var appSender *Sender
+ for _, k := range keys {
+ if !strings.HasSuffix(k, ".json") {
+ continue
+ }
+ raw, _, err := s.Get(ctx, k)
+ if err != nil {
+ continue
+ }
+ var sn Sender
+ if json.Unmarshal(raw, &sn) != nil {
+ continue
+ }
+ if sn.ID == SenderAppID {
+ appSender = &sn
+ continue
+ }
+ rest = append(rest, &sn)
+ }
+ if appSender == nil {
+ appSender, _, err = GetSender(ctx, s, acct, app, SenderAppID)
+ if err != nil {
+ return nil, err
+ }
+ }
+ sort.Slice(rest, func(i, j int) bool { return rest[i].ID < rest[j].ID })
+ out := make([]*Sender, 0, len(rest)+1)
+ if appSender != nil {
+ out = append(out, appSender)
+ }
+ return append(out, rest...), nil
}
diff --git a/internal/mail/mail_test.go b/internal/mail/mail_test.go
index e5f6314e7..80acdcdec 100644
--- a/internal/mail/mail_test.go
+++ b/internal/mail/mail_test.go
@@ -1,8 +1,10 @@
package mail
import (
+ "bytes"
"context"
"encoding/json"
+ "errors"
"strings"
"testing"
"time"
@@ -11,9 +13,14 @@ import (
)
func TestKeyIsUnderTheAppPrefix(t *testing.T) {
- if got, want := Key("bag", "harbourmail"), "apps/bag/harbourmail/email.json"; got != want {
+ if got, want := Key("bag", "harbourmail"), "apps/bag/harbourmail/email/senders/app.json"; got != want {
t.Fatalf("Key = %q, want %q", got, want)
}
+ // The pre-senders object keeps its name, because Get still reads it and
+ // the sweep still has to find it.
+ if got, want := LegacyKey("bag", "harbourmail"), "apps/bag/harbourmail/email.json"; got != want {
+ t.Fatalf("LegacyKey = %q, want %q", got, want)
+ }
}
func TestEnvNamesMatchHarbourmailsContract(t *testing.T) {
@@ -103,3 +110,93 @@ func containsFold(haystack, needle string) bool {
return len(needle) > 0 && len(haystack) >= len(needle) &&
strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
}
+
+// The migration (design §5). Get prefers senders/app.json and falls back to
+// email.json; when it served the fallback it returns an EMPTY etag, so every
+// writer's Put — and they all pass back the etag they were given — is a
+// create by construction, and a lost race is ErrCASConflict exactly as it is
+// today.
+func TestGetFallsBackToEmailJSONWithAnEmptyETag(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
+
+ legacy := &Record{Schema: Schema, App: "harbourmail",
+ Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "owner@example.test"},
+ Provisioned: &Provisioned{IAMUser: "carlos-mail-bag-harbourmail", KeyID: "AKIA1", Active: true, At: now},
+ }
+ body, err := json.MarshalIndent(legacy, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, LegacyKey("bag", "harbourmail"), append(body, '\n'), ""); err != nil {
+ t.Fatal(err)
+ }
+
+ rec, etag, err := Get(ctx, s, "bag", "harbourmail")
+ if err != nil || rec == nil {
+ t.Fatalf("Get = %+v %v", rec, err)
+ }
+ if etag != "" {
+ t.Fatalf("etag = %q, want empty so the next Put is a create", etag)
+ }
+ if rec.Provisioned.KeyID != "AKIA1" {
+ t.Fatalf("the fallback lost the record: %+v", rec)
+ }
+
+ // Put always writes the sender key and never the legacy one.
+ if _, err := Put(ctx, s, "bag", "harbourmail", rec, etag); err != nil {
+ t.Fatalf("Put after the fallback: %v", err)
+ }
+ if _, _, err := s.Get(ctx, Key("bag", "harbourmail")); err != nil {
+ t.Fatalf("senders/app.json was not written: %v", err)
+ }
+ // email.json is left in place, unwritten, for one release.
+ raw, _, err := s.Get(ctx, LegacyKey("bag", "harbourmail"))
+ if err != nil || !bytes.Contains(raw, []byte("AKIA1")) {
+ t.Fatalf("email.json was disturbed: %q %v", raw, err)
+ }
+
+ // Once the sender object exists it is preferred, with a real etag.
+ rec2, etag2, err := Get(ctx, s, "bag", "harbourmail")
+ if err != nil || rec2 == nil || etag2 == "" {
+ t.Fatalf("Get after the migration = %+v %q %v", rec2, etag2, err)
+ }
+ if rec2.Provisioned.KeyID != "AKIA1" || rec2.Declared.From != "noreply@x.com" {
+ t.Fatalf("the migrated record lost a block: %+v", rec2)
+ }
+ // A second writer still holding the fallback's empty etag loses the race
+ // rather than clobbering the migrated object.
+ if _, err := Put(ctx, s, "bag", "harbourmail", rec, ""); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("a create over an existing sender = %v, want ErrCASConflict", err)
+ }
+}
+
+// An app with neither object is "no email", a state and not an error.
+func TestGetWithNothingWrittenIsNotAnError(t *testing.T) {
+ rec, etag, err := Get(context.Background(), release.NewDirStore(t.TempDir()), "bag", "harbourmail")
+ if rec != nil || etag != "" || err != nil {
+ t.Fatalf("Get = %+v %q %v", rec, etag, err)
+ }
+}
+
+// ListSenders answers for an app that has never been migrated, because the
+// status reply reads it on every call and an unmigrated app must not look
+// like an app with no senders at all.
+func TestListSendersAnswersFromTheLegacyRecord(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
+ body, _ := json.Marshal(&Record{Schema: Schema, App: "harbourmail",
+ Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "o@example.test"}})
+ if _, err := s.Put(ctx, LegacyKey("bag", "harbourmail"), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err := ListSenders(ctx, s, "bag", "harbourmail")
+ if err != nil || len(got) != 1 {
+ t.Fatalf("ListSenders = %+v %v, want one sender", got, err)
+ }
+ if got[0].ID != SenderAppID || got[0].Scope != ScopeApp || got[0].Source != SourceDomain {
+ t.Fatalf("the legacy record did not read back as the app sender: %+v", got[0])
+ }
+}
diff --git a/internal/mail/walk.go b/internal/mail/walk.go
index d36e58c9d..89355d076 100644
--- a/internal/mail/walk.go
+++ b/internal/mail/walk.go
@@ -1294,9 +1294,16 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writ
}
if rec != nil {
+ // Both objects: the sender the app is on, and the legacy one the
+ // migration deliberately leaves standing until the sweep. Leaving
+ // email.json behind here would resurrect the app's whole email
+ // record on the next Get.
if err := s.Delete(ctx, Key(acct, app)); err != nil {
return err
}
+ if err := s.Delete(ctx, LegacyKey(acct, app)); err != nil {
+ return err
+ }
}
return nil
}