package mail import ( "embed" "fmt" htmltmpl "html/template" "io/fs" "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)). 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)). 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} } } // render produces the subject and both bodies for one message. // // The subject comes from the text set, not the HTML one: html/template would // escape an ampersand in an instance name into "&" and mail clients show // subjects verbatim. func render(name string, data any) (message, error) { s, ok := sets[name] if !ok { return message{}, fmt.Errorf("no such template") } var subject, text, html strings.Builder if err := s.text.ExecuteTemplate(&subject, "subject", data); err != nil { return message{}, err } if err := s.text.Execute(&text, data); err != nil { return message{}, err } if err := s.html.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 }, // 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, }