diff --git a/mail/account.go b/mail/account.go index 394e1b6..8039c0e 100644 --- a/mail/account.go +++ b/mail/account.go @@ -14,24 +14,42 @@ const VerifyWindow = 24 * time.Hour // The link is built from the Sender's PublicURL, because the address of the // portal is a property of the deployment, not of the call site. func (s Sender) SendVerification(to, token string) error { - return s.sendTemplate(to, "", "verification", struct { + return s.sendTemplate(to, "", "verification", s.verificationData(to, token)) +} + +// verificationData is split from its Send method, as every message's is, so +// render_test.go renders the exact struct the message is sent with. +func (s Sender) verificationData(to, token string) any { + return struct { + Email string Link string TTLHours int + Expires time.Time }{ + Email: to, Link: fmt.Sprintf("%s/verify?token=%s", s.PublicURL, token), TTLHours: int(VerifyWindow.Hours()), - }) + Expires: time.Now().UTC().Add(VerifyWindow), + } } // SendInvite asks someone to join an existing account and set their own // password. It names the account, because an unexpected invitation from a // service you have never used is otherwise indistinguishable from spam. func (s Sender) SendInvite(to, accountName, token string) error { - return s.sendTemplate(to, "", "invite", struct { + return s.sendTemplate(to, "", "invite", s.inviteData(to, accountName, token)) +} + +func (s Sender) inviteData(to, accountName, token string) any { + return struct { + Email string AccountName string Link string + TTLHours int }{ + Email: to, AccountName: accountName, Link: fmt.Sprintf("%s/accept-invite?token=%s", s.PublicURL, token), - }) + TTLHours: int(VerifyWindow.Hours()), + } } diff --git a/mail/billing.go b/mail/billing.go index e987c28..ee17414 100644 --- a/mail/billing.go +++ b/mail/billing.go @@ -3,16 +3,18 @@ package mail // SendCancelled confirms a cancellation and states what stays true: the licence // keeps working until it expires, then the instance degrades to read-only. func (s Sender) SendCancelled(to, instanceName string) error { - return s.sendTemplate(to, "", "cancelled", struct { - InstanceName string - }{instanceName}) + return s.sendTemplate(to, "", "cancelled", s.billingData(instanceName)) } // SendPastDue notifies of a failed charge without alarming: the licence is // untouched while Paddle retries the card. func (s Sender) SendPastDue(to, instanceName string) error { - return s.sendTemplate(to, "", "pastdue", struct { + return s.sendTemplate(to, "", "pastdue", s.billingData(instanceName)) +} + +func (s Sender) billingData(instanceName string) any { + return struct { InstanceName string PortalURL string - }{instanceName, s.PublicURL}) + }{instanceName, s.PublicURL} } diff --git a/mail/licence.go b/mail/licence.go index b27fa89..da7a4b4 100644 --- a/mail/licence.go +++ b/mail/licence.go @@ -8,10 +8,15 @@ import ( // SendLicense delivers the blob inline. It is signed public data, not a secret - // it is useless on any instance other than the one it names. func (s Sender) SendLicense(to, instanceName, blob string) error { - return s.sendTemplate(to, "", "license", struct { + return s.sendTemplate(to, "", "license", licenseData(instanceName, blob)) +} + +func licenseData(instanceName, blob string) any { + return struct { InstanceName string Blob string - }{instanceName, blob}) + Issued time.Time + }{instanceName, blob, time.Now().UTC()} } // SendInstanceReady tells a customer their cloud instance exists, where it is, @@ -21,28 +26,40 @@ func (s Sender) SendLicense(to, instanceName, blob string) error { // that quietly expires in a month is a surprise, and the first email is the one // people keep. func (s Sender) SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error { - return s.sendTemplate(to, "", "instanceready", struct { + return s.sendTemplate(to, "", "instanceready", instanceReadyData(instanceName, loginURL, expires)) +} + +func instanceReadyData(instanceName, loginURL string, expires time.Time) any { + return struct { InstanceName string LoginURL string Expires time.Time - }{instanceName, loginURL, expires}) + }{instanceName, loginURL, expires} } // SendRenewed confirms a renewal and states the new date. func (s Sender) SendRenewed(to, instanceName string, expires time.Time) error { - return s.sendTemplate(to, "", "renewed", struct { + return s.sendTemplate(to, "", "renewed", renewedData(instanceName, expires)) +} + +func renewedData(instanceName string, expires time.Time) any { + return struct { InstanceName string Expires time.Time - }{instanceName, expires}) + }{instanceName, expires} } // SendExpiring is the renew-now nudge, seven days out. func (s Sender) SendExpiring(to, instanceName, portalURL string, expires time.Time) error { - return s.sendTemplate(to, "", "expiring", struct { + return s.sendTemplate(to, "", "expiring", expiringData(instanceName, portalURL, expires)) +} + +func expiringData(instanceName, portalURL string, expires time.Time) any { + return struct { InstanceName string PortalURL string Expires time.Time - }{instanceName, portalURL, expires}) + }{instanceName, portalURL, expires} } // SendExpired states plainly what has stopped and what happens next. @@ -51,23 +68,32 @@ func (s Sender) SendExpiring(to, instanceName, portalURL string, expires time.Ti // sequence is that nobody loses an instance without having been told a date. A // zero deleteOn means the reaper is disabled, and then no date is claimed. func (s Sender) SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error { - return s.sendTemplate(to, "", "expired", struct { + return s.sendTemplate(to, "", "expired", expiredData(instanceName, portalURL, deleteOn)) +} + +func expiredData(instanceName, portalURL string, deleteOn time.Time) any { + return struct { InstanceName string PortalURL string DeleteOn time.Time - }{instanceName, portalURL, deleteOn}) + }{instanceName, portalURL, deleteOn} } // SendDeletionWarning is the final countdown, sent at seven days and one day. func (s Sender) SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error { + return s.sendTemplate(to, "", "deletionwarning", deletionWarningData(instanceName, portalURL, deleteOn, daysLeft)) +} + +func deletionWarningData(instanceName, portalURL string, deleteOn time.Time, daysLeft int) any { when := fmt.Sprintf("in %d days", daysLeft) if daysLeft <= 1 { when = "tomorrow" } - return s.sendTemplate(to, "", "deletionwarning", struct { + return struct { InstanceName string PortalURL string When string + DaysLeft int DeleteOn time.Time - }{instanceName, portalURL, when, deleteOn}) + }{instanceName, portalURL, when, daysLeft, deleteOn} } diff --git a/mail/render.go b/mail/render.go index 67629f6..0f16488 100644 --- a/mail/render.go +++ b/mail/render.go @@ -5,6 +5,7 @@ import ( "fmt" htmltmpl "html/template" "io/fs" + "math" "strings" texttmpl "text/template" "time" @@ -36,11 +37,13 @@ func init() { continue } h, err := htmltmpl.New("layout.html.tmpl").Funcs(htmltmpl.FuncMap(funcs)). + Funcs(htmltmpl.FuncMap(perRender("", "accent"))). ParseFS(files, "templates/layout.html.tmpl", e) if err != nil { panic("mail: parse " + e + ": " + err.Error()) } t, err := texttmpl.New("layout.txt.tmpl").Funcs(texttmpl.FuncMap(funcs)). + Funcs(texttmpl.FuncMap(perRender("", "accent"))). ParseFS(files, "templates/layout.txt.tmpl", "templates/"+name+".txt.tmpl") if err != nil { panic("mail: parse " + name + ".txt.tmpl: " + err.Error()) @@ -49,25 +52,54 @@ func init() { } } -// render produces the subject and both bodies for one message. +// perRender are the funcs whose value belongs to one message rather than to +// the package: the sender's portal address for the footer links, and the +// message's tone, which the layout needs before the body has run. They are +// registered with placeholders at parse time and rebound on a clone per render. +func perRender(hq, tone string) map[string]any { + return map[string]any{ + "hq": func() string { return hq }, + "tone": func() string { return tone }, + } +} + +// render produces the subject and both bodies for one message. hq is the +// sender's PublicURL; empty drops the footer links. // // The subject comes from the text set, not the HTML one: html/template would // escape an ampersand in an instance name into "&" and mail clients show // subjects verbatim. -func render(name string, data any) (message, error) { +func render(name string, data any, hq string) (message, error) { s, ok := sets[name] if !ok { return message{}, fmt.Errorf("no such template") } + var tone strings.Builder + if err := s.text.ExecuteTemplate(&tone, "tone", data); err != nil { + return message{}, err + } + per := perRender(hq, strings.TrimSpace(tone.String())) + + ht, err := s.html.Clone() + if err != nil { + return message{}, err + } + ht.Funcs(htmltmpl.FuncMap(per)) + tt, err := s.text.Clone() + if err != nil { + return message{}, err + } + tt.Funcs(texttmpl.FuncMap(per)) + var subject, text, html strings.Builder - if err := s.text.ExecuteTemplate(&subject, "subject", data); err != nil { + if err := tt.ExecuteTemplate(&subject, "subject", data); err != nil { return message{}, err } - if err := s.text.Execute(&text, data); err != nil { + if err := tt.Execute(&text, data); err != nil { return message{}, err } - if err := s.html.Execute(&html, data); err != nil { + if err := ht.Execute(&html, data); err != nil { return message{}, err } @@ -110,6 +142,24 @@ var funcs = map[string]any{ return m, nil }, "list": func(v ...any) []any { return v }, + "add": func(a, b int) int { return a + b }, + + // pick chooses one of five values by tone: up, down, pend, accent, and + // anything else. It lets the layout keep every hex while the tone of a + // message is decided by the message. + "pick": func(tone any, up, down, pend, accent, other string) string { + switch fmt.Sprint(tone) { + case "up": + return up + case "down": + return down + case "pend": + return pend + case "accent": + return accent + } + return other + }, // date is the one long-date format used across every Vantage email. "date": func(t time.Time) string { return t.Format("2 January 2006") }, @@ -118,4 +168,44 @@ var funcs = map[string]any{ "stamp": func(t time.Time) string { return t.Format("2006-01-02 15:04:05 MST") }, "hours": func(d time.Duration) int { return int(d.Hours()) }, "upper": strings.ToUpper, + "year": func() int { return time.Now().Year() }, + + // daysUntil counts whole days left, rounding up so "expires tomorrow + // afternoon" reads as 1, never 0. A date in the past is 0. + "daysUntil": func(t time.Time) int { + d := time.Until(t) + if d <= 0 { + return 0 + } + return int(math.Ceil(d.Hours() / 24)) + }, + "plural": func(n int, one, many string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, one) + } + return fmt.Sprintf("%d %s", n, many) + }, + + // sevTone maps a scanner severity onto a layout tone, so the vulnerability + // digest colours a finding the same way the control plane does. + "sevTone": func(sev string) string { + switch strings.ToLower(sev) { + case "critical", "high": + return "down" + case "medium": + return "pend" + } + return "accent" + }, + // advisoryURL links a finding to its public advisory when the ID says + // where that is. Other IDs get no link rather than a guessed one. + "advisoryURL": func(id string) string { + switch { + case strings.HasPrefix(id, "CVE-"): + return "https://nvd.nist.gov/vuln/detail/" + id + case strings.HasPrefix(id, "GHSA-"): + return "https://github.com/advisories/" + id + } + return "" + }, } diff --git a/mail/render_test.go b/mail/render_test.go new file mode 100644 index 0000000..62aa717 --- /dev/null +++ b/mail/render_test.go @@ -0,0 +1,107 @@ +package mail + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// cases renders every message with realistic data. Each entry's key is the +// template name, optionally with a "/variant" suffix for a message whose +// branches are worth seeing separately. +func cases() map[string]any { + s := Sender{PublicURL: "https://vantage-hq.hostxtra.co.uk"} + now := time.Now().UTC() + day := 24 * time.Hour + return map[string]any{ + "verification": s.verificationData("sam@example.com", "3f9c2a7d1e"), + "invite": s.inviteData("sam@example.com", "Northwind Ops", "8a1b2c3d4e"), + "license": licenseData("northwind-prod", + "eyJ2IjoxLCJpbnN0YW5jZSI6Im5vcnRod2luZC1wcm9kIiwidGllciI6ImZyZWUiLCJleHAiOjE3OTk5OTk5OTl9.MEUCIQDk3v1rX0sX7kQ2m9WQ1o0q8pWm6lq3vRjz8yN0yX4bGQIgYk2Fh7s"), + "instanceready": instanceReadyData("northwind-prod", "https://northwind-prod.vantage.hostxtra.co.uk", now.Add(30*day)), + "instanceready/nologin": instanceReadyData("northwind-prod", "", now.Add(30*day)), + "renewed": renewedData("northwind-prod", now.Add(30*day)), + "expiring": expiringData("northwind-prod", s.PublicURL, now.Add(7*day)), + "expired": expiredData("northwind-prod", s.PublicURL, now.Add(14*day)), + "expired/noreaper": expiredData("northwind-prod", s.PublicURL, time.Time{}), + "deletionwarning": deletionWarningData("northwind-prod", s.PublicURL, now.Add(7*day), 7), + "deletionwarning/1day": deletionWarningData("northwind-prod", s.PublicURL, now.Add(day), 1), + "cancelled": s.billingData("northwind-prod"), + "pastdue": s.billingData("northwind-prod"), + "monitoralert": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "up", NewStatus: "down", Message: "GET /healthz returned 503 Service Unavailable after 2.4s", Time: now, Down: true}, + "monitoralert/recovered": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "down", NewStatus: "up", Message: "200 OK in 182ms", Time: now}, + "contact": struct { + Enquiry + Received string + }{Enquiry{Name: "Priya Shah", Email: "priya@acme.io", Servers: "50-100", Topic: "Sales", + Message: "Hi,\n\nWe run about 80 Linux boxes across two sites and are looking at Vantage for patching and uptime.\nCould we get a demo next week?"}, now.Format(time.RFC1123)}, + "vuln_digest": VulnDigest{InstanceName: "northwind-prod", Count: 14, TopSeverity: "critical", + Summary: "14 new findings across 3 servers, 2 of them critical.", + Rows: []VulnDigestRow{ + {CVEID: "CVE-2026-31337", Severity: "critical", PackageName: "openssl", ServerName: "web-01", FixedIn: "3.0.15-1"}, + {CVEID: "CVE-2026-29001", Severity: "critical", PackageName: "sudo", ServerName: "db-02", FixedIn: "1.9.16p2"}, + {CVEID: "GHSA-4xq2-9r7m-p3wv", Severity: "high", PackageName: "golang.org/x/net", ServerName: "web-01"}, + {CVEID: "CVE-2026-20488", Severity: "medium", PackageName: "curl", ServerName: "worker-03", FixedIn: "8.9.1"}, + {CVEID: "CVE-2025-48112", Severity: "low", PackageName: "less", ServerName: "worker-03"}, + }, + More: 9, DBAge: "2 days"}, + } +} + +// TestRenderAll renders every template and fails if one exists that no case +// covers. The templates are parsed in init(), so without this test a mistyped +// field is a boot-time panic in three services. +// +// Set MAIL_PREVIEW_DIR to also write each rendering to disk for a look. +func TestRenderAll(t *testing.T) { + covered := map[string]bool{} + dir := os.Getenv("MAIL_PREVIEW_DIR") + + for key, data := range cases() { + name, _, _ := strings.Cut(key, "/") + covered[name] = true + + m, err := render(name, data, "https://vantage-hq.hostxtra.co.uk") + if err != nil { + t.Fatalf("%s: %v", key, err) + } + if m.Subject == "" { + t.Errorf("%s: empty subject", key) + } + for part, body := range map[string]string{"subject": m.Subject, "text": m.Text, "html": m.HTML} { + if strings.Contains(body, "") { + t.Errorf("%s: %s part has ", key, part) + } + if strings.Contains(body, "—") { + t.Errorf("%s: %s part has an em dash", key, part) + } + } + + if dir != "" { + base := filepath.Join(dir, strings.ReplaceAll(key, "/", "--")) + _ = os.WriteFile(base+".html", []byte(m.HTML), 0o644) + _ = os.WriteFile(base+".txt", []byte(m.Text), 0o644) + _ = os.WriteFile(base+".subject", []byte(m.Subject), 0o644) + } + } + + for name := range sets { + if !covered[name] { + t.Errorf("template %q has no render case", name) + } + } +} + +// TestRenderNoPortal checks the footer degrades when the sender has no +// PublicURL, as a monitor notification channel does not. +func TestRenderNoPortal(t *testing.T) { + m, err := render("monitoralert", MonitorEvent{MonitorName: "m", Type: "tcp", Time: time.Now(), Down: true}, "") + if err != nil { + t.Fatal(err) + } + if strings.Contains(m.HTML, "/billing") { + t.Error("footer links rendered without a portal URL") + } +} diff --git a/mail/sender.go b/mail/sender.go index 1701bc8..a5eda0c 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -78,7 +78,7 @@ type message struct { // sendTemplate renders name against data and delivers the result. func (s Sender) sendTemplate(to, replyTo, name string, data any) error { - m, err := render(name, data) + m, err := render(name, data, s.PublicURL) if err != nil { return fmt.Errorf("mail: render %s: %w", name, err) } diff --git a/mail/templates/cancelled.html.tmpl b/mail/templates/cancelled.html.tmpl index 56b4d8d..6fafe5d 100644 --- a/mail/templates/cancelled.html.tmpl +++ b/mail/templates/cancelled.html.tmpl @@ -1,6 +1,19 @@ +{{define "category"}}Billing{{end}} +{{define "preheader"}}No further charges. {{.InstanceName}} keeps working until its licence expires.{{end}} {{define "pill"}}{{template "chip" (dict "label" "Cancelled" "tone" "pend")}}{{end}} -{{define "title"}}Your subscription is cancelled{{end}} +{{define "title"}}Your subscription for {{.InstanceName}} is cancelled{{end}} {{define "body"}} -{{template "lead" (printf "Your subscription for %s is cancelled." .InstanceName)}} +{{template "lead" (printf "Your subscription for %s is cancelled. No further payments will be taken." .InstanceName)}} +{{template "timeline" (list + (dict "label" "Cancelled" "date" "Today" "state" "done") + (dict "label" "Still active" "date" "Until licence expiry" "state" "now" "tone" "up") + (dict "label" "Read-only" "date" "After that" "state" "next"))}} +{{template "rows" (list + (dict "k" "Instance" "v" .InstanceName) + (dict "k" "Subscription" "v" "Cancelled") + (dict "k" "Further charges" "v" "None"))}} {{template "p" "Your instance keeps working until the current licence expires. After that, monitors keep running but changes are disabled."}} +{{if .PortalURL}}{{template "callout" (dict "tone" "accent" "title" "Changed your mind?" "text" "You can start a new subscription from Vantage HQ at any time.")}} +{{template "button" (dict "label" "Open Vantage HQ" "url" .PortalURL)}}{{end}} {{end}} +{{define "why"}}the subscription for {{.InstanceName}} was cancelled.{{end}} diff --git a/mail/templates/cancelled.txt.tmpl b/mail/templates/cancelled.txt.tmpl index 95c387c..1b9f0d4 100644 --- a/mail/templates/cancelled.txt.tmpl +++ b/mail/templates/cancelled.txt.tmpl @@ -1,7 +1,20 @@ -{{define "subject"}}Your Vantage subscription is cancelled{{end}} +{{define "subject"}}Your Vantage subscription for {{.InstanceName}} is cancelled{{end}} +{{define "tone"}}pend{{end}} +{{define "category"}}Billing{{end}} {{define "pill"}}{{template "chip" (dict "label" "Cancelled" "tone" "pend")}}{{end}} -{{define "title"}}Your subscription is cancelled{{end}} +{{define "title"}}Your subscription for {{.InstanceName}} is cancelled{{end}} {{define "body"}} -{{template "lead" (printf "Your subscription for %s is cancelled." .InstanceName)}} +{{template "lead" (printf "Your subscription for %s is cancelled. No further payments will be taken." .InstanceName)}} +{{template "timeline" (list + (dict "label" "Cancelled" "date" "Today" "state" "done") + (dict "label" "Still active" "date" "Until licence expiry" "state" "now" "tone" "up") + (dict "label" "Read-only" "date" "After that" "state" "next"))}} +{{template "rows" (list + (dict "k" "Instance" "v" .InstanceName) + (dict "k" "Subscription" "v" "Cancelled") + (dict "k" "Further charges" "v" "None"))}} {{template "p" "Your instance keeps working until the current licence expires. After that, monitors keep running but changes are disabled."}} +{{if .PortalURL}}{{template "callout" (dict "tone" "accent" "title" "Changed your mind?" "text" "You can start a new subscription from Vantage HQ at any time.")}} +{{template "button" (dict "label" "Open Vantage HQ" "url" .PortalURL)}}{{end}} {{end}} +{{define "why"}}the subscription for {{.InstanceName}} was cancelled.{{end}} diff --git a/mail/templates/contact.html.tmpl b/mail/templates/contact.html.tmpl index 165077d..817f918 100644 --- a/mail/templates/contact.html.tmpl +++ b/mail/templates/contact.html.tmpl @@ -1,4 +1,7 @@ -{{define "title"}}New contact enquiry{{end}} +{{define "category"}}Enquiry{{end}} +{{define "preheader"}}{{.Topic}} from {{.Name}}: {{.Message}}{{end}} +{{define "pill"}}{{if .Topic}}{{template "chip" (dict "label" .Topic "tone" "accent")}}{{end}}{{end}} +{{define "title"}}New enquiry from {{.Name}}{{end}} {{define "body"}} {{template "lead" "Someone has used the contact form on the Vantage site."}} {{template "rows" (list @@ -7,5 +10,9 @@ (dict "k" "Servers" "v" .Servers) (dict "k" "Topic" "v" .Topic) (dict "k" "Received" "v" .Received))}} +{{template "label" "Message"}} {{template "note" .Message}} +{{template "button" (dict "label" (printf "Reply to %s" .Name) "url" (printf "mailto:%s" .Email) "nofallback" true)}} +{{template "small" (printf "Replying to this email also goes straight to %s." .Email)}} {{end}} +{{define "why"}}this inbox receives the contact form on the Vantage site.{{end}} diff --git a/mail/templates/contact.txt.tmpl b/mail/templates/contact.txt.tmpl index c076c97..9acea54 100644 --- a/mail/templates/contact.txt.tmpl +++ b/mail/templates/contact.txt.tmpl @@ -1,5 +1,8 @@ {{define "subject"}}[Vantage] {{.Topic}} {{.Email}}{{end}} -{{define "title"}}New contact enquiry{{end}} +{{define "tone"}}accent{{end}} +{{define "category"}}Enquiry{{end}} +{{define "pill"}}{{if .Topic}}{{template "chip" (dict "label" .Topic "tone" "accent")}}{{end}}{{end}} +{{define "title"}}New enquiry from {{.Name}}{{end}} {{define "body"}} {{template "lead" "Someone has used the contact form on the Vantage site."}} {{template "rows" (list @@ -8,6 +11,8 @@ (dict "k" "Servers" "v" .Servers) (dict "k" "Topic" "v" .Topic) (dict "k" "Received" "v" .Received))}} -Message: +{{template "label" "Message"}} {{template "note" .Message}} +{{template "small" (printf "Reply to this email to answer %s directly." .Email)}} {{end}} +{{define "why"}}this inbox receives the contact form on the Vantage site.{{end}} diff --git a/mail/templates/deletionwarning.html.tmpl b/mail/templates/deletionwarning.html.tmpl index d1ebf3b..4b8b2b1 100644 --- a/mail/templates/deletionwarning.html.tmpl +++ b/mail/templates/deletionwarning.html.tmpl @@ -1,7 +1,22 @@ +{{define "category"}}Licence{{end}} +{{define "preheader"}}Renew before {{date .DeleteOn}} to keep it. Deletion cannot be undone.{{end}} {{define "pill"}}{{template "chip" (dict "label" "Deletion scheduled" "tone" "down")}}{{end}} {{define "title"}}{{.InstanceName}} will be deleted {{.When}}{{end}} {{define "body"}} {{template "lead" (printf "%s and everything in it will be deleted %s, on %s." .InstanceName .When (date .DeleteOn))}} -{{template "p" "This cannot be undone. Renew it to keep it:"}} +{{template "stats" (list + (dict "big" (plural .DaysLeft "day" "days") "label" "until deletion" "tone" "down") + (dict "big" (shortDate .DeleteOn) "label" "deletion date" "tone" "none"))}} {{template "button" (dict "label" "Keep this instance" "url" .PortalURL)}} +{{template "callout" (dict "tone" "down" "title" "This cannot be undone" "text" (printf "Servers, agents, monitors, workflows, history and settings in %s are removed permanently. They cannot be restored afterwards." .InstanceName))}} +{{template "timeline" (list + (dict "label" "Licence" "date" "Expired" "state" "done") + (dict "label" "Read-only" "date" "Now" "state" "now" "tone" "pend") + (dict "label" "Deleted" "date" (date .DeleteOn) "state" "next"))}} +{{template "rows" (list + (dict "k" "Instance" "v" .InstanceName) + (dict "k" "Current state" "v" "Read-only, licence expired") + (dict "k" "Deleted on" "v" (date .DeleteOn)))}} +{{template "small" "Renewed in the last few minutes? No action is needed. Renewing moves the deletion date with the licence."}} {{end}} +{{define "why"}}{{.InstanceName}} is scheduled for deletion and this is its billing contact.{{end}} diff --git a/mail/templates/deletionwarning.txt.tmpl b/mail/templates/deletionwarning.txt.tmpl index 72b8ece..8c1f1e0 100644 --- a/mail/templates/deletionwarning.txt.tmpl +++ b/mail/templates/deletionwarning.txt.tmpl @@ -1,8 +1,21 @@ {{define "subject"}}{{.InstanceName}} will be deleted {{.When}}{{end}} +{{define "tone"}}down{{end}} +{{define "category"}}Licence{{end}} {{define "pill"}}{{template "chip" (dict "label" "Deletion scheduled" "tone" "down")}}{{end}} {{define "title"}}{{.InstanceName}} will be deleted {{.When}}{{end}} {{define "body"}} {{template "lead" (printf "%s and everything in it will be deleted %s, on %s." .InstanceName .When (date .DeleteOn))}} -{{template "p" "This cannot be undone. Renew it to keep it:"}} {{template "button" (dict "label" "Keep this instance" "url" .PortalURL)}} +{{template "callout" (dict "tone" "down" "title" "This cannot be undone" "text" (printf "Servers, agents, monitors, workflows, history and settings in %s are removed permanently. They cannot be restored afterwards." .InstanceName))}} +{{template "timeline" (list + (dict "label" "Licence" "date" "Expired" "state" "done") + (dict "label" "Read-only" "date" "Now" "state" "now" "tone" "pend") + (dict "label" "Deleted" "date" (date .DeleteOn) "state" "next"))}} +{{template "rows" (list + (dict "k" "Instance" "v" .InstanceName) + (dict "k" "Current state" "v" "Read-only, licence expired") + (dict "k" "Deleted on" "v" (date .DeleteOn)) + (dict "k" "Time left" "v" (plural .DaysLeft "day" "days")))}} +{{template "small" "Renewed in the last few minutes? No action is needed. Renewing moves the deletion date with the licence."}} {{end}} +{{define "why"}}{{.InstanceName}} is scheduled for deletion and this is its billing contact.{{end}} diff --git a/mail/templates/expired.html.tmpl b/mail/templates/expired.html.tmpl index 9ed09e3..e6fc0a6 100644 --- a/mail/templates/expired.html.tmpl +++ b/mail/templates/expired.html.tmpl @@ -1,8 +1,23 @@ +{{define "category"}}Licence{{end}} +{{define "preheader"}}The licence has expired. Renew to unlock changes{{if not .DeleteOn.IsZero}} before {{date .DeleteOn}}{{end}}.{{end}} {{define "pill"}}{{template "chip" (dict "label" "Read-only" "tone" "down")}}{{end}} {{define "title"}}{{.InstanceName}} is now read-only{{end}} {{define "body"}} -{{template "lead" (printf "%s's Free licence has expired." .InstanceName)}} -{{template "p" "Your servers and monitors keep running and your agents keep their keys, but changes are disabled."}} +{{template "lead" (printf "%s's Free licence has expired, so the instance is now read-only." .InstanceName)}} +{{if not .DeleteOn.IsZero}}{{template "stats" (list + (dict "big" (plural (daysUntil .DeleteOn) "day" "days") "label" "until deletion" "tone" "down") + (dict "big" (shortDate .DeleteOn) "label" "scheduled deletion" "tone" "none"))}}{{end}} {{template "button" (dict "label" "Renew now" "url" .PortalURL)}} -{{if not .DeleteOn.IsZero}}{{template "p" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn))}}{{end}} +{{template "label" "What still works"}} +{{template "rows" (list + (dict "k" "Servers and agents" "v" "Running, keys kept") + (dict "k" "Monitors" "v" "Still checking") + (dict "k" "Changes" "v" "Disabled until renewed") + (dict "k" "Deletion" "v" (or (and (not .DeleteOn.IsZero) (date .DeleteOn)) "Not scheduled")))}} +{{template "timeline" (list + (dict "label" "Active" "date" "Ended" "state" "done") + (dict "label" "Read-only" "date" "Now" "state" "now" "tone" "down") + (dict "label" "Deleted" "date" (or (and (not .DeleteOn.IsZero) (date .DeleteOn)) "Not scheduled") "state" "next"))}} +{{if not .DeleteOn.IsZero}}{{template "callout" (dict "tone" "down" "title" "Deletion is scheduled" "text" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn)))}}{{end}} {{end}} +{{define "why"}}the licence for {{.InstanceName}} has expired.{{end}} diff --git a/mail/templates/expired.txt.tmpl b/mail/templates/expired.txt.tmpl index 2eba94d..f8e2698 100644 --- a/mail/templates/expired.txt.tmpl +++ b/mail/templates/expired.txt.tmpl @@ -1,9 +1,21 @@ {{define "subject"}}{{.InstanceName}} is now read-only{{end}} +{{define "tone"}}down{{end}} +{{define "category"}}Licence{{end}} {{define "pill"}}{{template "chip" (dict "label" "Read-only" "tone" "down")}}{{end}} {{define "title"}}{{.InstanceName}} is now read-only{{end}} {{define "body"}} -{{template "lead" (printf "%s's Free licence has expired." .InstanceName)}} -{{template "p" "Your servers and monitors keep running and your agents keep their keys, but changes are disabled."}} +{{template "lead" (printf "%s's Free licence has expired, so the instance is now read-only." .InstanceName)}} {{template "button" (dict "label" "Renew now" "url" .PortalURL)}} -{{if not .DeleteOn.IsZero}}{{template "p" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn))}}{{end}} +{{template "label" "What still works"}} +{{template "rows" (list + (dict "k" "Servers and agents" "v" "Running, keys kept") + (dict "k" "Monitors" "v" "Still checking") + (dict "k" "Changes" "v" "Disabled until renewed") + (dict "k" "Deletion" "v" (or (and (not .DeleteOn.IsZero) (date .DeleteOn)) "Not scheduled")))}} +{{template "timeline" (list + (dict "label" "Active" "date" "Ended" "state" "done") + (dict "label" "Read-only" "date" "Now" "state" "now" "tone" "down") + (dict "label" "Deleted" "date" (or (and (not .DeleteOn.IsZero) (date .DeleteOn)) "Not scheduled") "state" "next"))}} +{{if not .DeleteOn.IsZero}}{{template "callout" (dict "tone" "down" "title" "Deletion is scheduled" "text" (printf "If it is not renewed, the instance and everything in it will be deleted on %s, in %s." (date .DeleteOn) (plural (daysUntil .DeleteOn) "day" "days")))}}{{end}} {{end}} +{{define "why"}}the licence for {{.InstanceName}} has expired.{{end}} diff --git a/mail/templates/expiring.html.tmpl b/mail/templates/expiring.html.tmpl index ec04b05..ac11c10 100644 --- a/mail/templates/expiring.html.tmpl +++ b/mail/templates/expiring.html.tmpl @@ -1,7 +1,18 @@ +{{define "category"}}Licence{{end}} +{{define "preheader"}}Renew in one click to keep {{.InstanceName}} fully working after {{date .Expires}}.{{end}} {{define "pill"}}{{template "chip" (dict "label" "Expiring soon" "tone" "pend")}}{{end}} {{define "title"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}} {{define "body"}} -{{template "lead" (printf "%s's Free licence runs out on %s." .InstanceName (date .Expires))}} +{{template "lead" (printf "%s's Free licence runs out on %s. Renewing takes one click and costs nothing." .InstanceName (date .Expires))}} +{{template "stats" (list + (dict "big" (plural (daysUntil .Expires) "day" "days") "label" "left on the licence" "tone" "pend") + (dict "big" (shortDate .Expires) "label" "expiry date" "tone" "none"))}} {{template "button" (dict "label" "Renew in one click" "url" .PortalURL)}} -{{template "p" "If you do nothing, the instance keeps running but stops accepting changes."}} +{{template "label" "Where you are"}} +{{template "timeline" (list + (dict "label" "Active" "date" (printf "Until %s" (shortDate .Expires)) "state" "now" "tone" "pend") + (dict "label" "Read-only" "date" "After expiry" "state" "next") + (dict "label" "Deleted" "date" "If never renewed" "state" "next"))}} +{{template "callout" (dict "tone" "pend" "title" "If you do nothing" "text" "The instance keeps running and monitoring, but stops accepting changes until it is renewed.")}} {{end}} +{{define "why"}}the licence for {{.InstanceName}} expires within a week.{{end}} diff --git a/mail/templates/expiring.txt.tmpl b/mail/templates/expiring.txt.tmpl index 9bbedeb..7e49b57 100644 --- a/mail/templates/expiring.txt.tmpl +++ b/mail/templates/expiring.txt.tmpl @@ -1,8 +1,19 @@ {{define "subject"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}} +{{define "tone"}}pend{{end}} +{{define "category"}}Licence{{end}} {{define "pill"}}{{template "chip" (dict "label" "Expiring soon" "tone" "pend")}}{{end}} {{define "title"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}} {{define "body"}} -{{template "lead" (printf "%s's Free licence runs out on %s." .InstanceName (date .Expires))}} +{{template "lead" (printf "%s's Free licence runs out on %s. Renewing takes one click and costs nothing." .InstanceName (date .Expires))}} +{{template "stats" (list + (dict "big" (plural (daysUntil .Expires) "day" "days") "label" "Left on the licence" "tone" "pend") + (dict "big" (date .Expires) "label" "Expiry date" "tone" "none"))}} {{template "button" (dict "label" "Renew in one click" "url" .PortalURL)}} -{{template "p" "If you do nothing, the instance keeps running but stops accepting changes."}} +{{template "label" "Where you are"}} +{{template "timeline" (list + (dict "label" "Active" "date" (printf "Until %s" (shortDate .Expires)) "state" "now" "tone" "pend") + (dict "label" "Read-only" "date" "After expiry" "state" "next") + (dict "label" "Deleted" "date" "If never renewed" "state" "next"))}} +{{template "callout" (dict "tone" "pend" "title" "If you do nothing" "text" "The instance keeps running and monitoring, but stops accepting changes until it is renewed.")}} {{end}} +{{define "why"}}the licence for {{.InstanceName}} expires within a week.{{end}} diff --git a/mail/templates/instanceready.html.tmpl b/mail/templates/instanceready.html.tmpl index 7e7fd21..4c4d5e4 100644 --- a/mail/templates/instanceready.html.tmpl +++ b/mail/templates/instanceready.html.tmpl @@ -1,8 +1,25 @@ +{{define "category"}}Instance{{end}} +{{define "preheader"}}{{.InstanceName}} is live. Your Free licence runs until {{date .Expires}}.{{end}} {{define "pill"}}{{template "chip" (dict "label" "Ready" "tone" "up")}}{{end}} {{define "title"}}{{.InstanceName}} is ready{{end}} {{define "body"}} {{template "lead" (printf "%s is provisioned and waiting for you." .InstanceName)}} -{{if .LoginURL}}{{template "button" (dict "label" "Sign in" "url" .LoginURL)}}{{end}} -{{template "p" (printf "Your Free licence runs until %s. We will email you before then so you can renew it in one click." (date .Expires))}} -{{template "p" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too."}} +{{if .LoginURL}}{{template "button" (dict "label" (printf "Sign in to %s" .InstanceName) "url" .LoginURL)}}{{end}} +{{template "stats" (list + (dict "big" "Free" "label" "plan" "tone" "accent") + (dict "big" (plural (daysUntil .Expires) "day" "days") "label" (printf "until renewal, on %s" (shortDate .Expires)) "tone" "up"))}} +{{if .LoginURL}}{{template "rows" (list + (dict "k" "Instance" "v" .InstanceName) + (dict "k" "Address" "v" .LoginURL) + (dict "k" "Licence expires" "v" (date .Expires)))}}{{else}}{{template "rows" (list + (dict "k" "Instance" "v" .InstanceName) + (dict "k" "Licence expires" "v" (date .Expires)))}}{{end}} +{{template "label" "Get started"}} +{{template "steps" (list + (dict "t" "Sign in" "d" "Use your Vantage account email and password.") + (dict "t" "Connect your first server" "d" "Install the Vantage agent on a server and it joins your fleet.") + (dict "t" "Add monitors and alerts" "d" "Watch the services that matter and choose who hears when they fail."))}} +{{template "callout" (dict "tone" "accent" "title" "One password for everything" "text" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too.")}} +{{template "small" "We will email you a week before the licence runs out, so you can renew it in one click."}} {{end}} +{{define "why"}}you created {{.InstanceName}} in Vantage HQ.{{end}} diff --git a/mail/templates/instanceready.txt.tmpl b/mail/templates/instanceready.txt.tmpl index 2ff3888..a160825 100644 --- a/mail/templates/instanceready.txt.tmpl +++ b/mail/templates/instanceready.txt.tmpl @@ -1,9 +1,20 @@ {{define "subject"}}{{.InstanceName}} is ready{{end}} +{{define "tone"}}up{{end}} +{{define "category"}}Instance{{end}} {{define "pill"}}{{template "chip" (dict "label" "Ready" "tone" "up")}}{{end}} {{define "title"}}{{.InstanceName}} is ready{{end}} {{define "body"}} {{template "lead" (printf "%s is provisioned and waiting for you." .InstanceName)}} -{{if .LoginURL}}{{template "button" (dict "label" "Sign in" "url" .LoginURL)}}{{end}} -{{template "p" (printf "Your Free licence runs until %s. We will email you before then so you can renew it in one click." (date .Expires))}} -{{template "p" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too."}} +{{if .LoginURL}}{{template "button" (dict "label" (printf "Sign in to %s" .InstanceName) "url" .LoginURL)}}{{end}} +{{template "stats" (list + (dict "big" "Free" "label" "Plan" "tone" "accent") + (dict "big" (plural (daysUntil .Expires) "day" "days") "label" (printf "Until renewal, on %s" (shortDate .Expires)) "tone" "up"))}} +{{template "label" "Get started"}} +{{template "steps" (list + (dict "t" "Sign in" "d" "Use your Vantage account email and password.") + (dict "t" "Connect your first server" "d" "Install the Vantage agent on a server and it joins your fleet.") + (dict "t" "Add monitors and alerts" "d" "Watch the services that matter and choose who hears when they fail."))}} +{{template "callout" (dict "tone" "accent" "title" "One password for everything" "text" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too.")}} +{{template "small" "We will email you a week before the licence runs out, so you can renew it in one click."}} {{end}} +{{define "why"}}you created {{.InstanceName}} in Vantage HQ.{{end}} diff --git a/mail/templates/invite.html.tmpl b/mail/templates/invite.html.tmpl index bcb4472..e3536d6 100644 --- a/mail/templates/invite.html.tmpl +++ b/mail/templates/invite.html.tmpl @@ -1,7 +1,19 @@ +{{define "category"}}Account{{end}} +{{define "preheader"}}Join {{.AccountName}} on Vantage. Set a password to accept.{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Invitation" "tone" "accent")}}{{end}} {{define "title"}}You have been invited to {{.AccountName}}{{end}} {{define "body"}} -{{template "lead" (printf "You have been invited to join %s on Vantage." .AccountName)}} -{{template "p" "Set your own password and finish joining:"}} -{{template "button" (dict "label" "Set password and join" "url" .Link)}} -{{template "p" "This link expires in 24 hours. If you were not expecting this, ignore it - nothing happens until you open the link."}} +{{template "lead" (printf "You have been invited to join %s on Vantage, where the team manages its servers, agents and monitors." .AccountName)}} +{{template "button" (dict "label" "Accept invitation" "url" .Link)}} +{{template "rows" (list + (dict "k" "Account" "v" .AccountName) + (dict "k" "Invited address" "v" .Email) + (dict "k" "Link valid for" "v" (plural .TTLHours "hour" "hours")))}} +{{template "label" "Joining takes a minute"}} +{{template "steps" (list + (dict "t" "Accept the invitation" "d" "Open the link above from this inbox.") + (dict "t" "Choose your password" "d" "It is yours alone. Nobody else on the account can see it.") + (dict "t" "Sign in to Vantage HQ" "d" (printf "Use %s from then on." .Email)))}} +{{template "callout" (dict "tone" "none" "title" "Not expecting this?" "text" "Ignore it. Nothing happens until you open the link, and it expires on its own.")}} {{end}} +{{define "why"}}someone on the {{.AccountName}} account invited {{.Email}}.{{end}} diff --git a/mail/templates/invite.txt.tmpl b/mail/templates/invite.txt.tmpl index 81559e2..f7b434d 100644 --- a/mail/templates/invite.txt.tmpl +++ b/mail/templates/invite.txt.tmpl @@ -1,8 +1,20 @@ {{define "subject"}}You have been invited to {{.AccountName}} on Vantage{{end}} +{{define "tone"}}accent{{end}} +{{define "category"}}Account{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Invitation" "tone" "accent")}}{{end}} {{define "title"}}You have been invited to {{.AccountName}}{{end}} {{define "body"}} -{{template "lead" (printf "You have been invited to join %s on Vantage." .AccountName)}} -{{template "p" "Set your own password and finish joining:"}} -{{template "button" (dict "label" "Set password and join" "url" .Link)}} -{{template "p" "This link expires in 24 hours. If you were not expecting this, ignore it - nothing happens until you open the link."}} +{{template "lead" (printf "You have been invited to join %s on Vantage, where the team manages its servers, agents and monitors." .AccountName)}} +{{template "button" (dict "label" "Accept invitation" "url" .Link)}} +{{template "rows" (list + (dict "k" "Account" "v" .AccountName) + (dict "k" "Invited address" "v" .Email) + (dict "k" "Link valid for" "v" (plural .TTLHours "hour" "hours")))}} +{{template "label" "Joining takes a minute"}} +{{template "steps" (list + (dict "t" "Accept the invitation" "d" "Open the link above from this inbox.") + (dict "t" "Choose your password" "d" "It is yours alone. Nobody else on the account can see it.") + (dict "t" "Sign in to Vantage HQ" "d" (printf "Use %s from then on." .Email)))}} +{{template "callout" (dict "tone" "none" "title" "Not expecting this?" "text" "Ignore it. Nothing happens until you open the link, and it expires on its own.")}} {{end}} +{{define "why"}}someone on the {{.AccountName}} account invited {{.Email}}.{{end}} diff --git a/mail/templates/layout.html.tmpl b/mail/templates/layout.html.tmpl index dc4d7e5..a174f6c 100644 --- a/mail/templates/layout.html.tmpl +++ b/mail/templates/layout.html.tmpl @@ -17,102 +17,272 @@ --ink-2 #9fb3ca --pend #d6a63f --ink-3 #71879f --well #04101f + Derived here only, never in a front end: each tone has a tint (15% over + --panel) for washes and a border (35% over --panel) for outlines. + + up #173743 / #245452 pend #2b3539 / #534f3a + down #2d2d3d / #573d44 accent #193352 / #284b75 + Layout is tables and inline styles throughout, which is not a stylistic choice - it is the only thing Outlook renders predictably. - A message file overrides "title", "pill" and "body"; the empty defaults below + A message file overrides "title", "pill", "body", "category", "preheader" + and "why"; its txt file defines "subject" and "tone". The defaults below exist so that a message needing no pill does not have to define one. + + Colour choices that depend on a tone go through pick, which takes the tone + and one value per tone (up, down, pend, accent, anything else), so the hex + stays in this file while the branching stays out of it. */ -}} {{- define "title"}}{{end -}} {{- define "pill"}}{{end -}} {{- define "body"}}{{end -}} +{{- define "category"}}Account{{end -}} +{{- define "preheader"}}{{end -}} +{{- define "why"}}it relates to your Vantage account.{{end -}} {{- /* p renders one paragraph of body copy. */ -}} {{- define "p" -}} -

