// The one-shot sweep that finishes the senders migration (design ยง5). package mail import ( "context" "errors" "fmt" "log" "os" "path" "strings" "github.com/carlosframework/platform/internal/release" ) // SweepLegacyRecords deletes apps///email.json for every app that // already has a senders/app.json, and nothing else. // // It is guarded by a flag that defaults OFF, and it is written now rather // than a release from now because the thing that makes it safe is knowing, // while the migration is fresh in view, exactly which object it may remove. // The rule is one sentence: a legacy record is deletable when, and only // when, the sender object that replaced it exists. An unmigrated app's // email.json is still the only copy of its email and is left alone, so a // deployment that turns the flag on early loses nothing. // // A failure on one app is logged and the sweep continues: one unreadable // object must not stop the other five hundred, and every deletion is // independent. // // It walks with ListDirs, twice, and never with List. List(ctx, "apps/") on // a production bucket returns every object under every app, every release // artifact of every version of every app, to find the handful of keys // ending in /email.json, and it would do it at console start. ListDirs is // the delimiter-aware listing (release/store.go:50-58): one page of // accounts, one page of apps per account, then two Gets per app. func SweepLegacyRecords(ctx context.Context, s release.Store, enabled bool) (int, error) { if !enabled { return 0, nil } accts, err := s.ListDirs(ctx, "apps/") if err != nil { return 0, fmt.Errorf("mail: sweep legacy records: %w", err) } deleted := 0 for _, acctPrefix := range accts { // ListDirs hands back a full prefix ending in "/", never a bare // name, so the account is the last segment of it. acct := path.Base(strings.TrimSuffix(acctPrefix, "/")) if !release.ValidAccount(acct) { continue } apps, err := s.ListDirs(ctx, acctPrefix) if err != nil { log.Printf("mail: sweep legacy records: listing %s -> %v", acctPrefix, err) continue } for _, appPrefix := range apps { app := path.Base(strings.TrimSuffix(appPrefix, "/")) if !release.ValidApp(app) { continue } if _, _, err := s.Get(ctx, SenderKey(acct, app, SenderAppID)); err != nil { if !errors.Is(err, os.ErrNotExist) { log.Printf("mail: sweep legacy records: reading %s -> %v", SenderKey(acct, app, SenderAppID), err) } continue // not migrated: this object is still the only copy } legacy := LegacyKey(acct, app) if _, _, err := s.Get(ctx, legacy); err != nil { if !errors.Is(err, os.ErrNotExist) { log.Printf("mail: sweep legacy records: reading %s -> %v", legacy, err) } continue // nothing to remove, which is the steady state } if err := s.Delete(ctx, legacy); err != nil { log.Printf("mail: sweep legacy records: deleting %s -> %v", legacy, err) continue } deleted++ } } return deleted, nil }