feat: add HTML email template system to skeleton service component
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Every project generated from the skeleton now ships with styled,
production-ready transactional emails out of the box.

New pkg/email package:
- Renderer: loads templates from caller-provided embed.FS, inlines CSS via
  douceur at startup, derives plain text via goquery for multipart delivery
- DevHandler: live browser preview at GET /dev/emails and /dev/emails/{purpose}
  (development only, never mounted in production)
- CSSInlineErr field on RenderedEmail so callers can log degraded renders

New service component templates:
- internal/email/embed.go.tmpl — embeds template FS (uses all: prefix for _*.html)
- internal/email/renderer_test.go.tmpl — 9 tests covering all purposes + brand injection
- internal/email/templates/ — 5 HTML email types (login_otp, email_verify,
  magic_link, password_reset, welcome) + 5 shared partials (_layout, _header,
  _footer, _button, _code_box)

Updated service component templates:
- config.go.tmpl — brand fields: AppName, AppURL, SupportEmail, LogoURL, BrandColor
- main.go.tmpl — wires renderer at startup, logs template count
- routes.go.tmpl — mounts /dev/emails in development; EmailRenderer in Dependencies
- notify.go.tmpl — renders HTML before sending; warns on CSS inlining failure
- go.mod.tmpl — adds douceur, goquery, gorilla/css, andybalholm/cascadia

Deleted: internal/adapter/email/helpers.go.tmpl (replaced by meta.yaml + renderer)

Fix: template directory named email_verify (matching domain.PurposeEmailVerify)
rather than verify_email — the mismatch caused all verification emails to fail
with "unknown email purpose" at send time while tests passed (tests called
Render directly with the wrong name).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-21 22:44:59 -07:00
parent 4f01015132
commit 27e6cfd42b
26 changed files with 947 additions and 50 deletions

View File

@ -19,6 +19,7 @@ import (
"{{GO_MODULE}}/pkg/mediagen" "{{GO_MODULE}}/pkg/mediagen"
mediagenAdapters "{{GO_MODULE}}/pkg/mediagen/adapters" mediagenAdapters "{{GO_MODULE}}/pkg/mediagen/adapters"
"{{GO_MODULE}}/pkg/generation" "{{GO_MODULE}}/pkg/generation"
emailpkg "{{GO_MODULE}}/pkg/email"
"{{GO_MODULE}}/pkg/notify" "{{GO_MODULE}}/pkg/notify"
"{{GO_MODULE}}/pkg/queue" "{{GO_MODULE}}/pkg/queue"
"{{GO_MODULE}}/pkg/realtime" "{{GO_MODULE}}/pkg/realtime"
@ -26,6 +27,7 @@ import (
"{{GO_MODULE}}/pkg/textgen" "{{GO_MODULE}}/pkg/textgen"
textgenAdapters "{{GO_MODULE}}/pkg/textgen/adapters" textgenAdapters "{{GO_MODULE}}/pkg/textgen/adapters"
emailadapter "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/email" emailadapter "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/email"
componentemail "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/email"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/memory" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/memory"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/postgres" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/postgres"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api"
@ -146,6 +148,20 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Load email renderer (HTML templates embedded at build time).
emailRenderer, err := emailpkg.NewRendererFromFS(componentemail.TemplateFS, "templates", emailpkg.BrandConfig{
AppName: cfg.AppName,
AppURL: cfg.AppURL,
SupportEmail: cfg.SupportEmail,
LogoURL: cfg.LogoURL,
PrimaryColor: cfg.BrandColor,
})
if err != nil {
logger.Error("failed to load email templates", "error", err)
os.Exit(1)
}
logger.Info("email renderer loaded", "templates", len(emailRenderer.Purposes()))
// Create email sender — notify service in production (NOTIFY_URL set), log-only for dev. // Create email sender — notify service in production (NOTIFY_URL set), log-only for dev.
var emailSender port.EmailSender var emailSender port.EmailSender
if cfg.NotifyURL != "" { if cfg.NotifyURL != "" {
@ -158,7 +174,7 @@ func main() {
logger.Error("failed to create notify client", "error", err) logger.Error("failed to create notify client", "error", err)
os.Exit(1) os.Exit(1)
} }
emailSender = emailadapter.NewNotifySender(notifyClient, cfg.NotifyHost, cfg.NotifyFrom, logger) emailSender = emailadapter.NewNotifySender(notifyClient, emailRenderer, cfg.NotifyHost, cfg.NotifyFrom, logger)
logger.Info("email sender initialized (notify)", "url", cfg.NotifyURL, "host", cfg.NotifyHost) logger.Info("email sender initialized (notify)", "url", cfg.NotifyURL, "host", cfg.NotifyHost)
} else { } else {
emailSender = emailadapter.NewLogSender(logger) emailSender = emailadapter.NewLogSender(logger)
@ -189,6 +205,7 @@ func main() {
SSEHub: sseHub, SSEHub: sseHub,
Store: mediaStore, Store: mediaStore,
MediaRepo: mediaRepo, MediaRepo: mediaRepo,
EmailRenderer: emailRenderer,
}) })
// Start background cleanup of expired sessions and auth codes. // Start background cleanup of expired sessions and auth codes.

