Files
vantage-shared/mail/render.go
T
2026-09-10 13:21:04 +00:00

216 lines
6.5 KiB
Go

package mail
import (
"embed"
"fmt"
htmltmpl "html/template"
"io/fs"
"math"
"strings"
texttmpl "text/template"
"time"
)
//go:embed templates
var files embed.FS
// A message is rendered from three files: the shared layout, which owns every
// colour and every piece of chrome, and the message's own html/txt pair, which
// owns only its subject and its content. There is one template set per message
// rather than one big set, because each message defines "subject" and "body"
// under the same names and they would otherwise collide.
type set struct {
html *htmltmpl.Template
text *texttmpl.Template
}
var sets = map[string]set{}
func init() {
entries, err := fs.Glob(files, "templates/*.html.tmpl")
if err != nil {
panic("mail: glob templates: " + err.Error())
}
for _, e := range entries {
name := strings.TrimSuffix(strings.TrimPrefix(e, "templates/"), ".html.tmpl")
if name == "layout" {
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())
}
sets[name] = set{html: h, text: t}
}
}
// 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, hq ...string) (message, error) {
s, ok := sets[name]
if !ok {
return message{}, fmt.Errorf("no such template")
}
var hqURL string
if len(hq) > 0 {
hqURL = hq[0]
}
var tone strings.Builder
if err := s.text.ExecuteTemplate(&tone, "tone", data); err != nil {
return message{}, err
}
per := perRender(hqURL, 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 := tt.ExecuteTemplate(&subject, "subject", data); err != nil {
return message{}, err
}
if err := tt.Execute(&text, data); err != nil {
return message{}, err
}
if err := ht.Execute(&html, data); err != nil {
return message{}, err
}
return message{
Subject: strings.TrimSpace(subject.String()),
Text: normaliseText(text.String()),
HTML: html.String(),
}, nil
}
// normaliseText gives the plain part CRLF line endings and collapses the blank
// runs that fall out of templating whitespace.
func normaliseText(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
for strings.Contains(s, "\n\n\n") {
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
}
s = strings.TrimSpace(s) + "\n"
return strings.ReplaceAll(s, "\n", "\r\n")
}
// funcs are shared by both template flavours. They exist so that a message
// template never formats a date or builds a structure itself - two templates
// formatting the same date two ways is exactly the drift this package removes.
var funcs = map[string]any{
// dict builds a map for the layout's helper templates, which take more
// than one argument. Go templates have no literal for this.
"dict": func(kv ...any) (map[string]any, error) {
if len(kv)%2 != 0 {
return nil, fmt.Errorf("dict: odd argument count")
}
m := make(map[string]any, len(kv)/2)
for i := 0; i < len(kv); i += 2 {
k, ok := kv[i].(string)
if !ok {
return nil, fmt.Errorf("dict: key %d is not a string", i)
}
m[k] = kv[i+1]
}
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") },
// shortDate drops the year, for subject lines where it is obvious.
"shortDate": func(t time.Time) string { return t.Format("2 January") },
"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 ""
},
}