{{.}}

+

{{.}}

{{- end -}} -{{- /* lead is the first paragraph: same size, brighter, sets the subject. */ -}} +{{- /* lead is the first paragraph: larger, brighter, sets the subject. */ -}} {{- define "lead" -}} -

{{.}}

+

{{.}}

{{- end -}} -{{- /* button takes dict "label" "…" "url" "…". +{{- /* small is fine print under a section. */ -}} +{{- define "small" -}} +

{{.}}

+{{- end -}} + +{{- /* label is an eyebrow over the section that follows it. */ -}} +{{- define "label" -}} +

{{.}}

+{{- end -}} + +{{- define "divider" -}} +
 
+{{- end -}} + +{{- /* button takes dict "label" "…" "url" "…", and optionally "nofallback". The bare URL is printed underneath on purpose: a plain-text-preferring client, a stripped-styles inbox and a forwarded message all lose the anchor, and a verification email whose link cannot be reached is a - support ticket. */ -}} + support ticket. nofallback is for mailto: links, where it would be noise. */ -}} {{- define "button" -}} - +
-
- {{.label}} + + {{.label}} →
-

- Or paste this into your browser:
+{{- if not .nofallback}} +

+ Button not working? Paste this into your browser:
{{.url}}

+{{- end}} {{- end -}} {{- /* well shows machine output - a licence blob, an install ID. Mirrors web/'s --well surface, the floor beneath the ground. */ -}} {{- define "well" -}} -
{{.}}
+
{{.}}
{{- end -}} {{- /* note is a quoted callout, used for a free-text message we did not - write ourselves. */ -}} + write ourselves. Line breaks are kept: it is somebody's own words. */ -}} {{- define "note" -}} -

