feat: Updated email templates

This commit is contained in:
2026-09-10 12:06:29 +00:00
parent 059949674d
commit e1ad2467d2
34 changed files with 896 additions and 163 deletions
+22 -4
View File
@@ -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()),
}
}
+7 -5
View File
@@ -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}
}
+38 -12
View File
@@ -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}
}
+95 -5
View File
@@ -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 "&amp;" 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 ""
},
}
+107
View File
@@ -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, "<no value>") {
t.Errorf("%s: %s part has <no value>", 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")
}
}
+1 -1
View File
@@ -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)
}
+15 -2
View File
@@ -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}}
+16 -3
View File
@@ -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}}
+8 -1
View File
@@ -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}}
+7 -2
View File
@@ -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}}
+16 -1
View File
@@ -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}}
+14 -1
View File
@@ -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}}
+18 -3
View File
@@ -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}}
+15 -3
View File
@@ -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}}
+13 -2
View File
@@ -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}}
+13 -2
View File
@@ -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}}
+20 -3
View File
@@ -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}}
+14 -3
View File
@@ -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}}
+16 -4
View File
@@ -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}}
+16 -4
View File
@@ -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}}
+205 -35
View File
@@ -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" -}}
<p style="margin:0 0 16px;color:#9fb3ca;font-size:14px;line-height:1.6;">{{.}}</p>
<p style="margin:0 0 16px;color:#9fb3ca;font-size:14px;line-height:1.65;">{{.}}</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" -}}
<p style="margin:0 0 16px;color:#e4ecf6;font-size:15px;line-height:1.6;">{{.}}</p>
<p style="margin:0 0 20px;color:#e4ecf6;font-size:16px;line-height:1.6;">{{.}}</p>
{{- end -}}
{{- /* button takes dict "label" "…" "url" "…".
{{- /* small is fine print under a section. */ -}}
{{- define "small" -}}
<p style="margin:0 0 16px;color:#71879f;font-size:12px;line-height:1.6;">{{.}}</p>
{{- end -}}
{{- /* label is an eyebrow over the section that follows it. */ -}}
{{- define "label" -}}
<p style="margin:24px 0 10px;color:#71879f;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;">{{.}}</p>
{{- end -}}
{{- define "divider" -}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0 20px;"><tr><td style="height:1px;line-height:1px;font-size:0;background:#1e3855;">&nbsp;</td></tr></table>
{{- 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" -}}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:4px 0 16px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:4px 0 14px;">
<tr>
<td style="border-radius:4px;background:#5b9be8;">
<a href="{{.url}}" style="display:inline-block;padding:10px 20px;color:#04101f;font-size:14px;font-weight:600;text-decoration:none;">{{.label}}</a>
<td style="border-radius:6px;background:#5b9be8;">
<a href="{{.url}}" style="display:inline-block;padding:12px 24px;color:#04101f;font-size:14px;font-weight:700;text-decoration:none;border-radius:6px;">{{.label}} &rarr;</a>
</td>
</tr>
</table>
<p style="margin:0 0 20px;color:#71879f;font-size:12px;line-height:1.5;word-break:break-all;">
Or paste this into your browser:<br>
{{- if not .nofallback}}
<p style="margin:0 0 22px;color:#71879f;font-size:12px;line-height:1.5;word-break:break-all;">
Button not working? Paste this into your browser:<br>
<a href="{{.url}}" style="color:#5b9be8;text-decoration:none;">{{.url}}</a>
</p>
{{- end}}
{{- end -}}
{{- /* well shows machine output - a licence blob, an install ID. Mirrors
web/'s --well surface, the floor beneath the ground. */ -}}
{{- define "well" -}}
<pre style="margin:0 0 20px;padding:14px;background:#04101f;border:1px solid #1e3855;border-radius:4px;color:#9fb3ca;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all;">{{.}}</pre>
<pre style="margin:0 0 20px;padding:14px 16px;background:#04101f;border:1px solid #1e3855;border-radius:6px;color:#9fb3ca;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:12px;line-height:1.55;white-space:pre-wrap;word-break:break-all;">{{.}}</pre>
{{- 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" -}}
<p style="margin:0 0 20px;padding:12px 14px;background:#102842;border:1px solid #1e3855;border-radius:4px;color:#e4ecf6;font-size:13px;line-height:1.6;">{{.}}</p>
<p style="margin:0 0 20px;padding:14px 16px;background:#102842;border:1px solid #1e3855;border-radius:6px;color:#e4ecf6;font-size:13px;line-height:1.65;white-space:pre-wrap;">{{.}}</p>
{{- 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" -}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 8px;border-top:1px solid #1e3855;">
{{- range .}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 20px;background:#102842;border:1px solid #1e3855;border-radius:6px;border-collapse:separate;">
{{- range $i, $r := .}}
<tr>
<td style="padding:9px 0;border-bottom:1px solid #172c44;color:#71879f;font-size:13px;width:130px;vertical-align:top;">{{.k}}</td>
<td style="padding:9px 0;border-bottom:1px solid #172c44;color:#e4ecf6;font-size:13px;font-weight:500;">{{.v}}</td>
<td style="padding:11px 16px;{{if $i}}border-top:1px solid #172c44;{{end}}color:#71879f;font-size:13px;width:140px;vertical-align:top;">{{$r.k}}</td>
<td style="padding:11px 16px 11px 0;{{if $i}}border-top:1px solid #172c44;{{end}}color:#e4ecf6;font-size:13px;font-weight:600;word-break:break-word;">{{$r.v}}</td>
</tr>
{{- end}}
</table>
{{- 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 -}}
<span style="display:inline-block;margin:0 0 12px;padding:4px 11px;border:1px solid {{$fg}};border-radius:9999px;color:{{$fg}};font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;">{{.label}}</span>
{{- $fg := pick .tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#9fb3ca"}}{{$bg := pick .tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#102842"}}{{$bd := pick .tone "#245452" "#573d44" "#534f3a" "#284b75" "#1e3855" -}}
<span style="display:inline-block;padding:4px 11px;background:{{$bg}};border:1px solid {{$bd}};border-radius:9999px;color:{{$fg}};font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;white-space:nowrap;">{{.label}}</span>
{{- 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" -}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 20px;background:{{$bg}};border:1px solid {{$bd}};border-radius:6px;border-collapse:separate;">
<tr>
<td style="padding:14px 16px;">
<p style="margin:0 0 4px;color:{{$fg}};font-size:13px;font-weight:700;">{{.title}}</p>
<p style="margin:0;color:#c3d1e1;font-size:13px;line-height:1.6;">{{.text}}</p>
</td>
</tr>
</table>
{{- end -}}
{{- /* steps takes a list of dict "t" "…" "d" "…". Numbered because the
content is always an order the reader follows. */ -}}
{{- define "steps" -}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;">
{{- range $i, $s := .}}
<tr>
<td width="28" style="width:28px;padding:0 12px 14px 0;vertical-align:top;">
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
<td width="26" height="26" align="center" style="width:26px;height:26px;border-radius:9999px;background:#193352;border:1px solid #284b75;color:#7fb2f0;font-size:12px;font-weight:700;line-height:26px;text-align:center;">{{add $i 1}}</td>
</tr></table>
</td>
<td style="padding:3px 0 14px;vertical-align:top;">
<p style="margin:0 0 2px;color:#e4ecf6;font-size:14px;font-weight:600;line-height:1.4;">{{$s.t}}</p>
<p style="margin:0;color:#9fb3ca;font-size:13px;line-height:1.55;">{{$s.d}}</p>
</td>
</tr>
{{- end}}
</table>
{{- end -}}
{{- /* stats takes a list of dict "big" "…" "label" "…" "tone" "…": the
figures a message exists to state, such as days left. */ -}}
{{- define "stats" -}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 20px;">
<tr>
{{- range $i, $s := .}}
{{- if $i}}<td width="10" style="width:10px;font-size:0;">&nbsp;</td>{{end}}
<td style="padding:14px 16px;background:#102842;border:1px solid #1e3855;border-radius:6px;vertical-align:top;">
<p style="margin:0;color:{{pick $s.tone "#4fb484" "#e2705a" "#d6a63f" "#7fb2f0" "#e4ecf6"}};font-size:26px;font-weight:700;line-height:1.15;letter-spacing:-.01em;">{{$s.big}}</p>
<p style="margin:4px 0 0;color:#71879f;font-size:12px;line-height:1.4;">{{$s.label}}</p>
</td>
{{- end}}
</tr>
</table>
{{- 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" -}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 22px;table-layout:fixed;">
<tr>
{{- range $i, $s := .}}
{{- if $i}}<td width="6" style="width:6px;font-size:0;">&nbsp;</td>{{end}}
<td style="vertical-align:top;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="height:4px;line-height:4px;font-size:0;border-radius:2px;background:{{if eq $s.state "now"}}{{pick $s.tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#5b9be8"}}{{else if eq $s.state "done"}}#3d5673{{else}}#1e3855{{end}};">&nbsp;</td></tr></table>
<p style="margin:10px 0 2px;color:{{if eq $s.state "now"}}{{pick $s.tone "#4fb484" "#e2705a" "#d6a63f" "#7fb2f0" "#7fb2f0"}}{{else}}#71879f{{end}};font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;">{{$s.label}}</p>
<p style="margin:0;color:{{if eq $s.state "next"}}#9fb3ca{{else}}#e4ecf6{{end}};font-size:13px;font-weight:600;line-height:1.4;">{{$s.date}}</p>
</td>
{{- end}}
</tr>
</table>
{{- 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" -}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 16px;background:#102842;border:1px solid #1e3855;border-radius:6px;border-collapse:separate;">
{{- range $i, $r := .}}
<tr>
<td style="padding:12px 16px;{{if $i}}border-top:1px solid #172c44;{{end}}vertical-align:top;">
<p style="margin:0 0 6px;">{{template "chip" (dict "label" $r.Severity "tone" (sevTone $r.Severity))}}&nbsp;&nbsp;{{with advisoryURL $r.CVEID}}<a href="{{.}}" style="color:#7fb2f0;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:13px;font-weight:700;text-decoration:none;">{{$r.CVEID}}</a>{{else}}<span style="color:#e4ecf6;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:13px;font-weight:700;">{{$r.CVEID}}</span>{{end}}</p>
<p style="margin:0;color:#9fb3ca;font-size:13px;line-height:1.5;"><span style="color:#e4ecf6;font-weight:600;">{{$r.PackageName}}</span> on {{$r.ServerName}}</p>
</td>
<td align="right" style="padding:12px 16px 12px 0;{{if $i}}border-top:1px solid #172c44;{{end}}vertical-align:top;white-space:nowrap;font-size:12px;font-weight:600;">
{{- if $r.FixedIn}}<span style="color:#4fb484;">Fixed in {{$r.FixedIn}}</span>{{else}}<span style="color:#d6a63f;">No fix yet</span>{{end -}}
</td>
</tr>
{{- end}}
</table>
{{- end -}}
{{- $tone := tone -}}
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#071628;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#071628;padding:32px 12px;">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="supported-color-schemes" content="dark">
<title>{{template "title" .}}</title>
</head>
<body style="margin:0;padding:0;background:#071628;" bgcolor="#071628">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:#071628;font-size:1px;line-height:1px;">{{template "preheader" .}}&#8199;&#847;&#8199;&#847;&#8199;&#847;&#8199;&#847;&#8199;&#847;&#8199;&#847;&#8199;&#847;&#8199;&#847;&#8199;&#847;&#8199;&#847;</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="#071628" style="background:#071628;">
<tr>
<td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background:#0d2138;border:1px solid #1e3855;border-radius:4px;overflow:hidden;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<tr><td style="height:3px;background:#5b9be8;"></td></tr>
<td align="center" style="padding:32px 12px 40px;">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<tr>
<td style="padding:26px 28px 8px;">
<span style="font-size:16px;font-weight:700;letter-spacing:-.01em;color:#7fb2f0;">Vantage</span>
<td style="padding:0 4px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="vertical-align:middle;">
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
<td width="28" height="28" align="center" style="width:28px;height:28px;border-radius:7px;background:#5b9be8;color:#04101f;font-size:15px;font-weight:800;line-height:28px;text-align:center;">V</td>
<td style="padding-left:10px;color:#e4ecf6;font-size:16px;font-weight:700;letter-spacing:-.01em;">Vantage</td>
</tr></table>
</td>
<td align="right" style="vertical-align:middle;color:#71879f;font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;">{{template "category" .}}</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:10px 28px 26px;">
{{template "pill" .}}
<h1 style="margin:2px 0 14px;font-size:20px;font-weight:700;line-height:1.3;color:#e4ecf6;">{{template "title" .}}</h1>
{{template "body" .}}
<td>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0d2138;border:1px solid #1e3855;border-radius:10px;border-collapse:separate;overflow:hidden;">
<tr><td style="height:4px;line-height:4px;font-size:0;background:{{pick $tone "#4fb484" "#e2705a" "#d6a63f" "#5b9be8" "#5b9be8"}};border-radius:10px 10px 0 0;">&nbsp;</td></tr>
<tr>
<td style="padding:28px 32px 8px;background-color:#0d2138;background-image:linear-gradient(180deg,{{pick $tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#193352"}} 0%,#0d2138 100%);">
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 16px;"><tr>
<td width="40" height="40" align="center" style="width:40px;height:40px;border-radius:9999px;background:{{pick $tone "#173743" "#2d2d3d" "#2b3539" "#193352" "#193352"}};border:1px solid {{pick $tone "#245452" "#573d44" "#534f3a" "#284b75" "#284b75"}};color:{{pick $tone "#4fb484" "#e2705a" "#d6a63f" "#7fb2f0" "#7fb2f0"}};font-size:18px;font-weight:800;line-height:40px;text-align:center;">{{pick $tone "✓" "!" "!" "→" "→"}}</td>
<td style="padding-left:12px;vertical-align:middle;">{{template "pill" .}}</td>
</tr></table>
<h1 style="margin:0 0 6px;font-size:23px;font-weight:700;line-height:1.3;letter-spacing:-.01em;color:#e4ecf6;">{{template "title" .}}</h1>
</td>
</tr>
<tr>
<td style="padding:12px 32px 12px;">
{{template "body" .}}
</td>
</tr>
<tr>
<td style="padding:16px 32px 18px;border-top:1px solid #1e3855;background:#0a1c30;border-radius:0 0 10px 10px;">
<p style="margin:0;color:#71879f;font-size:12px;line-height:1.6;"><span style="color:#9fb3ca;font-weight:600;">Why you got this:</span> {{template "why" .}}</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:14px 28px;border-top:1px solid #1e3855;background:#04101f;">
<p style="margin:0;color:#71879f;font-size:12px;line-height:1.5;">Sent by Vantage · infrastructure control plane</p>
<td align="center" style="padding:22px 8px 0;">
{{- with hq}}
<p style="margin:0 0 10px;font-size:12px;line-height:1.6;">
<a href="{{.}}" style="color:#9fb3ca;text-decoration:none;font-weight:600;">Vantage HQ</a>
<span style="color:#3d5673;">&nbsp;&middot;&nbsp;</span>
<a href="{{.}}/instances" style="color:#9fb3ca;text-decoration:none;font-weight:600;">Instances</a>
<span style="color:#3d5673;">&nbsp;&middot;&nbsp;</span>
<a href="{{.}}/billing" style="color:#9fb3ca;text-decoration:none;font-weight:600;">Billing</a>
<span style="color:#3d5673;">&nbsp;&middot;&nbsp;</span>
<a href="{{.}}/settings" style="color:#9fb3ca;text-decoration:none;font-weight:600;">Settings</a>
</p>
{{- end}}
<p style="margin:0 0 4px;color:#71879f;font-size:12px;line-height:1.6;">Vantage &middot; infrastructure control plane</p>
<p style="margin:0;color:#4f6680;font-size:11px;line-height:1.6;">&copy; {{year}} Vantage. This is an automated message about your service.</p>
</td>
</tr>
</table>
</td>
</tr>
+47 -12
View File
@@ -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
+16 -4
View File
@@ -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}}
+17 -5
View File
@@ -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}}
+10 -5
View File
@@ -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}}
+10 -5
View File
@@ -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}}
+15 -3
View File
@@ -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}}
+16 -4
View File
@@ -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}}
+15 -1
View File
@@ -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}}
+14 -2
View File
@@ -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}}
+15 -3
View File
@@ -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}}
+16 -4
View File
@@ -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}}
+14 -10
View File
@@ -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}}
+15 -9
View File
@@ -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}}
{{define "why"}}this address is on a notification channel attached to a vulnerability alert rule on {{.InstanceName}}.{{end}}