From 27e6cfd42b2ce66746cc2f252f9bc7372e1ef85b Mon Sep 17 00:00:00 2001 From: jordan Date: Sat, 21 Feb 2026 22:44:59 -0700 Subject: [PATCH] feat: add HTML email template system to skeleton service component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../service/cmd/server/main.go.tmpl | 19 +- .../internal/adapter/email/helpers.go.tmpl | 33 --- .../internal/adapter/email/notify.go.tmpl | 81 +++++- .../service/internal/api/routes.go.tmpl | 9 + .../service/internal/config/config.go.tmpl | 21 +- .../service/internal/email/embed.go.tmpl | 8 + .../internal/email/renderer_test.go.tmpl | 202 +++++++++++++++ .../internal/email/templates/_button.html | 4 + .../internal/email/templates/_code_box.html | 4 + .../internal/email/templates/_footer.html | 7 + .../internal/email/templates/_header.html | 8 + .../internal/email/templates/_layout.html | 83 ++++++ .../email/templates/email_verify/body.html | 8 + .../email/templates/email_verify/meta.yaml | 4 + .../email/templates/login_otp/body.html | 8 + .../email/templates/login_otp/meta.yaml | 4 + .../email/templates/magic_link/body.html | 13 + .../email/templates/magic_link/meta.yaml | 4 + .../email/templates/password_reset/body.html | 13 + .../email/templates/password_reset/meta.yaml | 4 + .../email/templates/welcome/body.html | 10 + .../email/templates/welcome/meta.yaml | 4 + .../skeleton/pkg/email/dev_handler.go.tmpl | 122 +++++++++ .../skeleton/pkg/email/plaintext.go.tmpl | 80 ++++++ .../skeleton/pkg/email/renderer.go.tmpl | 238 ++++++++++++++++++ .../templates/skeleton/pkg/go.mod.tmpl | 6 +- 26 files changed, 947 insertions(+), 50 deletions(-) delete mode 100644 internal/adapter/templates/templates/components/service/internal/adapter/email/helpers.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/email/embed.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/email/renderer_test.go.tmpl create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/_button.html create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/_code_box.html create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/_footer.html create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/_header.html create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/_layout.html create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/email_verify/body.html create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/email_verify/meta.yaml create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/body.html create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/meta.yaml create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/body.html create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/meta.yaml create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/body.html create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/meta.yaml create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/welcome/body.html create mode 100644 internal/adapter/templates/templates/components/service/internal/email/templates/welcome/meta.yaml create mode 100644 internal/adapter/templates/templates/skeleton/pkg/email/dev_handler.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/email/plaintext.go.tmpl create mode 100644 internal/adapter/templates/templates/skeleton/pkg/email/renderer.go.tmpl diff --git a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl index 141800a..eb1bfa0 100644 --- a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl +++ b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl @@ -19,6 +19,7 @@ import ( "{{GO_MODULE}}/pkg/mediagen" mediagenAdapters "{{GO_MODULE}}/pkg/mediagen/adapters" "{{GO_MODULE}}/pkg/generation" + emailpkg "{{GO_MODULE}}/pkg/email" "{{GO_MODULE}}/pkg/notify" "{{GO_MODULE}}/pkg/queue" "{{GO_MODULE}}/pkg/realtime" @@ -26,6 +27,7 @@ import ( "{{GO_MODULE}}/pkg/textgen" textgenAdapters "{{GO_MODULE}}/pkg/textgen/adapters" 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/postgres" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api" @@ -146,6 +148,20 @@ func main() { 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. var emailSender port.EmailSender if cfg.NotifyURL != "" { @@ -158,7 +174,7 @@ func main() { logger.Error("failed to create notify client", "error", err) 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) } else { emailSender = emailadapter.NewLogSender(logger) @@ -189,6 +205,7 @@ func main() { SSEHub: sseHub, Store: mediaStore, MediaRepo: mediaRepo, + EmailRenderer: emailRenderer, }) // Start background cleanup of expired sessions and auth codes. diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/email/helpers.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/email/helpers.go.tmpl deleted file mode 100644 index c0972b8..0000000 --- a/internal/adapter/templates/templates/components/service/internal/adapter/email/helpers.go.tmpl +++ /dev/null @@ -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) - } -} diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/email/notify.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/email/notify.go.tmpl index d3d0f79..85727d9 100644 --- a/internal/adapter/templates/templates/components/service/internal/adapter/email/notify.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/adapter/email/notify.go.tmpl @@ -4,6 +4,7 @@ import ( "context" "fmt" + emailpkg "{{GO_MODULE}}/pkg/email" "{{GO_MODULE}}/pkg/logging" "{{GO_MODULE}}/pkg/notify" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" @@ -12,31 +13,49 @@ import ( // Compile-time interface check. 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 { - client *notify.Client - host string - from string - logger *logging.Logger + client *notify.Client + renderer *emailpkg.Renderer + host string + from string + logger *logging.Logger } -// NewNotifySender creates a new notify-backed email sender. -func NewNotifySender(client *notify.Client, host, from string, logger *logging.Logger) *NotifySender { +// NewNotifySender creates a notify-backed email sender with HTML rendering. +func NewNotifySender(client *notify.Client, renderer *emailpkg.Renderer, host, from string, logger *logging.Logger) *NotifySender { return &NotifySender{ - client: client, - host: host, - from: from, - logger: logger.WithComponent("EmailSender"), + client: client, + renderer: renderer, + host: host, + from: from, + logger: logger.WithComponent("EmailSender"), } } 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, ¬ify.SendRequest{ To: toEmail, From: s.from, Content: notify.Content{ - Subject: subjectForPurpose(purpose), - Text: bodyForPurpose(purpose, code), + Subject: rendered.Subject, + HTML: rendered.HTML, + Text: rendered.PlainText, }, Meta: notify.Meta{ Host: s.host, @@ -44,6 +63,7 @@ func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose Tags: []string{"auth", purpose}, }, 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), }, }) @@ -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) 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, + } + } +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl index 531cdd7..b179276 100644 --- a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl @@ -4,6 +4,7 @@ package api import ( "time" + emailpkg "{{GO_MODULE}}/pkg/email" "{{GO_MODULE}}/pkg/app" "{{GO_MODULE}}/pkg/auth" "{{GO_MODULE}}/pkg/middleware" @@ -44,6 +45,13 @@ func RegisterRoutes(application *app.App, deps *Dependencies) { 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. // The ingress routes /api/{{COMPONENT_NAME}}/* to this service. application.Route("/api/{{COMPONENT_NAME}}", func(r app.Router) { @@ -156,4 +164,5 @@ type Dependencies struct { SSEHub *realtime.SSEHub Store storage.Store MediaRepo port.MediaRepository + EmailRenderer *emailpkg.Renderer } diff --git a/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl b/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl index fc94e4e..f4f7bc1 100644 --- a/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl @@ -29,6 +29,13 @@ type Config struct { NotifyAPIKey string NotifyHost 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. @@ -38,7 +45,9 @@ func Load() *Config { regEnabled = strings.EqualFold(v, "true") } - return &Config{ + notifyFrom := getEnvDefault("NOTIFY_FROM", "noreply@{{PROJECT_NAME}}.com") + + cfg := &Config{ AppConfig: config.ReadAppConfig(), Server: config.ReadServerConfig(), Database: config.ReadDatabaseConfig(), @@ -52,8 +61,16 @@ func Load() *Config { NotifyURL: os.Getenv("NOTIFY_URL"), NotifyAPIKey: os.Getenv("NOTIFY_API_KEY"), 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 { diff --git a/internal/adapter/templates/templates/components/service/internal/email/embed.go.tmpl b/internal/adapter/templates/templates/components/service/internal/email/embed.go.tmpl new file mode 100644 index 0000000..c705107 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/embed.go.tmpl @@ -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 diff --git a/internal/adapter/templates/templates/components/service/internal/email/renderer_test.go.tmpl b/internal/adapter/templates/templates/components/service/internal/email/renderer_test.go.tmpl new file mode 100644 index 0000000..8a13a39 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/renderer_test.go.tmpl @@ -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) + } + } +} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/_button.html b/internal/adapter/templates/templates/components/service/internal/email/templates/_button.html new file mode 100644 index 0000000..bbfd435 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/_button.html @@ -0,0 +1,4 @@ +{{define "button"}} +{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/_code_box.html b/internal/adapter/templates/templates/components/service/internal/email/templates/_code_box.html new file mode 100644 index 0000000..f0ecadc --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/_code_box.html @@ -0,0 +1,4 @@ +{{define "code_box"}}
+ {{.Code}} +
+{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/_footer.html b/internal/adapter/templates/templates/components/service/internal/email/templates/_footer.html new file mode 100644 index 0000000..0c98740 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/_footer.html @@ -0,0 +1,7 @@ +{{define "footer"}} +{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/_header.html b/internal/adapter/templates/templates/components/service/internal/email/templates/_header.html new file mode 100644 index 0000000..4e59b9e --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/_header.html @@ -0,0 +1,8 @@ +{{define "header"}} +{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/_layout.html b/internal/adapter/templates/templates/components/service/internal/email/templates/_layout.html new file mode 100644 index 0000000..1db0a29 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/_layout.html @@ -0,0 +1,83 @@ +{{define "layout"}} + + + + + + + + {{.AppName}} + + + + + + +{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/email_verify/body.html b/internal/adapter/templates/templates/components/service/internal/email/templates/email_verify/body.html new file mode 100644 index 0000000..47e68e0 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/email_verify/body.html @@ -0,0 +1,8 @@ +{{define "body"}} +

Hi there,

+

Enter this code to verify your email address for {{.AppName}}:

+ +{{template "code_box" .}} + +

This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email.

+{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/email_verify/meta.yaml b/internal/adapter/templates/templates/components/service/internal/email/templates/email_verify/meta.yaml new file mode 100644 index 0000000..8978ac6 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/email_verify/meta.yaml @@ -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." diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/body.html b/internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/body.html new file mode 100644 index 0000000..5d4315d --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/body.html @@ -0,0 +1,8 @@ +{{define "body"}} +

Hi there,

+

Here is your sign in code for {{.AppName}}:

+ +{{template "code_box" .}} + +

This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email — your account is secure.

+{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/meta.yaml b/internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/meta.yaml new file mode 100644 index 0000000..1eac2d6 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/meta.yaml @@ -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." diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/body.html b/internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/body.html new file mode 100644 index 0000000..9e7044a --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/body.html @@ -0,0 +1,13 @@ +{{define "body"}} +

Sign in to {{.AppName}}

+

Click the button below to sign in. This link expires in {{.ExpiresIn}} minutes.

+ +{{template "button" .}} + +

Or copy this link into your browser:
+{{.ActionURL}}

+ +
+ +

If you didn't request this sign-in link, you can safely ignore this email — your account is secure.

+{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/meta.yaml b/internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/meta.yaml new file mode 100644 index 0000000..08ebb3d --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/meta.yaml @@ -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." diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/body.html b/internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/body.html new file mode 100644 index 0000000..e2bdcb6 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/body.html @@ -0,0 +1,13 @@ +{{define "body"}} +

Reset your password

+

Click the button below to choose a new password. This link expires in {{.ExpiresIn}} minutes.

+ +{{template "button" .}} + +

Or copy this link into your browser:
+{{.ActionURL}}

+ +
+ +

If you didn't request a password reset, you can safely ignore this email — your password won't change.

+{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/meta.yaml b/internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/meta.yaml new file mode 100644 index 0000000..67e8b7f --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/meta.yaml @@ -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." diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/welcome/body.html b/internal/adapter/templates/templates/components/service/internal/email/templates/welcome/body.html new file mode 100644 index 0000000..2eac89b --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/welcome/body.html @@ -0,0 +1,10 @@ +{{define "body"}} +

Welcome to {{.AppName}}{{if .Name}}, {{.Name}}{{end}}!

+

Your account is ready. Start exploring everything {{.AppName}} has to offer.

+ +{{template "button" .}} + +{{- if .SupportEmail}} +

Have questions? Reach us at {{.SupportEmail}}.

+{{- end}} +{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/welcome/meta.yaml b/internal/adapter/templates/templates/components/service/internal/email/templates/welcome/meta.yaml new file mode 100644 index 0000000..4f9bc92 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/welcome/meta.yaml @@ -0,0 +1,4 @@ +purpose: welcome +category: transactional +subject: "Welcome to {{.AppName}}" +preheader: "Your {{.AppName}} account is ready. Get started today." diff --git a/internal/adapter/templates/templates/skeleton/pkg/email/dev_handler.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/email/dev_handler.go.tmpl new file mode 100644 index 0000000..1443bdc --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/email/dev_handler.go.tmpl @@ -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(` + + + + Email Templates — Dev Preview + + + +

Email Templates

+

Development preview — not visible in production.

+ + +`) + + 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{} + } +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/email/plaintext.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/email/plaintext.go.tmpl new file mode 100644 index 0000000..950c645 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/email/plaintext.go.tmpl @@ -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")) +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/email/renderer.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/email/renderer.go.tmpl new file mode 100644 index 0000000..e284fec --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/email/renderer.go.tmpl @@ -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