{{.}}

+

{{.}}

{{- end -}} -{{- /* rows takes a list of dict "k" "…" "v" "…". */ -}} +{{- /* rows takes a list of dict "k" "…" "v" "…", and renders them as one + panel of facts. */ -}} {{- define "rows" -}} - - {{- range .}} +
+ {{- range $i, $r := .}} - - + + {{- end}}
{{.k}}{{.v}}{{$r.k}}{{$r.v}}
{{- end -}} -{{- /* chip takes dict "label" "…" "tone" "up|down|pend|accent". Tone is +{{- /* chip takes dict "label" "…" "tone" "up|down|pend|accent|none". Tone is never the only signal: the label spells the state out. */ -}} {{- define "chip" -}} -{{- $fg := "#5b9be8"}}{{if eq .tone "up"}}{{$fg = "#4fb484"}}{{else if eq .tone "down"}}{{$fg = "#e2705a"}}{{else if eq .tone "pend"}}{{$fg = "#d6a63f"}}{{end -}} -{{.label}} +{{- $fg := pick .tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#9fb3ca"}}{{$bg := pick .tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#102842"}}{{$bd := pick .tone "#245452" "#573d44" "#534f3a" "#284b75" "#1e3855" -}} +{{.label}} {{- end -}} +{{- /* callout takes dict "tone" "…" "title" "…" "text" "…": one tinted panel + for the sentence the reader must not miss. */ -}} +{{- define "callout" -}} +{{- $fg := pick .tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#9fb3ca"}}{{$bg := pick .tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#102842"}}{{$bd := pick .tone "#245452" "#573d44" "#534f3a" "#284b75" "#1e3855" -}} + + + + +
+

{{.title}}

+

{{.text}}

+
+{{- end -}} + +{{- /* steps takes a list of dict "t" "…" "d" "…". Numbered because the + content is always an order the reader follows. */ -}} +{{- define "steps" -}} + + {{- range $i, $s := .}} + + + + + {{- end}} +
+ + +
{{add $i 1}}
+
+

{{$s.t}}

+

{{$s.d}}

+
+{{- end -}} + +{{- /* stats takes a list of dict "big" "…" "label" "…" "tone" "…": the + figures a message exists to state, such as days left. */ -}} +{{- define "stats" -}} + + + {{- range $i, $s := .}} + {{- if $i}}{{end}} + + {{- end}} + +
  +

{{$s.big}}

+

{{$s.label}}

+
+{{- end -}} + +{{- /* timeline takes a list of dict "label" "…" "date" "…" "state" + "done|now|next" "tone" "…". It is the licence lifecycle, drawn as + segments so the reader sees where they are in it. */ -}} +{{- define "timeline" -}} + + + {{- range $i, $s := .}} + {{- if $i}}{{end}} + + {{- end}} + +
  +
 
+

{{$s.label}}

+

{{$s.date}}

+
+{{- end -}} + +{{- /* findings takes a list of VulnDigestRow: one line per finding, the + severity as a chip, the advisory linked when its ID says where it is. */ -}} +{{- define "findings" -}} + + {{- range $i, $r := .}} + + + + + {{- end}} +
+

{{template "chip" (dict "label" $r.Severity "tone" (sevTone $r.Severity))}}  {{with advisoryURL $r.CVEID}}{{$r.CVEID}}{{else}}{{$r.CVEID}}{{end}}

+

{{$r.PackageName}} on {{$r.ServerName}}

+
+ {{- if $r.FixedIn}}Fixed in {{$r.FixedIn}}{{else}}No fix yet{{end -}} +
+{{- end -}} + +{{- $tone := tone -}} - - + + + + + +{{template "title" .}} + + +
{{template "preheader" .}} ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
+
-
- - + diff --git a/mail/templates/layout.txt.tmpl b/mail/templates/layout.txt.tmpl index 279e16d..8b5dc85 100644 --- a/mail/templates/layout.txt.tmpl +++ b/mail/templates/layout.txt.tmpl @@ -1,18 +1,23 @@ {{- /* The plain-text counterpart of layout.html.tmpl. - It defines the same helper names - p, lead, button, well, note, rows, chip - - so a message's txt file reads as the same document as its html one, and a - helper added on one side is obvious by its absence on the other. + It defines the same helper names - p, lead, small, label, divider, button, + well, note, rows, chip, callout, steps, stats, timeline - so a message's txt + file reads as the same document as its html one, and a helper added on one + side is obvious by its absence on the other. - "subject" is defined here rather than in the HTML file: html/template would - escape an ampersand in an instance name, and mail clients show subject lines - verbatim. + "subject" and "tone" are defined here rather than in the HTML file: + html/template would escape an ampersand in an instance name, and mail + clients show subject lines verbatim. tone is read once per render and handed + to both layouts through the tone func. */ -}} {{- define "subject"}}Vantage{{end -}} +{{- define "tone"}}accent{{end -}} {{- define "title"}}{{end -}} {{- define "pill"}}{{end -}} {{- define "body"}}{{end -}} +{{- define "category"}}Account{{end -}} +{{- define "why"}}it relates to your Vantage account.{{end -}} {{- define "p"}}{{.}} @@ -20,8 +25,16 @@ {{- define "lead"}}{{.}} {{end -}} -{{- define "button"}}{{.label}}: +{{- define "small"}}{{.}} +{{end -}} +{{- define "label"}}{{upper .}} + +{{end -}} +{{- define "divider"}}---------------------------------------- + +{{end -}} +{{- define "button"}}{{.label}}: {{.url}} {{end -}} @@ -31,16 +44,38 @@ {{- define "note"}}{{.}} {{end -}} -{{- define "rows"}}{{range .}}{{.k}}: {{.v}} +{{- define "rows"}}{{range .}} {{.k}}: {{.v}} {{end}} {{end -}} -{{- define "chip"}}[{{upper .label}}] +{{- define "chip"}}[{{upper .label}}]{{end -}} +{{- define "callout"}}>> {{.title}} + {{.text}} {{end -}} -VANTAGE +{{- define "steps"}}{{range $i, $s := .}} {{add $i 1}}. {{$s.t}} + {{$s.d}} +{{end}} +{{end -}} +{{- define "stats"}}{{range .}} {{.label}}: {{.big}} +{{end}} +{{end -}} +{{- define "timeline"}}{{range .}} {{if eq .state "done"}}[x]{{else if eq .state "now"}}[>]{{else}}[ ]{{end}} {{.label}}: {{.date}} +{{end}} +{{end -}} +{{- define "findings"}}{{range .}} - {{.CVEID}} [{{upper .Severity}}] {{.PackageName}} on {{.ServerName}}, {{if .FixedIn}}fixed in {{.FixedIn}}{{else}}no fix published yet{{end}} +{{end}} +{{end -}} +VANTAGE · {{template "category" .}} + {{template "pill" .}} {{template "title" .}} +======================================== {{template "body" .}} --- -Sent by Vantage · infrastructure control plane +---------------------------------------- +Why you got this: {{template "why" .}} +{{with hq}} +Vantage HQ: {{.}} +Billing: {{.}}/billing +{{end}} +Vantage · infrastructure control plane diff --git a/mail/templates/license.html.tmpl b/mail/templates/license.html.tmpl index c2e8b25..f3bfeb9 100644 --- a/mail/templates/license.html.tmpl +++ b/mail/templates/license.html.tmpl @@ -1,7 +1,19 @@ -{{define "title"}}Your licence key{{end}} +{{define "category"}}Licence{{end}} +{{define "preheader"}}Paste this key into Settings, Licence on {{.InstanceName}}.{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Licence key" "tone" "accent")}}{{end}} +{{define "title"}}Your licence key for {{.InstanceName}}{{end}} {{define "body"}} -{{template "lead" (printf "Your licence for %s is below." .InstanceName)}} -{{template "p" "Paste it into Settings → Licence on your Vantage install:"}} +{{template "lead" (printf "Here is the signed licence for %s. It activates that install and no other." .InstanceName)}} +{{template "label" "Install it in three steps"}} +{{template "steps" (list + (dict "t" "Open your Vantage install" "d" "Sign in as an administrator.") + (dict "t" "Go to Settings → Licence" "d" "The current licence and its expiry are shown there.") + (dict "t" "Paste the key and save" "d" "Copy the whole block below, from the first character to the last."))}} +{{template "label" "Licence key"}} {{template "well" .Blob}} -{{template "p" "The licence is signed public data, not a secret - it is useless on any instance other than the one it names."}} +{{template "rows" (list + (dict "k" "Instance" "v" .InstanceName) + (dict "k" "Issued" "v" (stamp .Issued)))}} +{{template "callout" (dict "tone" "accent" "title" "Safe to keep in email" "text" "The licence is signed public data, not a secret. It is useless on any instance other than the one it names.")}} {{end}} +{{define "why"}}a licence was issued for {{.InstanceName}}.{{end}} diff --git a/mail/templates/license.txt.tmpl b/mail/templates/license.txt.tmpl index 0c0156f..10af6c0 100644 --- a/mail/templates/license.txt.tmpl +++ b/mail/templates/license.txt.tmpl @@ -1,8 +1,20 @@ -{{define "subject"}}Your Vantage licence key{{end}} -{{define "title"}}Your licence key{{end}} +{{define "subject"}}Your Vantage licence key for {{.InstanceName}}{{end}} +{{define "tone"}}accent{{end}} +{{define "category"}}Licence{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Licence key" "tone" "accent")}}{{end}} +{{define "title"}}Your licence key for {{.InstanceName}}{{end}} {{define "body"}} -{{template "lead" (printf "Your licence for %s is below." .InstanceName)}} -{{template "p" "Paste it into Settings > Licence on your Vantage install:"}} +{{template "lead" (printf "Here is the signed licence for %s. It activates that install and no other." .InstanceName)}} +{{template "label" "Install it in three steps"}} +{{template "steps" (list + (dict "t" "Open your Vantage install" "d" "Sign in as an administrator.") + (dict "t" "Go to Settings > Licence" "d" "The current licence and its expiry are shown there.") + (dict "t" "Paste the key and save" "d" "Copy the whole block below, from the first character to the last."))}} +{{template "label" "Licence key"}} {{template "well" .Blob}} -{{template "p" "The licence is signed public data, not a secret - it is useless on any instance other than the one it names."}} +{{template "rows" (list + (dict "k" "Instance" "v" .InstanceName) + (dict "k" "Issued" "v" (stamp .Issued)))}} +{{template "callout" (dict "tone" "accent" "title" "Safe to keep in email" "text" "The licence is signed public data, not a secret. It is useless on any instance other than the one it names.")}} {{end}} +{{define "why"}}a licence was issued for {{.InstanceName}}.{{end}} diff --git a/mail/templates/monitoralert.html.tmpl b/mail/templates/monitoralert.html.tmpl index 84ee42f..e325d8d 100644 --- a/mail/templates/monitoralert.html.tmpl +++ b/mail/templates/monitoralert.html.tmpl @@ -1,10 +1,15 @@ +{{define "category"}}Monitoring{{end}} +{{define "preheader"}}{{.Type}} check {{if .Down}}is failing{{else}}is passing again{{end}}{{if .Message}}: {{.Message}}{{end}}{{end}} {{define "pill"}}{{if .Down}}{{template "chip" (dict "label" "Down" "tone" "down")}}{{else}}{{template "chip" (dict "label" "Recovered" "tone" "up")}}{{end}}{{end}} -{{define "title"}}{{.MonitorName}}{{end}} +{{define "title"}}{{.MonitorName}} {{if .Down}}is down{{else}}has recovered{{end}}{{end}} {{define "body"}} -{{template "p" (printf "%s check" .Type)}} -{{if .Message}}{{template "note" .Message}}{{end}} +{{if .Down}}{{template "lead" (printf "The %s check for %s started failing at %s." .Type .MonitorName (stamp .Time))}}{{else}}{{template "lead" (printf "The %s check for %s is passing again as of %s." .Type .MonitorName (stamp .Time))}}{{end}} +{{if .Message}}{{template "label" "Check output"}}{{template "note" .Message}}{{end}} {{template "rows" (list + (dict "k" "Monitor" "v" .MonitorName) + (dict "k" "Check type" "v" .Type) (dict "k" "Status" "v" (printf "%s → %s" .OldStatus .NewStatus)) - (dict "k" "Type" "v" .Type) - (dict "k" "Time" "v" (stamp .Time)))}} + (dict "k" "Changed at" "v" (stamp .Time)))}} +{{if .Down}}{{template "callout" (dict "tone" "down" "title" "What to do" "text" "Open the monitor in your control plane to see recent samples and response times. You will get another email as soon as it recovers.")}}{{else}}{{template "callout" (dict "tone" "up" "title" "All clear" "text" "No action is needed. You will be emailed again if the check fails.")}}{{end}} {{end}} +{{define "why"}}this address is on an email notification channel for this monitor.{{end}} diff --git a/mail/templates/monitoralert.txt.tmpl b/mail/templates/monitoralert.txt.tmpl index 34d480a..49cf3fc 100644 --- a/mail/templates/monitoralert.txt.tmpl +++ b/mail/templates/monitoralert.txt.tmpl @@ -1,11 +1,16 @@ {{define "subject"}}[Vantage] {{.MonitorName}} ({{.Type}}) {{if .Down}}is DOWN{{else}}recovered{{end}}{{if .Message}}: {{.Message}}{{end}}{{end}} +{{define "tone"}}{{if .Down}}down{{else}}up{{end}}{{end}} +{{define "category"}}Monitoring{{end}} {{define "pill"}}{{if .Down}}{{template "chip" (dict "label" "Down" "tone" "down")}}{{else}}{{template "chip" (dict "label" "Recovered" "tone" "up")}}{{end}}{{end}} -{{define "title"}}{{.MonitorName}}{{end}} +{{define "title"}}{{.MonitorName}} {{if .Down}}is down{{else}}has recovered{{end}}{{end}} {{define "body"}} -{{template "p" (printf "%s check" .Type)}} -{{if .Message}}{{template "note" .Message}}{{end}} +{{if .Down}}{{template "lead" (printf "The %s check for %s started failing at %s." .Type .MonitorName (stamp .Time))}}{{else}}{{template "lead" (printf "The %s check for %s is passing again as of %s." .Type .MonitorName (stamp .Time))}}{{end}} +{{if .Message}}{{template "label" "Check output"}}{{template "note" .Message}}{{end}} {{template "rows" (list + (dict "k" "Monitor" "v" .MonitorName) + (dict "k" "Check type" "v" .Type) (dict "k" "Status" "v" (printf "%s -> %s" .OldStatus .NewStatus)) - (dict "k" "Type" "v" .Type) - (dict "k" "Time" "v" (stamp .Time)))}} + (dict "k" "Changed at" "v" (stamp .Time)))}} +{{if .Down}}{{template "callout" (dict "tone" "down" "title" "What to do" "text" "Open the monitor in your control plane to see recent samples and response times. You will get another email as soon as it recovers.")}}{{else}}{{template "callout" (dict "tone" "up" "title" "All clear" "text" "No action is needed. You will be emailed again if the check fails.")}}{{end}} {{end}} +{{define "why"}}this address is on an email notification channel for this monitor.{{end}} diff --git a/mail/templates/pastdue.html.tmpl b/mail/templates/pastdue.html.tmpl index 611f485..96d1e63 100644 --- a/mail/templates/pastdue.html.tmpl +++ b/mail/templates/pastdue.html.tmpl @@ -1,7 +1,19 @@ +{{define "category"}}Billing{{end}} +{{define "preheader"}}Nothing has changed yet. Update your payment method to avoid interruption.{{end}} {{define "pill"}}{{template "chip" (dict "label" "Payment failed" "tone" "pend")}}{{end}} {{define "title"}}Payment failed for {{.InstanceName}}{{end}} {{define "body"}} -{{template "lead" (printf "A payment for %s failed." .InstanceName)}} -{{template "p" "Your instance is unaffected while the card is retried. Update your payment method from the billing portal."}} -{{if .PortalURL}}{{template "button" (dict "label" "Open billing portal" "url" .PortalURL)}}{{end}} +{{template "lead" (printf "A payment for %s did not go through. The card will be retried automatically." .InstanceName)}} +{{template "callout" (dict "tone" "up" "title" "Your instance is unaffected" "text" "Servers, agents and monitors keep running while the payment is retried. The licence is untouched.")}} +{{if .PortalURL}}{{template "button" (dict "label" "Update payment method" "url" (printf "%s/billing" .PortalURL))}}{{end}} +{{template "label" "How to fix it"}} +{{template "steps" (list + (dict "t" "Open billing in Vantage HQ" "d" "Sign in and go to Billing.") + (dict "t" "Update your payment method" "d" "Add a new card, or correct the details of the current one.") + (dict "t" "That is all" "d" "The next retry uses the updated details."))}} +{{template "rows" (list + (dict "k" "Instance" "v" .InstanceName) + (dict "k" "Payment" "v" "Failed, retrying") + (dict "k" "Licence" "v" "Unaffected"))}} {{end}} +{{define "why"}}a payment for the {{.InstanceName}} subscription failed.{{end}} diff --git a/mail/templates/pastdue.txt.tmpl b/mail/templates/pastdue.txt.tmpl index c3319b8..b2d2b80 100644 --- a/mail/templates/pastdue.txt.tmpl +++ b/mail/templates/pastdue.txt.tmpl @@ -1,8 +1,20 @@ -{{define "subject"}}Payment failed for your Vantage subscription{{end}} +{{define "subject"}}Payment failed for your Vantage subscription ({{.InstanceName}}){{end}} +{{define "tone"}}pend{{end}} +{{define "category"}}Billing{{end}} {{define "pill"}}{{template "chip" (dict "label" "Payment failed" "tone" "pend")}}{{end}} {{define "title"}}Payment failed for {{.InstanceName}}{{end}} {{define "body"}} -{{template "lead" (printf "A payment for %s failed." .InstanceName)}} -{{template "p" "Your instance is unaffected while the card is retried. Update your payment method from the billing portal."}} -{{if .PortalURL}}{{template "button" (dict "label" "Open billing portal" "url" .PortalURL)}}{{end}} +{{template "lead" (printf "A payment for %s did not go through. The card will be retried automatically." .InstanceName)}} +{{template "callout" (dict "tone" "up" "title" "Your instance is unaffected" "text" "Servers, agents and monitors keep running while the payment is retried. The licence is untouched.")}} +{{if .PortalURL}}{{template "button" (dict "label" "Update payment method" "url" (printf "%s/billing" .PortalURL))}}{{end}} +{{template "label" "How to fix it"}} +{{template "steps" (list + (dict "t" "Open billing in Vantage HQ" "d" "Sign in and go to Billing.") + (dict "t" "Update your payment method" "d" "Add a new card, or correct the details of the current one.") + (dict "t" "That is all" "d" "The next retry uses the updated details."))}} +{{template "rows" (list + (dict "k" "Instance" "v" .InstanceName) + (dict "k" "Payment" "v" "Failed, retrying") + (dict "k" "Licence" "v" "Unaffected"))}} {{end}} +{{define "why"}}a payment for the {{.InstanceName}} subscription failed.{{end}} diff --git a/mail/templates/renewed.html.tmpl b/mail/templates/renewed.html.tmpl index 94056a6..6390588 100644 --- a/mail/templates/renewed.html.tmpl +++ b/mail/templates/renewed.html.tmpl @@ -1,6 +1,20 @@ +{{define "category"}}Licence{{end}} +{{define "preheader"}}Licence extended to {{date .Expires}}. Nothing else changes.{{end}} {{define "pill"}}{{template "chip" (dict "label" "Renewed" "tone" "up")}}{{end}} {{define "title"}}{{.InstanceName}} is renewed{{end}} {{define "body"}} {{template "lead" (printf "Your Free licence for %s now runs until %s." .InstanceName (date .Expires))}} -{{template "p" "Nothing else changes - your servers, agents and monitors carry on as they were."}} +{{template "stats" (list + (dict "big" (plural (daysUntil .Expires) "day" "days") "label" "remaining on the licence" "tone" "up") + (dict "big" (shortDate .Expires) "label" "new expiry date" "tone" "none"))}} +{{template "timeline" (list + (dict "label" "Renewed" "date" "Today" "state" "done") + (dict "label" "Active" "date" (printf "Until %s" (shortDate .Expires)) "state" "now" "tone" "up") + (dict "label" "Reminder" "date" "A week before" "state" "next"))}} +{{template "rows" (list + (dict "k" "Instance" "v" .InstanceName) + (dict "k" "Plan" "v" "Free") + (dict "k" "Licence expires" "v" (date .Expires)))}} +{{template "p" "Nothing else changes. Your servers, agents and monitors carry on as they were."}} {{end}} +{{define "why"}}the licence for {{.InstanceName}} was renewed.{{end}} diff --git a/mail/templates/renewed.txt.tmpl b/mail/templates/renewed.txt.tmpl index 7c2d233..3e5bb8d 100644 --- a/mail/templates/renewed.txt.tmpl +++ b/mail/templates/renewed.txt.tmpl @@ -1,7 +1,19 @@ -{{define "subject"}}{{.InstanceName}} renewed{{end}} +{{define "subject"}}{{.InstanceName}} renewed until {{shortDate .Expires}}{{end}} +{{define "tone"}}up{{end}} +{{define "category"}}Licence{{end}} {{define "pill"}}{{template "chip" (dict "label" "Renewed" "tone" "up")}}{{end}} {{define "title"}}{{.InstanceName}} is renewed{{end}} {{define "body"}} {{template "lead" (printf "Your Free licence for %s now runs until %s." .InstanceName (date .Expires))}} -{{template "p" "Nothing else changes - your servers, agents and monitors carry on as they were."}} +{{template "timeline" (list + (dict "label" "Renewed" "date" "Today" "state" "done") + (dict "label" "Active" "date" (printf "Until %s" (shortDate .Expires)) "state" "now" "tone" "up") + (dict "label" "Reminder" "date" "A week before" "state" "next"))}} +{{template "rows" (list + (dict "k" "Instance" "v" .InstanceName) + (dict "k" "Plan" "v" "Free") + (dict "k" "Licence expires" "v" (date .Expires)) + (dict "k" "Remaining" "v" (plural (daysUntil .Expires) "day" "days")))}} +{{template "p" "Nothing else changes. Your servers, agents and monitors carry on as they were."}} {{end}} +{{define "why"}}the licence for {{.InstanceName}} was renewed.{{end}} diff --git a/mail/templates/verification.html.tmpl b/mail/templates/verification.html.tmpl index 48b9668..f45d1cd 100644 --- a/mail/templates/verification.html.tmpl +++ b/mail/templates/verification.html.tmpl @@ -1,7 +1,19 @@ +{{define "category"}}Account{{end}} +{{define "preheader"}}One click to confirm {{.Email}}. The link expires in {{.TTLHours}} hours.{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Action needed" "tone" "accent")}}{{end}} {{define "title"}}Confirm your email address{{end}} {{define "body"}} -{{template "lead" "Confirm this address to finish setting up your Vantage account."}} +{{template "lead" (printf "Welcome to Vantage. Confirm that %s is your address and your account is ready to use." .Email)}} {{template "button" (dict "label" "Confirm email address" "url" .Link)}} -{{template "p" (printf "The link works once and expires in %d hours." .TTLHours)}} -{{template "p" "If you did not request this, ignore this email - nothing happens until the link is opened."}} +{{template "rows" (list + (dict "k" "Account email" "v" .Email) + (dict "k" "Link expires" "v" (stamp .Expires)) + (dict "k" "Can be used" "v" "Once"))}} +{{template "label" "What happens next"}} +{{template "steps" (list + (dict "t" "Confirm your address" "d" "Open the link above. It works once, then it is spent.") + (dict "t" "Sign in to Vantage HQ" "d" "Use this email address and the password you chose at sign-up.") + (dict "t" "Create your first instance" "d" "It starts on a Free licence you can renew in one click."))}} +{{template "callout" (dict "tone" "none" "title" "Did not sign up?" "text" "Ignore this email. Nothing is activated until the link is opened, and it expires on its own.")}} {{end}} +{{define "why"}}someone signed up for Vantage with {{.Email}}.{{end}} diff --git a/mail/templates/verification.txt.tmpl b/mail/templates/verification.txt.tmpl index 9be7061..a644e10 100644 --- a/mail/templates/verification.txt.tmpl +++ b/mail/templates/verification.txt.tmpl @@ -1,8 +1,20 @@ -{{define "subject"}}Verify your Vantage account{{end}} +{{define "subject"}}Confirm your email to finish setting up Vantage{{end}} +{{define "tone"}}accent{{end}} +{{define "category"}}Account{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Action needed" "tone" "accent")}}{{end}} {{define "title"}}Confirm your email address{{end}} {{define "body"}} -{{template "lead" "Confirm this address to finish setting up your Vantage account."}} +{{template "lead" (printf "Welcome to Vantage. Confirm that %s is your address and your account is ready to use." .Email)}} {{template "button" (dict "label" "Confirm email address" "url" .Link)}} -{{template "p" (printf "The link works once and expires in %d hours." .TTLHours)}} -{{template "p" "If you did not request this, ignore this email - nothing happens until the link is opened."}} +{{template "rows" (list + (dict "k" "Account email" "v" .Email) + (dict "k" "Link expires" "v" (stamp .Expires)) + (dict "k" "Can be used" "v" "Once"))}} +{{template "label" "What happens next"}} +{{template "steps" (list + (dict "t" "Confirm your address" "d" "Open the link above. It works once, then it is spent.") + (dict "t" "Sign in to Vantage HQ" "d" "Use this email address and the password you chose at sign-up.") + (dict "t" "Create your first instance" "d" "It starts on a Free licence you can renew in one click."))}} +{{template "callout" (dict "tone" "none" "title" "Did not sign up?" "text" "Ignore this email. Nothing is activated until the link is opened, and it expires on its own.")}} {{end}} +{{define "why"}}someone signed up for Vantage with {{.Email}}.{{end}} diff --git a/mail/templates/vuln_digest.html.tmpl b/mail/templates/vuln_digest.html.tmpl index d492fe0..83c10a6 100644 --- a/mail/templates/vuln_digest.html.tmpl +++ b/mail/templates/vuln_digest.html.tmpl @@ -1,13 +1,17 @@ -{{define "title"}}New vulnerabilities detected{{end}} -{{define "pill"}}{{template "chip" (dict "label" (upper .TopSeverity) "tone" "down")}}{{end}} +{{define "category"}}Security{{end}} +{{define "preheader"}}{{.Summary}}{{end}} +{{define "pill"}}{{template "chip" (dict "label" (printf "Highest: %s" .TopSeverity) "tone" (sevTone .TopSeverity))}}{{end}} +{{define "title"}}{{plural .Count "new vulnerability" "new vulnerabilities"}} on {{.InstanceName}}{{end}} {{define "body"}} {{template "lead" .Summary}} -{{template "rows" (list - (dict "k" "Instance" "v" .InstanceName) - (dict "k" "New findings" "v" .Count))}} -{{range .Rows}} -{{if .FixedIn}}{{template "well" (printf "%s (%s) - %s on %s, fixed in %s" .CVEID .Severity .PackageName .ServerName .FixedIn)}}{{else}}{{template "well" (printf "%s (%s) - %s on %s, no fix published" .CVEID .Severity .PackageName .ServerName)}}{{end}} -{{end}} -{{if .More}}{{template "p" (printf "…and %d more." .More)}}{{end}} -{{template "note" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}} +{{template "stats" (list + (dict "big" .Count "label" "new findings" "tone" (sevTone .TopSeverity)) + (dict "big" .TopSeverity "label" "highest severity" "tone" (sevTone .TopSeverity)) + (dict "big" .DBAge "label" "database age" "tone" "none"))}} +{{template "label" "Findings"}} +{{template "findings" .Rows}} +{{if .More}}{{template "small" (printf "Plus %d more not listed here. The full list is on the Vulnerabilities page of your control plane." .More)}}{{end}} +{{template "callout" (dict "tone" "accent" "title" "What to do" "text" "Update each affected package to the fixed version shown. Findings marked No fix yet have no vendor patch published so far.")}} +{{template "small" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}} {{end}} +{{define "why"}}this address is on a notification channel attached to a vulnerability alert rule on {{.InstanceName}}.{{end}} diff --git a/mail/templates/vuln_digest.txt.tmpl b/mail/templates/vuln_digest.txt.tmpl index 1c00c08..e47e5df 100644 --- a/mail/templates/vuln_digest.txt.tmpl +++ b/mail/templates/vuln_digest.txt.tmpl @@ -1,12 +1,18 @@ -{{define "subject"}}{{.Count}} new {{if eq .Count 1}}vulnerability{{else}}vulnerabilities{{end}} on {{.InstanceName}}{{end}} -{{define "title"}}New vulnerabilities detected{{end}} -{{define "pill"}}{{.TopSeverity}}{{end}} +{{define "subject"}}{{plural .Count "new vulnerability" "new vulnerabilities"}} on {{.InstanceName}} (highest: {{.TopSeverity}}){{end}} +{{define "tone"}}{{sevTone .TopSeverity}}{{end}} +{{define "category"}}Security{{end}} +{{define "pill"}}{{template "chip" (dict "label" (printf "Highest: %s" .TopSeverity) "tone" (sevTone .TopSeverity))}}{{end}} +{{define "title"}}{{plural .Count "new vulnerability" "new vulnerabilities"}} on {{.InstanceName}}{{end}} {{define "body"}} {{template "lead" .Summary}} - -{{range .Rows}}- {{.CVEID}} ({{.Severity}}) - {{.PackageName}} on {{.ServerName}}{{if .FixedIn}}, fixed in {{.FixedIn}}{{else}}, no fix published{{end}} +{{template "stats" (list + (dict "big" .Count "label" "New findings" "tone" (sevTone .TopSeverity)) + (dict "big" .TopSeverity "label" "Highest severity" "tone" (sevTone .TopSeverity)) + (dict "big" .DBAge "label" "Database age" "tone" "none"))}} +{{template "label" "Findings"}} +{{template "findings" .Rows}} +{{if .More}}{{template "small" (printf "Plus %d more not listed here. The full list is on the Vulnerabilities page of your control plane." .More)}}{{end}} +{{template "callout" (dict "tone" "accent" "title" "What to do" "text" "Update each affected package to the fixed version shown. Findings marked no fix published yet have no vendor patch so far.")}} +{{template "small" (printf "Scanned against a vulnerability database pulled %s ago." .DBAge)}} {{end}} -{{if .More}}...and {{.More}} more.{{end}} - -Scanned against vulnerability database pulled {{.DBAge}} ago. -{{end}} \ No newline at end of file +{{define "why"}}this address is on a notification channel attached to a vulnerability alert rule on {{.InstanceName}}.{{end}}
+ + - + - + - +
- Vantage + + + + + + +
+ + + +
VVantage
+
{{template "category" .}}
- {{template "pill" .}} -

{{template "title" .}}

- {{template "body" .}} +
+ + + + + + + + + + + +
 
+ + + +
{{pick $tone "✓" "!" "!" "→" "→"}}{{template "pill" .}}
+

{{template "title" .}}

+
+ {{template "body" .}} +
+

Why you got this: {{template "why" .}}

+
-

Sent by Vantage · infrastructure control plane

+
+ {{- with hq}} +

+ Vantage HQ +  ·  + Instances +  ·  + Billing +  ·  + Settings +

+ {{- end}} +

Vantage · infrastructure control plane

+

© {{year}} Vantage. This is an automated message about your service.