persona-community-2/pkg/email/renderer.go
jordan cb3d4d5786
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-23 10:53:55 +00:00

239 lines
8.2 KiB
Go

// Package email provides HTML email template rendering for transactional auth emails.
// Templates are embedded at build time and compiled at startup. CSS inlining runs at
// render time so class-based styles are compatible with email clients.
package email
import (
"bytes"
"embed"
"fmt"
"html/template"
"io/fs"
"sort"
"strings"
texttemplate "text/template"
"github.com/aymerick/douceur/inliner"
"gopkg.in/yaml.v3"
)
// BrandConfig holds per-project branding values injected into every transactional email.
// Set once at startup; never set by individual send calls.
type BrandConfig struct {
AppName string // service name shown in header and copy
AppURL string // base URL linked in the header logo/name
SupportEmail string // displayed in footer for user questions
LogoURL string // optional logo image URL; hides logo area when empty
PrimaryColor string // hex color e.g. "#6366f1"; default "#6366f1"
TextColor string // hex color e.g. "#111827"; default "#111827"
}
func (b BrandConfig) withDefaults() BrandConfig {
if b.PrimaryColor == "" {
b.PrimaryColor = "#6366f1"
}
if b.TextColor == "" {
b.TextColor = "#111827"
}
return b
}
// TemplateMeta holds metadata from a template's meta.yaml file.
// Subject and Preheader are Go text/template strings rendered with EmailContext as data.
type TemplateMeta struct {
Purpose string `yaml:"purpose"`
Category string `yaml:"category"`
Subject string `yaml:"subject"` // e.g. "Your {{.AppName}} sign in code"
Preheader string `yaml:"preheader"` // e.g. "{{.Code}} — expires in {{.ExpiresIn}} minutes."
}
// RenderedEmail holds the fully rendered output ready for delivery.
type RenderedEmail struct {
Subject string
HTML string // CSS-inlined HTML; un-inlined fallback if CSSInlineErr is set
PlainText string // auto-derived from HTML
Preheader string
CSSInlineErr error // non-nil if CSS inlining failed; email still sends but styles may render inconsistently
}
// EmailContext is the unified template data context for all email types.
// Fields that a given template does not use are simply zero-valued and ignored.
type EmailContext struct {
// Brand fields — always overwritten by the renderer from BrandConfig.
// Callers must not set these; they are populated by Render().
AppName string
AppURL template.URL
SupportEmail string
LogoURL template.URL
PrimaryColor template.CSS // typed for safe use in html/template CSS context
TextColor template.CSS // typed for safe use in html/template CSS context
// Rendered from meta.yaml before template execution — set by the renderer.
Preheader string
// OTP fields (login_otp, verify_email).
Code string // digits-only OTP code
ExpiresIn int // minutes until expiry
Purpose string // "sign in" | "verify your email"
// Link fields (magic_link, password_reset).
ActionURL template.URL // server-generated action URL
ButtonText string // e.g. "Sign In →" | "Reset Password →"
// Welcome fields.
Name string // user display name; may be empty
}
type compiledTemplate struct {
meta TemplateMeta
html *template.Template
}
// Renderer loads and renders transactional email templates from an embedded FS.
// Templates are compiled at construction time. CSS inlining runs at render time.
type Renderer struct {
templates map[string]*compiledTemplate
brand BrandConfig
}
// NewRendererFromFS creates a Renderer from a caller-provided embed.FS.
// prefix is the directory inside the FS where templates live (e.g. "templates").
// The FS must contain _layout.html, _header.html, _footer.html, _button.html,
// _code_box.html at the top level of prefix, and one subdirectory per email type
// each containing meta.yaml and body.html.
func NewRendererFromFS(emailFS embed.FS, prefix string, brand BrandConfig) (*Renderer, error) {
brand = brand.withDefaults()
// Read shared layout partials.
partials := []string{"_layout.html", "_header.html", "_footer.html", "_button.html", "_code_box.html"}
var sharedBuilder strings.Builder
for _, name := range partials {
data, err := fs.ReadFile(emailFS, prefix+"/"+name)
if err != nil {
return nil, fmt.Errorf("read email partial %s: %w", name, err)
}
sharedBuilder.Write(data)
sharedBuilder.WriteByte('\n')
}
sharedContent := sharedBuilder.String()
r := &Renderer{
templates: make(map[string]*compiledTemplate),
brand: brand,
}
// Walk the prefix directory for per-purpose subdirectories.
entries, err := fs.ReadDir(emailFS, prefix)
if err != nil {
return nil, fmt.Errorf("read email template directory: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), "_") {
continue
}
purpose := entry.Name()
metaBytes, err := fs.ReadFile(emailFS, prefix+"/"+purpose+"/meta.yaml")
if err != nil {
return nil, fmt.Errorf("read meta.yaml for %s: %w", purpose, err)
}
var meta TemplateMeta
if err := yaml.Unmarshal(metaBytes, &meta); err != nil {
return nil, fmt.Errorf("parse meta.yaml for %s: %w", purpose, err)
}
bodyBytes, err := fs.ReadFile(emailFS, prefix+"/"+purpose+"/body.html")
if err != nil {
return nil, fmt.Errorf("read body.html for %s: %w", purpose, err)
}
// Compose: shared partials define "layout", "header", "footer", "button",
// "code_box" — body.html defines "body". All parsed into one template set.
fullContent := sharedContent + string(bodyBytes)
tpl, err := template.New("email").Parse(fullContent)
if err != nil {
return nil, fmt.Errorf("parse template for %s: %w", purpose, err)
}
r.templates[purpose] = &compiledTemplate{meta: meta, html: tpl}
}
return r, nil
}
// Render executes the named email template with the provided context.
// Brand fields in ctx are always overwritten from the renderer's BrandConfig.
func (r *Renderer) Render(purpose string, ctx EmailContext) (*RenderedEmail, error) {
tmpl, ok := r.templates[purpose]
if !ok {
return nil, fmt.Errorf("unknown email purpose %q", purpose)
}
// Inject brand (callers must not set these).
ctx.AppName = r.brand.AppName
ctx.AppURL = template.URL(r.brand.AppURL)
ctx.SupportEmail = r.brand.SupportEmail
ctx.LogoURL = template.URL(r.brand.LogoURL)
ctx.PrimaryColor = template.CSS(r.brand.PrimaryColor)
ctx.TextColor = template.CSS(r.brand.TextColor)
// Render preheader from its text/template string before HTML execution.
ctx.Preheader = renderTextTemplate(tmpl.meta.Preheader, ctx)
// Execute the "layout" HTML template — it calls "header", "body", "footer".
var htmlBuf bytes.Buffer
if err := tmpl.html.ExecuteTemplate(&htmlBuf, "layout", ctx); err != nil {
return nil, fmt.Errorf("execute email template %s: %w", purpose, err)
}
// Inline CSS: douceur converts <style> class rules to inline style="" attributes
// for email client compatibility (Gmail, Outlook, Apple Mail).
htmlStr := htmlBuf.String()
inlinedHTML, inlineErr := inliner.Inline(htmlStr)
if inlineErr != nil {
// Non-fatal: fall back to un-inlined HTML. CSSInlineErr is exposed so callers
// can log the failure — emails still send but styles may not render in all clients.
inlinedHTML = htmlStr
}
return &RenderedEmail{
Subject: renderTextTemplate(tmpl.meta.Subject, ctx),
HTML: inlinedHTML,
PlainText: htmlToPlainText(inlinedHTML),
Preheader: ctx.Preheader,
CSSInlineErr: inlineErr,
}, nil
}
// Purposes returns all registered template purposes, sorted alphabetically.
func (r *Renderer) Purposes() []string {
purposes := make([]string, 0, len(r.templates))
for p := range r.templates {
purposes = append(purposes, p)
}
sort.Strings(purposes)
return purposes
}
// SafeURL wraps a server-generated URL string as html/template.URL.
// Use this when the URL is produced by server code (not user input) to
// avoid html/template's URL sanitization in href attributes.
func SafeURL(s string) template.URL {
return template.URL(s)
}
// renderTextTemplate renders a text/template string with ctx as data.
// Returns the raw string on any error — never panics.
func renderTextTemplate(tmplStr string, ctx EmailContext) string {
t, err := texttemplate.New("").Parse(tmplStr)
if err != nil {
return tmplStr
}
var buf bytes.Buffer
if err := t.Execute(&buf, ctx); err != nil {
return tmplStr
}
return buf.String()
}