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"
|
"{{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.
|
||||||
|
|||||||
@ -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"
|
"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, ¬ify.SendRequest{
|
resp, err := s.client.SendEmail(ctx, ¬ify.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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 (
|
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
|
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user