console: add a read-only Email card to the app Settings tab
Mirrors StoreCard verb for verb (issue #240, tenant email's sibling primitive): a MailCard folds apps/<acct>/<app>/email.json into env prefix, From, granted sending domains, daily cap, SMTP username and key age, plus the paused/mid-rotation/standalone-credential states. Same error discipline as storeCard: a read failure returns nil so the page says nothing about email rather than guessing, and an absent record returns a zero card. Available (s.cfg.MailProvisioner != nil) decides which empty-state sentence the zero card renders — a deployment with no provisioner is told email isn't available here, never given a `carlos email enable` command that mailProvisionerGate would 404. No new routes or forms — read-only, no destroy door (none exists in the API).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+487
−0
internal/console/appsettings.go+122 −0internal/console/appsettings_test.go+301 −0internal/console/templates/app_settings.html+64 −0
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index 2d1755265..a023ed93a 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -38,6 +38,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -65,6 +66,12 @@ type SettingsView struct { |
| // "ask for one" line renders from. |
| Store *StoreCard |
| + // Mail is the app's tenant email as this page sees it (issue #240) — |
| + // object storage's sibling primitive, same declare -> provision -> |
| + // deliver -> rotate shape, and the same error discipline as Store: nil |
| + // on a read failure, a zero card when there is genuinely no record. |
| + Mail *MailCard |
| + |
| // Domains is this page's OWN Domains & TLS rows (settingsDomains): a |
| // copy of AppView.Domains with a placeholder row appended for any of |
| // this app's claims that have no registry row yet. Deliberately not |
| @@ -118,6 +125,62 @@ type StoreCard struct { |
| CanDestroy bool |
| } |
| +// MailCard is the Settings tab's view of apps/<acct>/<app>/email.json (issue |
| +// #240, mail.Record): tenant email's Settings card, mirroring StoreCard verb |
| +// for verb — same declare -> provision -> deliver -> rotate shape, same |
| +// read-only presentation, same error discipline (mailCard's doc comment). |
| +// |
| +// Available is NOT about this app's record at all — it answers whether this |
| +// DEPLOYMENT has email configured (s.cfg.MailProvisioner != nil), the same |
| +// class of question StoreCard.CanDestroy asks of s.cfg.StoreProvisioner. |
| +// Every email API door 404s when it is false (mailProvisionerGate, |
| +// apimail.go), so it decides which empty-state sentence the zero card gets: |
| +// "ask for one" only when the command being suggested could actually run. |
| +// |
| +// KeyAge carries StoreCard.KeyAge's exact rule: it is Provisioned.At's age, |
| +// labelled as the KEY's age everywhere, never as "provisioned" — a rotation |
| +// moves that timestamp forward without changing when email was first turned |
| +// on for this app. |
| +// |
| +// Domains is deliberately Grant.Domains, not a second read of |
| +// mail.ListDomains + VerifiedARNs. The email API's own enable door |
| +// (apimail.go's handleAPIEmailEnable) computes exactly this app's granted |
| +// domain set with mail.ApplyGrant BEFORE mail.Enable runs and stores it as |
| +// Grant.Domains — so Grant.Domains already IS "the verified domains granted |
| +// to this app", sitting on the record this function is reading anyway. |
| +// Re-deriving it from ListDomains would cost a second bucket listing and a |
| +// scan for Apps-contains-this-app to show a set no more true than the one |
| +// already on the record. |
| +type MailCard struct { |
| + Available bool |
| + |
| + Declared bool |
| + EnvPrefix string |
| + From string |
| + |
| + Granted bool |
| + Domains []string |
| + DailyCap int |
| + |
| + Provisioned bool |
| + KeyID string |
| + KeyAge string |
| + PendingFinish bool |
| + Paused bool |
| + PauseKind string |
| + |
| + Standalone []MailStandaloneView |
| +} |
| + |
| +// MailStandaloneView is one displayed-credential row (mail.Standalone). |
| +// Never carries a password — there is none on the record, and the card must |
| +// not invent a place to show one. |
| +type MailStandaloneView struct { |
| + Name string |
| + KeyID string |
| + Age string |
| +} |
| + |
| // assembleSettings fills the Settings tab's view. Best-effort by design: a |
| // bucket hiccup here blocks the rename form (fail closed — an unverifiable |
| // emptiness claim must not enable an identity change) and never the page. |
| @@ -170,6 +233,7 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) * |
| sv.CanRename = true |
| } |
| sv.Store = s.storeCard(ctx, acct, p.AppName) |
| + sv.Mail = s.mailCard(ctx, acct, p.AppName) |
| if ra := p.Detail.RouteAccount; ra != "" && p.Detail.AllMismatched { |
| sv.MismatchTarget = ra |
| if target, err := s.store.AccountBySqid(ra); err == nil && target != nil { |
| @@ -229,6 +293,64 @@ func (s *Server) storeCard(ctx context.Context, acct *Account, app string) *Stor |
| return card |
| } |
| +// mailCard folds the tenant-email record into the Settings card. Wears |
| +// storeCard's exact error discipline: a read failure returns nil — the page |
| +// says nothing about email at all rather than guessing — and an absent |
| +// record is a zero card, the shape the empty-state line renders from. |
| +// |
| +// Available is set FIRST, unconditionally, because it must be correct |
| +// whether or not there turns out to be a record: it is what the template |
| +// uses to choose between the two empty-state sentences the brief calls for |
| +// (one names a command, the other says this deployment has no email at |
| +// all), and a bucket read failure must not be allowed to hide which one |
| +// applies — hence it is read straight off s.cfg.MailProvisioner before the |
| +// record read even starts, and is the one field a nil return also throws |
| +// away, same as every other field storeCard discards on its own read error. |
| +func (s *Server) mailCard(ctx context.Context, acct *Account, app string) *MailCard { |
| + available := s.cfg.MailProvisioner != nil |
| + rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: settings mail read %s/%s: %v", acct.Sqid, app, err) |
| + return nil |
| + } |
| + if rec == nil { |
| + return &MailCard{Available: available} |
| + } |
| + card := &MailCard{Available: available} |
| + if d := rec.Declared; d != nil { |
| + card.Declared = true |
| + card.EnvPrefix = d.EnvPrefix |
| + card.From = d.From |
| + } |
| + if g := rec.Grant; g != nil { |
| + card.Granted = true |
| + card.Domains = g.Domains |
| + card.DailyCap = g.DailyCap |
| + } |
| + if pr := rec.Provisioned; pr != nil { |
| + card.Provisioned = true |
| + card.KeyID = pr.KeyID |
| + if !pr.At.IsZero() { |
| + card.KeyAge = humanAge(time.Since(pr.At)) + " ago" |
| + } |
| + card.PendingFinish = pr.PrevKeyID != "" |
| + // Active is the SENDING state, not the record's completeness — a |
| + // provisioned-but-paused app has a credential, just one that must |
| + // send nothing right now, which is exactly the sentence the page |
| + // has to make impossible to miss. |
| + card.Paused = !pr.Active |
| + card.PauseKind = pr.PauseKind |
| + } |
| + for _, sa := range rec.Standalone { |
| + row := MailStandaloneView{Name: sa.Name, KeyID: sa.KeyID} |
| + if !sa.At.IsZero() { |
| + row.Age = humanAge(time.Since(sa.At)) + " ago" |
| + } |
| + card.Standalone = append(card.Standalone, row) |
| + } |
| + return card |
| +} |
| + |
| // settingsDomains is the Settings page's own Domains & TLS rows: routed |
| // (the AppView's own DomainView slice, untouched) plus a placeholder row |
| // for any of this app's domain claims that routed has no entry for yet. |
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go| index 9c1985f31..0d668e52a 100644 |
| --- a/internal/console/appsettings_test.go |
| +++ b/internal/console/appsettings_test.go |
| @@ -1138,3 +1138,304 @@ func TestAssembleSettingsListsRoutedDomainsWithoutABucket(t *testing.T) { |
| t.Fatalf("Settings.Domains on a bucketless console = %+v, want kass's routed domain still listed", sv.Domains) |
| } |
| } |
| + |
| +// ---- Settings tab: the Email card (issue #240) ------------------------------ |
| +// |
| +// mailCard is object storage's sibling (storeCard, above) — same declare -> |
| +// grant -> provision -> rotate shape, same error discipline: a read failure |
| +// returns nil so the page says nothing about email at all rather than |
| +// guessing, and an absent record returns a zero card, the shape the |
| +// empty-state line renders from. Available is the one field that does NOT |
| +// come from the record at all: it is s.cfg.MailProvisioner != nil, read |
| +// before the record is, because it decides which of the two empty-state |
| +// sentences a zero card gets — and a deployment with no provisioner must |
| +// never be told to run a command that mailProvisionerGate would 404. |
| + |
| +// putMailRecord seeds apps/<acct>/<app>/email.json directly — the state the |
| +// card has to report on and the walk functions (Declare/ApplyGrant/Enable/ |
| +// SetPaused) would normally reach through their own doors. |
| +func putMailRecord(t *testing.T, fx *consoleFixture, app string, rec *mail.Record) { |
| + t.Helper() |
| + if _, err := mail.Put(context.Background(), fx.rel, fx.sqid, app, rec, ""); err != nil { |
| + t.Fatalf("seed mail record for %s: %v", app, err) |
| + } |
| +} |
| + |
| +// mailCardAccount is the fixture account as mailCard's Account parameter — |
| +// only Sqid is read, so a bare literal is enough; AccountBySqid's roundtrip |
| +// through the console database would prove nothing extra. |
| +func mailCardAccount(fx *consoleFixture) *Account { return &Account{Sqid: fx.sqid} } |
| + |
| +func TestMailCardStates(t *testing.T) { |
| + now := time.Now() |
| + cases := []struct { |
| + name string |
| + app string |
| + rec *mail.Record // nil = write nothing |
| + provisionerNil bool |
| + check func(t *testing.T, card *MailCard) |
| + }{ |
| + { |
| + name: "absent record", |
| + app: "mail-absent", |
| + check: func(t *testing.T, card *MailCard) { |
| + if card == nil { |
| + t.Fatal("absent record with no read error returned nil, want a zero card") |
| + } |
| + if !card.Available { |
| + t.Fatal("Available = false with a MailProvisioner configured") |
| + } |
| + if card.Declared || card.Granted || card.Provisioned || len(card.Standalone) != 0 { |
| + t.Fatalf("absent record produced a non-zero card: %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "declared only", |
| + app: "mail-declared", |
| + rec: &mail.Record{Declared: &mail.Declared{ |
| + EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now, By: "paul@keymail.dev", |
| + }}, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.Declared || card.EnvPrefix != "CARLOS_SMTP" || card.From != "hi@example.com" { |
| + t.Fatalf("declared-only card = %+v", card) |
| + } |
| + if card.Granted || card.Provisioned { |
| + t.Fatalf("declared-only card claims more than declared: %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "granted", |
| + app: "mail-granted", |
| + rec: &mail.Record{ |
| + Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, |
| + Grant: &mail.GrantBlock{Domains: []string{"example.com"}, DailyCap: 500, Source: "tier:free", At: now}, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.Granted || card.DailyCap != 500 { |
| + t.Fatalf("granted card = %+v", card) |
| + } |
| + if len(card.Domains) != 1 || card.Domains[0] != "example.com" { |
| + t.Fatalf("granted card domains = %+v, want [example.com]", card.Domains) |
| + } |
| + if card.Provisioned { |
| + t.Fatalf("granted-only card claims provisioned: %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "provisioned", |
| + app: "mail-provisioned", |
| + rec: &mail.Record{ |
| + Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, |
| + Grant: &mail.GrantBlock{Domains: []string{"example.com"}, DailyCap: 500, At: now}, |
| + Provisioned: &mail.Provisioned{KeyID: "AKIA1", Active: true, At: now.Add(-2 * time.Hour)}, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.Provisioned || card.KeyID != "AKIA1" { |
| + t.Fatalf("provisioned card = %+v", card) |
| + } |
| + if card.KeyAge == "" { |
| + t.Fatal("provisioned card has no KeyAge") |
| + } |
| + if card.Paused || card.PendingFinish { |
| + t.Fatalf("a healthy provisioned card reads paused or pending-finish: %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "mid-rotation", |
| + app: "mail-rotating", |
| + rec: &mail.Record{ |
| + Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, |
| + Provisioned: &mail.Provisioned{KeyID: "AKIA2", PrevKeyID: "AKIA1", Active: true, At: now}, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.PendingFinish { |
| + t.Fatalf("a record with a PrevKeyID did not set PendingFinish: %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "paused-cap", |
| + app: "mail-paused-cap", |
| + rec: &mail.Record{ |
| + Provisioned: &mail.Provisioned{KeyID: "AKIA3", Active: false, PauseKind: mail.PauseKindCap, At: now}, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.Paused || card.PauseKind != mail.PauseKindCap { |
| + t.Fatalf("paused-cap card = %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "paused-rate", |
| + app: "mail-paused-rate", |
| + rec: &mail.Record{ |
| + Provisioned: &mail.Provisioned{KeyID: "AKIA4", Active: false, PauseKind: mail.PauseKindRate, At: now}, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.Paused || card.PauseKind != mail.PauseKindRate { |
| + t.Fatalf("paused-rate card = %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "paused-manual", |
| + app: "mail-paused-manual", |
| + rec: &mail.Record{ |
| + Provisioned: &mail.Provisioned{KeyID: "AKIA5", Active: false, PauseKind: "", At: now}, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.Paused || card.PauseKind != "" { |
| + t.Fatalf("paused-manual card = %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "with standalone credentials", |
| + app: "mail-standalone", |
| + rec: &mail.Record{ |
| + Standalone: []*mail.Standalone{ |
| + {Name: "mailgun-fallback", KeyID: "AKIASTAND", At: now.Add(-24 * time.Hour), By: "paul@keymail.dev"}, |
| + }, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if len(card.Standalone) != 1 { |
| + t.Fatalf("standalone card = %+v", card) |
| + } |
| + row := card.Standalone[0] |
| + if row.Name != "mailgun-fallback" || row.KeyID != "AKIASTAND" || row.Age == "" { |
| + t.Fatalf("standalone row = %+v", row) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "provisioner nil", |
| + app: "mail-no-provisioner", |
| + provisionerNil: true, |
| + check: func(t *testing.T, card *MailCard) { |
| + if card == nil { |
| + t.Fatal("provisioner-nil, absent record returned nil, want a zero card") |
| + } |
| + if card.Available { |
| + t.Fatal("Available = true with no MailProvisioner configured") |
| + } |
| + if card.Declared || card.Granted || card.Provisioned { |
| + t.Fatalf("provisioner-nil card is not otherwise zero: %+v", card) |
| + } |
| + }, |
| + }, |
| + } |
| + |
| + for _, tc := range cases { |
| + tc := tc |
| + t.Run(tc.name, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if !tc.provisionerNil { |
| + fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") |
| + } |
| + if tc.rec != nil { |
| + putMailRecord(t, fx, tc.app, tc.rec) |
| + } |
| + card := fx.srv.mailCard(context.Background(), mailCardAccount(fx), tc.app) |
| + tc.check(t, card) |
| + }) |
| + } |
| +} |
| + |
| +// unreadableMailStore breaks exactly the email record's read — the state a |
| +// bucket hiccup (or a permissions gap on this one object) leaves behind. |
| +type unreadableMailStore struct{ release.Store } |
| + |
| +func (u unreadableMailStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if strings.HasSuffix(key, "/email.json") { |
| + return nil, "", errors.New("AccessDenied: s3://carlos-releases/apps/.../email.json request id XYZ") |
| + } |
| + return u.Store.Get(ctx, key) |
| +} |
| + |
| +// TestMailCardReadErrorReturnsNilNotAZeroCard is storeCard's own discipline, |
| +// carried over: "could not read the record" and "there is genuinely no |
| +// email" must never render the same way. A zero card here would tell a |
| +// member "no email for this app yet, get it with carlos email enable" on a |
| +// page that could not actually confirm that — mailCard must instead return |
| +// nil, which the template renders as nothing at all about email. |
| +func TestMailCardReadErrorReturnsNilNotAZeroCard(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") |
| + fx.srv.cfg.Releases = unreadableMailStore{fx.rel} |
| + |
| + card := fx.srv.mailCard(context.Background(), mailCardAccount(fx), "kass") |
| + if card != nil { |
| + t.Fatalf("a read failure returned %+v, want nil", card) |
| + } |
| +} |
| + |
| +// TestSettingsPageShowsTheEmailCard is the render half: a card field the |
| +// template never reads does not exist for the member looking at the page, |
| +// and the paused state above all else must reach the HTML — a paused app is |
| +// sending nothing, and this must be impossible to miss. |
| +func TestSettingsPageShowsTheEmailCard(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") |
| + putMailRecord(t, fx, "kass", &mail.Record{ |
| + Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@kass.example.com", At: time.Now()}, |
| + Grant: &mail.GrantBlock{Domains: []string{"kass.example.com"}, DailyCap: 500, At: time.Now()}, |
| + Provisioned: &mail.Provisioned{ |
| + KeyID: "AKIAPAUSED", Active: false, PauseKind: mail.PauseKindRate, At: time.Now(), |
| + }, |
| + Standalone: []*mail.Standalone{{Name: "legacy-notifier", KeyID: "AKIASTAND1", At: time.Now()}}, |
| + }) |
| + c := signInPaul(t, fx) |
| + |
| + status, _, body := get(t, c, fx.u("/apps/kass/settings")) |
| + if status != http.StatusOK { |
| + t.Fatalf("settings page = %d:\n%s", status, body) |
| + } |
| + for _, want := range []string{ |
| + "CARLOS_SMTP", // the env prefix |
| + "hi@kass.example.com", // the From address |
| + "kass.example.com", // the granted sending domain |
| + "AKIAPAUSED", // the SMTP username |
| + "AKIASTAND1", // the standalone credential's key id |
| + "legacy-notifier", // the standalone credential's name |
| + "Sending is paused", // impossible to miss |
| + "does not clear itself", // the rate-pause distinction from a cap pause |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("the email card is missing %q:\n%s", want, body) |
| + } |
| + } |
| + |
| + // An app nobody has declared email for, on a console WITH a provisioner, |
| + // gets the command that can actually run. |
| + freshApp(t, fx, "sprout") |
| + _, _, body = get(t, c, fx.u("/apps/sprout/settings")) |
| + if !strings.Contains(body, "carlos email enable -app sprout") { |
| + t.Fatalf("an undeclared app is not told how to ask for email:\n%s", body) |
| + } |
| +} |
| + |
| +// TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner is the |
| +// judgement call the brief calls out: a deployment with no MailProvisioner |
| +// must never suggest a command that mailProvisionerGate would 404 — |
| +// suggesting `carlos email enable` there would tell the member to run |
| +// something that cannot work. |
| +func TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.MailProvisioner = nil |
| + c := signInPaul(t, fx) |
| + |
| + status, _, body := get(t, c, fx.u("/apps/kass/settings")) |
| + if status != http.StatusOK { |
| + t.Fatalf("settings page = %d:\n%s", status, body) |
| + } |
| + if !strings.Contains(body, "not available on this deployment") { |
| + t.Fatalf("a provisioner-less console does not say email is unavailable:\n%s", body) |
| + } |
| + if strings.Contains(body, "carlos email enable") { |
| + t.Fatalf("a provisioner-less console still suggests a command that would 404:\n%s", body) |
| + } |
| +} |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index b552e594b..1ffbe32a3 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -159,6 +159,70 @@ credentials — an operator of this deployment has to tear it down.</p> |
| and delivers its credentials into this app's config.</p> |
| {{end}} |
| {{end}}{{end}} |
| +{{if .Settings}}{{with .Settings.Mail}} |
| +<h2>Email</h2> |
| +{{if or .Declared .Granted .Provisioned .Standalone}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Env prefix</th><th>From address</th><th>Sending domains</th><th>Daily cap</th><th>SMTP username</th><th>Key age</th></tr> |
| + <tr> |
| + <td>{{if .EnvPrefix}}<code>{{.EnvPrefix}}_*</code>{{else}}<span title="no declaration on the record">—</span>{{end}}</td> |
| + <td>{{if .From}}{{.From}}{{else}}<span title="no declaration on the record">—</span>{{end}}</td> |
| + <td>{{if .Domains}}{{range $i, $d := .Domains}}{{if $i}}, {{end}}<code>{{$d}}</code>{{end}}{{else}}<span title="not granted yet">—</span>{{end}}</td> |
| + <td>{{if .Granted}}{{.DailyCap}}{{else}}<span title="not granted yet">—</span>{{end}}</td> |
| + <td>{{if .KeyID}}<code>{{.KeyID}}</code>{{else}}<span title="no credentials delivered yet">—</span>{{end}}</td> |
| + <td>{{if .KeyAge}}{{.KeyAge}}{{else}}<span title="unknown">—</span>{{end}}</td> |
| + </tr> |
| +</table> |
| +</div> |
| +{{if .Provisioned}}<p class="small">Key age is the last credential change — a |
| +rotation moves it. The SMTP username above IS this credential's access key id; |
| +there is no separate password to show here.</p>{{end}} |
| +{{if .Paused}} |
| +<p class="small banner error">Sending is paused for this app — no email goes |
| +out right now. |
| +{{if eq .PauseKind "cap"}}The daily send cap was hit; this clears itself on |
| +the next UTC day roll.{{else if eq .PauseKind "rate"}}SES's bounce/complaint |
| +rate tripped a safety pause; it does not clear itself — an operator has to |
| +resume it.{{else}}An operator paused this deliberately; only an operator can |
| +resume it.{{end}}</p> |
| +{{end}} |
| +{{if .PendingFinish}}<p class="small">A rotation is half-finished — two keys |
| +open this credential until you retire the old one:</p> |
| +<pre><code>carlos email rotate -finish -app {{$.Detail.App}}</code></pre>{{end}} |
| +{{if and .Declared (not .Granted) (not .Provisioned)}}<p class="small">Declared |
| +and waiting on a verified sending domain — add one with |
| +<code>carlos email domains add -app {{$.Detail.App}} <domain></code>, |
| +then <code>carlos email enable -app {{$.Detail.App}}</code>.</p>{{end}} |
| +{{if and .Granted (not .Provisioned)}}<p class="small">Authorised to send but |
| +not provisioned yet — run |
| +<code>carlos email enable -app {{$.Detail.App}}</code> to finish it.</p>{{end}} |
| +{{if .Standalone}} |
| +<h3>Standalone credentials</h3> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Name</th><th>SMTP username</th><th>Key age</th></tr> |
| + {{range .Standalone}} |
| + <tr> |
| + <td>{{.Name}}</td> |
| + <td><code>{{.KeyID}}</code></td> |
| + <td>{{if .Age}}{{.Age}}{{else}}<span title="unknown">—</span>{{end}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +<p class="small">No password is shown here — standalone credentials have |
| +none on the record; it was handed over once, at creation.</p> |
| +{{end}} |
| +{{else}} |
| +{{if .Available}} |
| +<p class="small">No email for this app yet — get it with |
| +<code>carlos email enable -app {{$.Detail.App}}</code>.</p> |
| +{{else}} |
| +<p class="small">Email is not available on this deployment.</p> |
| +{{end}} |
| +{{end}} |
| +{{end}}{{end}} |
| {{if .IsOwner}} |
| <h2 class="danger-head">Delete this app</h2> |