feat: add HTML email template system to skeleton service component
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
4f01015132
commit
27e6cfd42b
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
{{define "button"}}<div class="btn-wrapper">
|
||||
<a href="{{.ActionURL}}" class="btn btn-primary">{{.ButtonText}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -0,0 +1,4 @@
|
||||
{{define "code_box"}}<div class="code-box">
|
||||
<span class="code-box-value">{{.Code}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -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}}
|
||||
@ -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}}
|
||||
@ -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}}‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ </span>
|
||||
<div class="email-card">
|
||||
{{template "header" .}}
|
||||
<div class="email-body">
|
||||
{{template "body" .}}
|
||||
</div>
|
||||
{{template "footer" .}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@ -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}}
|
||||
@ -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."
|
||||
@ -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}}
|
||||
@ -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."
|
||||
@ -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}}
|
||||
@ -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."
|
||||
@ -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}}
|
||||
@ -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."
|
||||
@ -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}}
|
||||
@ -0,0 +1,4 @@
|
||||
purpose: welcome
|
||||
category: transactional
|
||||
subject: "Welcome to {{.AppName}}"
|
||||
preheader: "Your {{.AppName}} account is ready. Get started today."
|
||||
@ -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{}
|
||||
}
|
||||
}
|
||||
@ -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"))
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -4,6 +4,8 @@ go 1.25
|
||||
|
||||
require (
|
||||
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/go-chi/chi/v5 v5.2.0
|
||||
github.com/go-chi/cors v1.2.1
|
||||
@ -18,11 +20,14 @@ require (
|
||||
google.golang.org/api v0.192.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
google.golang.org/genai v1.46.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // 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/universal-translator v0.18.1 // 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/text v0.14.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user