carlos / platform Public

Merge pull request #267 from carlosframework/console-mail-card

Console Email card on the app Settings tab, mirroring StoreCard.

Merged with the required check unable to run (Actions billing-dead since ~2026-08-16); gate reproduced locally against the merge result: 31 packages, vet and gofmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev c25cd5f6f670af9b33507a14085688cd28a85ac3 parent d64deacparent 94fb084
3 files changed, +487 −0
  • internal/console/appsettings.go +122 −0
  • internal/console/appsettings_test.go +301 −0
  • internal/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}} &lt;domain&gt;</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>