This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user