| 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 |
| } |