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
+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")
}
}