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 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 := 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 "" }, }