239 lines
8.2 KiB
Go
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()
|
|
}
|