View File

@ -1,33 +0,0 @@
package email
import "fmt"
func subjectForPurpose(purpose string) string {
switch purpose {
case "login_otp":
return "Your login code"
case "magic_link":
return "Your sign-in link"
case "password_reset":
return "Reset your password"
case "email_verify":
return "Verify your email"
default:
return "Your authentication code"
}
}
func bodyForPurpose(purpose, code string) string {
switch purpose {
case "login_otp":
return fmt.Sprintf("Your login code is: %s\n\nThis code expires in 10 minutes.", code)
case "magic_link":
return fmt.Sprintf("Click this link to sign in:\n\n%s\n\nThis link expires in 15 minutes.", code)
case "password_reset":
return fmt.Sprintf("Use this code to reset your password:\n\n%s\n\nThis code expires in 1 hour.", code)
case "email_verify":
return fmt.Sprintf("Your verification code is: %s\n\nThis code expires in 24 hours.", code)
default:
return fmt.Sprintf("Your code is: %s", code)
}
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
emailpkg "{{GO_MODULE}}/pkg/email"
"{{GO_MODULE}}/pkg/logging" "{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/notify" "{{GO_MODULE}}/pkg/notify"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port"
@ -12,18 +13,22 @@ import (
// Compile-time interface check. // Compile-time interface check.
var _ port.EmailSender = (*NotifySender)(nil) var _ port.EmailSender = (*NotifySender)(nil)
// NotifySender sends emails via the orchard9 notify service. // NotifySender sends transactional emails via the orchard9 notify service.
// It renders HTML using the email.Renderer before sending so every email
// has a styled layout with inline CSS.
type NotifySender struct { type NotifySender struct {
client *notify.Client client *notify.Client
renderer *emailpkg.Renderer
host string host string
from string from string
logger *logging.Logger logger *logging.Logger
} }
// NewNotifySender creates a new notify-backed email sender. // NewNotifySender creates a notify-backed email sender with HTML rendering.
func NewNotifySender(client *notify.Client, host, from string, logger *logging.Logger) *NotifySender { func NewNotifySender(client *notify.Client, renderer *emailpkg.Renderer, host, from string, logger *logging.Logger) *NotifySender {
return &NotifySender{ return &NotifySender{
client: client, client: client,
renderer: renderer,
host: host, host: host,
from: from, from: from,
logger: logger.WithComponent("EmailSender"), logger: logger.WithComponent("EmailSender"),
@ -31,12 +36,26 @@ func NewNotifySender(client *notify.Client, host, from string, logger *logging.L
} }
func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose string) error { func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose string) error {
// Map (purpose, code) to the correct template context.
emailCtx := purposeToContext(purpose, code)
rendered, err := s.renderer.Render(purpose, emailCtx)
if err != nil {
s.logger.Error("failed to render email template", "purpose", purpose, "error", err)
return fmt.Errorf("render email %s: %w", purpose, err)
}
if rendered.CSSInlineErr != nil {
s.logger.Warn("CSS inlining failed for email, styles may be degraded in some clients",
"purpose", purpose, "error", rendered.CSSInlineErr)
}
resp, err := s.client.SendEmail(ctx, &notify.SendRequest{ resp, err := s.client.SendEmail(ctx, &notify.SendRequest{
To: toEmail, To: toEmail,
From: s.from, From: s.from,
Content: notify.Content{ Content: notify.Content{
Subject: subjectForPurpose(purpose), Subject: rendered.Subject,
Text: bodyForPurpose(purpose, code), HTML: rendered.HTML,
Text: rendered.PlainText,
}, },
Meta: notify.Meta{ Meta: notify.Meta{
Host: s.host, Host: s.host,
@ -44,6 +63,7 @@ func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose
Tags: []string{"auth", purpose}, Tags: []string{"auth", purpose},
}, },
Options: notify.Options{ Options: notify.Options{
// Stable idempotency key: same user + same code = same key, safe to retry.
IdempotencyKey: fmt.Sprintf("auth:%s:%s:%s", toEmail, purpose, code), IdempotencyKey: fmt.Sprintf("auth:%s:%s:%s", toEmail, purpose, code),
}, },
}) })
@ -55,3 +75,38 @@ func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose
s.logger.Info("email queued via notify", "to", toEmail, "purpose", purpose, "message_id", resp.MessageID) s.logger.Info("email queued via notify", "to", toEmail, "purpose", purpose, "message_id", resp.MessageID)
return nil return nil
} }
// purposeToContext maps (purpose, code) to an EmailContext for template rendering.
// code may be an OTP digit string or a URL depending on the purpose.
func purposeToContext(purpose, code string) emailpkg.EmailContext {
switch purpose {
case "login_otp":
return emailpkg.EmailContext{
Code: code,
ExpiresIn: 10,
Purpose: "sign in",
}
case "magic_link":
return emailpkg.EmailContext{
ActionURL: emailpkg.SafeURL(code),
ButtonText: "Sign In \u2192",
ExpiresIn: 15,
}
case "password_reset":
return emailpkg.EmailContext{
ActionURL: emailpkg.SafeURL(code),
ButtonText: "Reset Password \u2192",
ExpiresIn: 60,
}
case "email_verify":
return emailpkg.EmailContext{
Code: code,
ExpiresIn: 30,
Purpose: "verify your email",
}
default:
return emailpkg.EmailContext{
Code: code,
}
}
}

View File

@ -4,6 +4,7 @@ package api
import ( import (
"time" "time"
emailpkg "{{GO_MODULE}}/pkg/email"
"{{GO_MODULE}}/pkg/app" "{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/auth" "{{GO_MODULE}}/pkg/auth"
"{{GO_MODULE}}/pkg/middleware" "{{GO_MODULE}}/pkg/middleware"
@ -44,6 +45,13 @@ func RegisterRoutes(application *app.App, deps *Dependencies) {
Issuer: "{{PROJECT_NAME}}", Issuer: "{{PROJECT_NAME}}",
}) })
// Dev email preview (development only — not mounted in production).
if cfg.AppConfig.Environment == "development" && deps.EmailRenderer != nil {
devHandler := emailpkg.NewDevHandler(deps.EmailRenderer)
application.Router().Get("/dev/emails", devHandler.List)
application.Router().Get("/dev/emails/{purpose}", devHandler.Preview)
}
// Register API routes under /api/{service-name} to match ingress path routing. // Register API routes under /api/{service-name} to match ingress path routing.
// The ingress routes /api/{{COMPONENT_NAME}}/* to this service. // The ingress routes /api/{{COMPONENT_NAME}}/* to this service.
application.Route("/api/{{COMPONENT_NAME}}", func(r app.Router) { application.Route("/api/{{COMPONENT_NAME}}", func(r app.Router) {
@ -156,4 +164,5 @@ type Dependencies struct {
SSEHub *realtime.SSEHub SSEHub *realtime.SSEHub
Store storage.Store Store storage.Store
MediaRepo port.MediaRepository MediaRepo port.MediaRepository
EmailRenderer *emailpkg.Renderer
} }

View File

@ -29,6 +29,13 @@ type Config struct {
NotifyAPIKey string NotifyAPIKey string
NotifyHost string NotifyHost string
NotifyFrom string NotifyFrom string
// Email branding — injected into every transactional email.
AppName string // APP_NAME, default: "{{COMPONENT_NAME}}"
AppURL string // APP_URL, default: ""
SupportEmail string // SUPPORT_EMAIL, default: NOTIFY_FROM value
LogoURL string // LOGO_URL, default: "" (hides logo area)
BrandColor string // BRAND_COLOR, default: "#6366f1"
} }
// Load reads configuration from environment variables. // Load reads configuration from environment variables.
@ -38,7 +45,9 @@ func Load() *Config {
regEnabled = strings.EqualFold(v, "true") regEnabled = strings.EqualFold(v, "true")
} }
return &Config{ notifyFrom := getEnvDefault("NOTIFY_FROM", "noreply@{{PROJECT_NAME}}.com")
cfg := &Config{
AppConfig: config.ReadAppConfig(), AppConfig: config.ReadAppConfig(),
Server: config.ReadServerConfig(), Server: config.ReadServerConfig(),
Database: config.ReadDatabaseConfig(), Database: config.ReadDatabaseConfig(),
@ -52,8 +61,16 @@ func Load() *Config {
NotifyURL: os.Getenv("NOTIFY_URL"), NotifyURL: os.Getenv("NOTIFY_URL"),
NotifyAPIKey: os.Getenv("NOTIFY_API_KEY"), NotifyAPIKey: os.Getenv("NOTIFY_API_KEY"),
NotifyHost: os.Getenv("NOTIFY_HOST"), NotifyHost: os.Getenv("NOTIFY_HOST"),
NotifyFrom: getEnvDefault("NOTIFY_FROM", "noreply@{{PROJECT_NAME}}.com"), NotifyFrom: notifyFrom,
AppName: getEnvDefault("APP_NAME", "{{COMPONENT_NAME}}"),
AppURL: os.Getenv("APP_URL"),
SupportEmail: getEnvDefault("SUPPORT_EMAIL", notifyFrom),
LogoURL: os.Getenv("LOGO_URL"),
BrandColor: getEnvDefault("BRAND_COLOR", "#6366f1"),
} }
return cfg
} }
func getEnvDefault(key, defaultVal string) string { func getEnvDefault(key, defaultVal string) string {

View File

@ -0,0 +1,8 @@
// Package email provides embedded email templates for {{COMPONENT_NAME}} transactional emails.
// The TemplateFS is passed to pkg/email.NewRendererFromFS at startup.
package email
import "embed"
//go:embed all:templates
var TemplateFS embed.FS

View File

@ -0,0 +1,202 @@
package email_test
import (
"strings"
"testing"
emailpkg "{{GO_MODULE}}/pkg/email"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/email"
)
var testBrand = emailpkg.BrandConfig{
AppName: "Test App",
AppURL: "https://example.com",
SupportEmail: "support@example.com",
PrimaryColor: "#6366f1",
}
func newTestRenderer(t *testing.T) *emailpkg.Renderer {
t.Helper()
r, err := emailpkg.NewRendererFromFS(email.TemplateFS, "templates", testBrand)
if err != nil {
t.Fatalf("NewRendererFromFS: %v", err)
}
return r
}
func TestRendererLoads(t *testing.T) {
r := newTestRenderer(t)
purposes := r.Purposes()
want := []string{"email_verify", "login_otp", "magic_link", "password_reset", "welcome"}
if len(purposes) != len(want) {
t.Fatalf("expected %d purposes, got %d: %v", len(want), len(purposes), purposes)
}
for i, p := range want {
if purposes[i] != p {
t.Errorf("purpose[%d]: want %q, got %q", i, p, purposes[i])
}
}
}
func TestRenderLoginOTP(t *testing.T) {
r := newTestRenderer(t)
out, err := r.Render("login_otp", emailpkg.EmailContext{
Code: "482916",
ExpiresIn: 10,
Purpose: "sign in",
})
if err != nil {
t.Fatalf("Render login_otp: %v", err)
}
if out.Subject == "" {
t.Error("Subject is empty")
}
if !strings.Contains(out.Subject, "Test App") {
t.Errorf("Subject %q does not contain app name", out.Subject)
}
if !strings.Contains(out.HTML, "482916") {
t.Error("HTML does not contain OTP code")
}
if !strings.Contains(out.HTML, "code-box") {
t.Error("HTML does not contain code-box element")
}
if out.PlainText == "" {
t.Error("PlainText is empty")
}
if !strings.Contains(out.PlainText, "482916") {
t.Error("PlainText does not contain OTP code")
}
if out.Preheader == "" {
t.Error("Preheader is empty")
}
}
func TestRenderMagicLink(t *testing.T) {
r := newTestRenderer(t)
out, err := r.Render("magic_link", emailpkg.EmailContext{
ActionURL: "https://example.com/auth/verify?token=abc123",
ButtonText: "Sign In \u2192",
ExpiresIn: 15,
})
if err != nil {
t.Fatalf("Render magic_link: %v", err)
}
if !strings.Contains(out.HTML, "Sign In") {
t.Error("HTML does not contain button text")
}
if !strings.Contains(out.HTML, "auth/verify") {
t.Error("HTML does not contain action URL")
}
if out.PlainText == "" {
t.Error("PlainText is empty")
}
}
func TestRenderPasswordReset(t *testing.T) {
r := newTestRenderer(t)
out, err := r.Render("password_reset", emailpkg.EmailContext{
ActionURL: "https://example.com/auth/reset?token=xyz789",
ButtonText: "Reset Password \u2192",
ExpiresIn: 60,
})
if err != nil {
t.Fatalf("Render password_reset: %v", err)
}
if !strings.Contains(out.HTML, "Reset Password") {
t.Error("HTML does not contain button text")
}
if !strings.Contains(out.Subject, "Reset") {
t.Errorf("Subject %q does not mention reset", out.Subject)
}
}
func TestRenderVerifyEmail(t *testing.T) {
r := newTestRenderer(t)
out, err := r.Render("email_verify", emailpkg.EmailContext{
Code: "738201",
ExpiresIn: 30,
Purpose: "verify your email",
})
if err != nil {
t.Fatalf("Render email_verify: %v", err)
}
if !strings.Contains(out.HTML, "738201") {
t.Error("HTML does not contain verification code")
}
if !strings.Contains(out.HTML, "code-box") {
t.Error("HTML does not contain code-box element")
}
}
func TestRenderWelcome(t *testing.T) {
r := newTestRenderer(t)
out, err := r.Render("welcome", emailpkg.EmailContext{
ActionURL: "https://example.com/dashboard",
ButtonText: "Get Started \u2192",
Name: "Jordan",
})
if err != nil {
t.Fatalf("Render welcome: %v", err)
}
if !strings.Contains(out.HTML, "Jordan") {
t.Error("HTML does not contain user name")
}
if !strings.Contains(out.HTML, "Welcome") {
t.Error("HTML does not contain welcome heading")
}
}
func TestBrandColorInjection(t *testing.T) {
r := newTestRenderer(t)
out, err := r.Render("login_otp", emailpkg.EmailContext{
Code: "123456",
ExpiresIn: 10,
})
if err != nil {
t.Fatalf("Render: %v", err)
}
// Brand primary color should appear in the inlined styles.
if !strings.Contains(out.HTML, "#6366f1") {
t.Error("HTML does not contain brand color #6366f1")
}
}
func TestUnknownPurposeReturnsError(t *testing.T) {
r := newTestRenderer(t)
_, err := r.Render("nonexistent_type", emailpkg.EmailContext{})
if err == nil {
t.Error("expected error for unknown purpose, got nil")
}
}
func TestAllTemplatesHaveSubjectAndPreheader(t *testing.T) {
r := newTestRenderer(t)
contexts := map[string]emailpkg.EmailContext{
"login_otp": {Code: "111111", ExpiresIn: 10},
"magic_link": {ActionURL: "https://example.com/auth", ButtonText: "Sign In", ExpiresIn: 15},
"password_reset": {ActionURL: "https://example.com/reset", ButtonText: "Reset", ExpiresIn: 60},
"email_verify": {Code: "222222", ExpiresIn: 30},
"welcome": {ActionURL: "https://example.com", ButtonText: "Get Started", Name: "Alex"},
}
for _, purpose := range r.Purposes() {
ctx := contexts[purpose]
out, err := r.Render(purpose, ctx)
if err != nil {
t.Errorf("%s: render error: %v", purpose, err)
continue
}
if out.Subject == "" {
t.Errorf("%s: Subject is empty", purpose)
}
if out.Preheader == "" {
t.Errorf("%s: Preheader is empty", purpose)
}
if out.HTML == "" {
t.Errorf("%s: HTML is empty", purpose)
}
if out.PlainText == "" {
t.Errorf("%s: PlainText is empty", purpose)
}
}
}

View File

@ -0,0 +1,4 @@
{{define "button"}}<div class="btn-wrapper">
<a href="{{.ActionURL}}" class="btn btn-primary">{{.ButtonText}}</a>
</div>
{{end}}

View File

@ -0,0 +1,4 @@
{{define "code_box"}}<div class="code-box">
<span class="code-box-value">{{.Code}}</span>
</div>
{{end}}

View File

@ -0,0 +1,7 @@
{{define "footer"}}<div class="email-footer">
{{- if .SupportEmail}}
<p>Questions? <a href="mailto:{{.SupportEmail}}">{{.SupportEmail}}</a></p>
{{- end}}
<p>You received this email because you have an account with {{.AppName}}.</p>
</div>
{{end}}

View File

@ -0,0 +1,8 @@
{{define "header"}}<div class="email-header">
{{- if .LogoURL}}
<a href="{{.AppURL}}"><img src="{{.LogoURL}}" alt="{{.AppName}}" class="app-logo"></a>
{{- else}}
<a href="{{.AppURL}}" class="app-name">{{.AppName}}</a>
{{- end}}
</div>
{{end}}

View File

@ -0,0 +1,83 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{{.AppName}}</title>
<style>
/* Reset */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background-color: #f9fafb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-size: 16px; line-height: 1.6; color: #374151; }
/* Page wrapper */
.email-wrapper { width: 100%; background-color: #f9fafb; padding: 32px 16px; }
/* Card */
.email-card { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; }
/* Preheader — visually hidden but visible in inbox preview text */
.preheader { display: none; max-height: 0; overflow: hidden; mso-hide: all; font-size: 1px; line-height: 1px; color: #f9fafb; opacity: 0; }
/* Header */
.email-header { padding: 24px 32px; border-bottom: 1px solid #e5e7eb; background-color: #ffffff; }
.app-name { font-size: 20px; font-weight: 700; color: {{.PrimaryColor}}; text-decoration: none; letter-spacing: -0.01em; display: inline-block; }
.app-logo { max-height: 36px; max-width: 160px; display: block; }
/* Body */
.email-body { padding: 40px 32px; }
/* Typography */
h1 { font-size: 24px; font-weight: 700; line-height: 1.3; color: #111827; margin-bottom: 12px; }
h2 { font-size: 20px; font-weight: 600; line-height: 1.4; color: #111827; margin-bottom: 12px; }
p { font-size: 16px; line-height: 1.6; color: #374151; margin-bottom: 16px; }
p:last-child { margin-bottom: 0; }
a { color: {{.PrimaryColor}}; text-decoration: underline; }
.text-muted { color: #6b7280; font-size: 14px; }
/* Divider */
.divider { border: none; border-top: 1px solid #e5e7eb; margin: 28px 0; }
/* OTP code box */
.code-box { background-color: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 8px; padding: 24px; text-align: center; margin: 28px 0; }
.code-box-value { font-family: 'Courier New', Courier, monospace; font-size: 48px; font-weight: 700; letter-spacing: 0.15em; color: #111827; display: block; line-height: 1; }
/* CTA button */
.btn-wrapper { margin: 28px 0; text-align: left; }
.btn { display: inline-block; padding: 14px 28px; border-radius: 8px; font-size: 16px; font-weight: 600; text-decoration: none; text-align: center; line-height: 1.4; }
.btn-primary { background-color: {{.PrimaryColor}}; color: #ffffff; }
/* Footer */
.email-footer { padding: 24px 32px; border-top: 1px solid #e5e7eb; background-color: #f9fafb; }
.email-footer p { font-size: 14px; color: #6b7280; margin-bottom: 4px; line-height: 1.5; }
.email-footer p:last-child { margin-bottom: 0; }
.email-footer a { color: #6b7280; }
/* Responsive */
@media only screen and (max-width: 600px) {
.email-wrapper { padding: 0; }
.email-card { border-radius: 0; border-left: none; border-right: none; }
.email-body { padding: 28px 20px; }
.email-header { padding: 20px; }
.email-footer { padding: 20px; }
.code-box-value { font-size: 36px; }
}
</style>
</head>
<body>
<div class="email-wrapper">
<!-- Preheader: visible in inbox preview, invisible in email body -->
<span class="preheader">{{.Preheader}}&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;</span>
<div class="email-card">
{{template "header" .}}
<div class="email-body">
{{template "body" .}}
</div>
{{template "footer" .}}
</div>
</div>
</body>
</html>
{{end}}

View File

@ -0,0 +1,8 @@
{{define "body"}}
<p>Hi there,</p>
<p>Enter this code to verify your email address for <strong>{{.AppName}}</strong>:</p>
{{template "code_box" .}}
<p class="text-muted">This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email.</p>
{{end}}

View File

@ -0,0 +1,4 @@
purpose: email_verify
category: transactional
subject: "Verify your {{.AppName}} email address"
preheader: "{{.Code}} is your email verification code — expires in {{.ExpiresIn}} minutes."

View File

@ -0,0 +1,8 @@
{{define "body"}}
<p>Hi there,</p>
<p>Here is your sign in code for <strong>{{.AppName}}</strong>:</p>
{{template "code_box" .}}
<p class="text-muted">This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email — your account is secure.</p>
{{end}}

View File

@ -0,0 +1,4 @@
purpose: login_otp
category: transactional
subject: "Your {{.AppName}} sign in code"
preheader: "{{.Code}} is your sign in code — expires in {{.ExpiresIn}} minutes."

View File

@ -0,0 +1,13 @@
{{define "body"}}
<h2>Sign in to {{.AppName}}</h2>
<p>Click the button below to sign in. This link expires in {{.ExpiresIn}} minutes.</p>
{{template "button" .}}
<p class="text-muted">Or copy this link into your browser:<br>
<a href="{{.ActionURL}}">{{.ActionURL}}</a></p>
<hr class="divider">
<p class="text-muted">If you didn't request this sign-in link, you can safely ignore this email — your account is secure.</p>
{{end}}

View File

@ -0,0 +1,4 @@
purpose: magic_link
category: transactional
subject: "Sign in to {{.AppName}}"
preheader: "Click to sign in to your {{.AppName}} account — link expires in {{.ExpiresIn}} minutes."

View File

@ -0,0 +1,13 @@
{{define "body"}}
<h2>Reset your password</h2>
<p>Click the button below to choose a new password. This link expires in {{.ExpiresIn}} minutes.</p>
{{template "button" .}}
<p class="text-muted">Or copy this link into your browser:<br>
<a href="{{.ActionURL}}">{{.ActionURL}}</a></p>
<hr class="divider">
<p class="text-muted">If you didn't request a password reset, you can safely ignore this email — your password won't change.</p>
{{end}}

View File

@ -0,0 +1,4 @@
purpose: password_reset
category: transactional
subject: "Reset your {{.AppName}} password"
preheader: "You requested a password reset for {{.AppName}} — link expires in {{.ExpiresIn}} minutes."

View File

@ -0,0 +1,10 @@
{{define "body"}}
<h2>Welcome to {{.AppName}}{{if .Name}}, {{.Name}}{{end}}!</h2>
<p>Your account is ready. Start exploring everything {{.AppName}} has to offer.</p>
{{template "button" .}}
{{- if .SupportEmail}}
<p class="text-muted">Have questions? Reach us at <a href="mailto:{{.SupportEmail}}">{{.SupportEmail}}</a>.</p>
{{- end}}
{{end}}

View File

@ -0,0 +1,4 @@
purpose: welcome
category: transactional
subject: "Welcome to {{.AppName}}"
preheader: "Your {{.AppName}} account is ready. Get started today."

View File

@ -0,0 +1,122 @@
package email
import (
"fmt"
"html/template"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
// DevHandler serves live HTML previews of email templates.
// It is ONLY safe to mount in development environments — never in production.
type DevHandler struct {
renderer *Renderer
}
// NewDevHandler creates a new DevHandler backed by the given renderer.
func NewDevHandler(r *Renderer) *DevHandler {
return &DevHandler{renderer: r}
}
// List serves an HTML page listing all available email template previews.
// Mount at GET /dev/emails.
func (h *DevHandler) List(w http.ResponseWriter, r *http.Request) {
purposes := h.renderer.Purposes()
var sb strings.Builder
sb.WriteString(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Email Templates — Dev Preview</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 640px; margin: 48px auto; padding: 0 24px; color: #111827; }
h1 { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
p.hint { color: #6b7280; font-size: 14px; margin-bottom: 32px; }
ul { list-style: none; padding: 0; }
li { margin-bottom: 12px; }
a { display: block; padding: 14px 18px; border: 1px solid #e5e7eb; border-radius: 8px; text-decoration: none; color: #374151; font-size: 15px; transition: background 0.1s; }
a:hover { background: #f9fafb; border-color: #d1d5db; }
.badge { float: right; font-size: 12px; color: #6366f1; background: #eef2ff; padding: 2px 8px; border-radius: 4px; }
</style>
</head>
<body>
<h1>Email Templates</h1>
<p class="hint">Development preview — not visible in production.</p>
<ul>`)
for _, p := range purposes {
sb.WriteString(fmt.Sprintf(
` <li><a href="/dev/emails/%s"><span class="badge">preview</span>%s</a></li>`,
template.HTMLEscapeString(p),
template.HTMLEscapeString(p),
))
sb.WriteByte('\n')
}
sb.WriteString(` </ul>
</body>
</html>`)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, sb.String())
}
// Preview renders an email template with placeholder data and serves the HTML.
// Mount at GET /dev/emails/{purpose}.
func (h *DevHandler) Preview(w http.ResponseWriter, r *http.Request) {
purpose := chi.URLParam(r, "purpose")
ctx := placeholderContext(purpose)
rendered, err := h.renderer.Render(purpose, ctx)
if err != nil {
http.Error(w, fmt.Sprintf("render %q: %v", purpose, err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, rendered.HTML)
}
// placeholderContext returns realistic placeholder data for each email type.
// These values are used by the dev preview only — never sent to real users.
func placeholderContext(purpose string) EmailContext {
switch purpose {
case "login_otp":
return EmailContext{
Code: "482916",
ExpiresIn: 10,
Purpose: "sign in",
}
case "magic_link":
return EmailContext{
ActionURL: "https://example.com/auth/verify?token=preview-token",
ButtonText: "Sign In \u2192",
ExpiresIn: 15,
}
case "password_reset":
return EmailContext{
ActionURL: "https://example.com/auth/reset?token=preview-token",
ButtonText: "Reset Password \u2192",
ExpiresIn: 60,
}
case "verify_email":
return EmailContext{
Code: "738201",
ExpiresIn: 30,
Purpose: "verify your email",
}
case "welcome":
return EmailContext{
ActionURL: "https://example.com/dashboard",
ButtonText: "Get Started \u2192",
Name: "Jordan",
}
default:
return EmailContext{}
}
}

View File

@ -0,0 +1,80 @@
package email
import (
"strings"
"github.com/PuerkitoBio/goquery"
)
// htmlToPlainText derives a plain-text representation from rendered HTML.
// The text version is for email clients that cannot render HTML. The HTML
// version is always preferred when the client supports it.
func htmlToPlainText(htmlStr string) string {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlStr))
if err != nil {
return htmlStr
}
// Remove elements that should not appear in plain text.
doc.Find("style, script, head, .preheader").Remove()
// Replace links with "text (URL)" format.
doc.Find("a[href]").Each(func(_ int, s *goquery.Selection) {
href, _ := s.Attr("href")
text := strings.TrimSpace(s.Text())
if href != "" && href != text && !strings.HasPrefix(href, "mailto:") {
s.ReplaceWithHtml(text + "\n" + href)
}
})
// Walk the DOM and collect text with structural newlines.
var lines []string
var walk func(*goquery.Selection)
walk = func(sel *goquery.Selection) {
sel.Contents().Each(func(_ int, n *goquery.Selection) {
tag := goquery.NodeName(n)
switch tag {
case "#text":
t := strings.TrimSpace(n.Text())
if t != "" {
lines = append(lines, t)
}
case "br":
lines = append(lines, "")
case "p", "div", "section", "article", "main", "h1", "h2", "h3", "h4", "h5", "h6", "li":
walk(n)
lines = append(lines, "")
case "tr":
walk(n)
lines = append(lines, "")
case "hr":
lines = append(lines, "---")
default:
walk(n)
}
})
}
body := doc.Find("body")
if body.Length() == 0 {
body = doc.Selection
}
walk(body)
// Collapse consecutive blank lines to a single blank line.
result := make([]string, 0, len(lines))
prevBlank := false
for _, line := range lines {
if line == "" {
if !prevBlank {
result = append(result, "")
}
prevBlank = true
} else {
result = append(result, line)
prevBlank = false
}
}
return strings.TrimSpace(strings.Join(result, "\n"))
}

View File

@ -0,0 +1,238 @@
// 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()
}

View File

@ -4,6 +4,8 @@ go 1.25
require ( require (
cloud.google.com/go/storage v1.43.0 cloud.google.com/go/storage v1.43.0
github.com/PuerkitoBio/goquery v1.9.3
github.com/aymerick/douceur v0.2.0
github.com/bdpiprava/scalar-go v0.13.0 github.com/bdpiprava/scalar-go v0.13.0
github.com/go-chi/chi/v5 v5.2.0 github.com/go-chi/chi/v5 v5.2.0
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
@ -18,11 +20,14 @@ require (
google.golang.org/api v0.192.0 google.golang.org/api v0.192.0
golang.org/x/crypto v0.21.0 golang.org/x/crypto v0.21.0
google.golang.org/genai v1.46.0 google.golang.org/genai v1.46.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
@ -44,5 +49,4 @@ require (
golang.org/x/sys v0.18.0 // indirect golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )