// 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