package notification import ( "bytes" "embed" "fmt" htmltemplate "html/template" "io/fs" "path" "strings" texttemplate "text/template" ) //go:embed templates var templatesFS embed.FS // TemplateName names one of the email templates. There must be a // matching `.html` and `.txt` under templates/. type TemplateName string const ( TmplVerification TemplateName = "verification" TmplPasswordReset TemplateName = "reset" TmplInvitation TemplateName = "invitation" TmplConfirmation TemplateName = "confirmation" TmplReminder TemplateName = "reminder" TmplCollaboratorInvite TemplateName = "collaborator_invite" TmplRSVPEditLink TemplateName = "rsvp_edit_link" ) // Templates renders branded transactional emails for both HTML and // plaintext bodies. Loaded once at construction; thread-safe afterwards. // // Each page-level HTML template gets its own *html/template.Template // holding a copy of the `base.html` partial. This avoids html/template's // per-template contextual-escape pass interfering between pages that all // define a sibling named "body". type Templates struct { html map[string]*htmltemplate.Template // page-name (no ext) -> root text *texttemplate.Template } func NewTemplates() (*Templates, error) { root, err := fs.Sub(templatesFS, "templates") if err != nil { return nil, fmt.Errorf("templates fs: %w", err) } baseHTML, err := fs.ReadFile(root, "base.html") if err != nil { return nil, fmt.Errorf("read base.html: %w", err) } out := &Templates{ html: make(map[string]*htmltemplate.Template), text: texttemplate.New("__root__"), } walk := func(p string, d fs.DirEntry, _ error) error { if d == nil || d.IsDir() { return nil } base := path.Base(p) if base == "base.html" { return nil // partial — folded into each page template below } b, err := fs.ReadFile(root, p) if err != nil { return err } switch { case strings.HasSuffix(p, ".html"): name := strings.TrimSuffix(base, ".html") t := htmltemplate.New(name) if _, err := t.Parse(string(baseHTML)); err != nil { return fmt.Errorf("parse _base for %s: %w", p, err) } if _, err := t.Parse(string(b)); err != nil { return fmt.Errorf("parse %s: %w", p, err) } out.html[name] = t case strings.HasSuffix(p, ".txt"): if _, err := out.text.New(base).Parse(string(b)); err != nil { return fmt.Errorf("parse %s: %w", p, err) } } return nil } if err := fs.WalkDir(root, ".", walk); err != nil { return nil, err } return out, nil } // Render returns (htmlBody, textBody) for the named template using data. func (t *Templates) Render(name TemplateName, data map[string]any) (htmlBody, textBody string, err error) { if data == nil { data = map[string]any{} } if _, ok := data["Subject"]; !ok { data["Subject"] = "GuestGuard" } htmlTpl, ok := t.html[string(name)] if !ok { return "", "", fmt.Errorf("unknown html template %q", name) } var hBuf, tBuf bytes.Buffer // Each page-root template's entry point is "base" (defined by base.html). if err := htmlTpl.ExecuteTemplate(&hBuf, "base", data); err != nil { return "", "", fmt.Errorf("render html %s: %w", name, err) } if err := t.text.ExecuteTemplate(&tBuf, string(name)+".txt", data); err != nil { return "", "", fmt.Errorf("render text %s: %w", name, err) } return hBuf.String(), tBuf.String(), nil }