diff --git a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl index 141800a..eb1bfa0 100644 --- a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl +++ b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl @@ -19,6 +19,7 @@ import ( "{{GO_MODULE}}/pkg/mediagen" mediagenAdapters "{{GO_MODULE}}/pkg/mediagen/adapters" "{{GO_MODULE}}/pkg/generation" + emailpkg "{{GO_MODULE}}/pkg/email" "{{GO_MODULE}}/pkg/notify" "{{GO_MODULE}}/pkg/queue" "{{GO_MODULE}}/pkg/realtime" @@ -26,6 +27,7 @@ import ( "{{GO_MODULE}}/pkg/textgen" textgenAdapters "{{GO_MODULE}}/pkg/textgen/adapters" emailadapter "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/email" + componentemail "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/email" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/memory" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/postgres" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api" @@ -146,6 +148,20 @@ func main() { os.Exit(1) } + // Load email renderer (HTML templates embedded at build time). + emailRenderer, err := emailpkg.NewRendererFromFS(componentemail.TemplateFS, "templates", emailpkg.BrandConfig{ + AppName: cfg.AppName, + AppURL: cfg.AppURL, + SupportEmail: cfg.SupportEmail, + LogoURL: cfg.LogoURL, + PrimaryColor: cfg.BrandColor, + }) + if err != nil { + logger.Error("failed to load email templates", "error", err) + os.Exit(1) + } + logger.Info("email renderer loaded", "templates", len(emailRenderer.Purposes())) + // Create email sender — notify service in production (NOTIFY_URL set), log-only for dev. var emailSender port.EmailSender if cfg.NotifyURL != "" { @@ -158,7 +174,7 @@ func main() { logger.Error("failed to create notify client", "error", err) os.Exit(1) } - emailSender = emailadapter.NewNotifySender(notifyClient, cfg.NotifyHost, cfg.NotifyFrom, logger) + emailSender = emailadapter.NewNotifySender(notifyClient, emailRenderer, cfg.NotifyHost, cfg.NotifyFrom, logger) logger.Info("email sender initialized (notify)", "url", cfg.NotifyURL, "host", cfg.NotifyHost) } else { emailSender = emailadapter.NewLogSender(logger) @@ -189,6 +205,7 @@ func main() { SSEHub: sseHub, Store: mediaStore, MediaRepo: mediaRepo, + EmailRenderer: emailRenderer, }) // Start background cleanup of expired sessions and auth codes. diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/email/helpers.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/email/helpers.go.tmpl deleted file mode 100644 index c0972b8..0000000 --- a/internal/adapter/templates/templates/components/service/internal/adapter/email/helpers.go.tmpl +++ /dev/null @@ -1,33 +0,0 @@ -package email - -import "fmt" - -func subjectForPurpose(purpose string) string { - switch purpose { - case "login_otp": - return "Your login code" - case "magic_link": - return "Your sign-in link" - case "password_reset": - return "Reset your password" - case "email_verify": - return "Verify your email" - default: - return "Your authentication code" - } -} - -func bodyForPurpose(purpose, code string) string { - switch purpose { - case "login_otp": - return fmt.Sprintf("Your login code is: %s\n\nThis code expires in 10 minutes.", code) - case "magic_link": - return fmt.Sprintf("Click this link to sign in:\n\n%s\n\nThis link expires in 15 minutes.", code) - case "password_reset": - return fmt.Sprintf("Use this code to reset your password:\n\n%s\n\nThis code expires in 1 hour.", code) - case "email_verify": - return fmt.Sprintf("Your verification code is: %s\n\nThis code expires in 24 hours.", code) - default: - return fmt.Sprintf("Your code is: %s", code) - } -} diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/email/notify.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/email/notify.go.tmpl index d3d0f79..85727d9 100644 --- a/internal/adapter/templates/templates/components/service/internal/adapter/email/notify.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/adapter/email/notify.go.tmpl @@ -4,6 +4,7 @@ import ( "context" "fmt" + emailpkg "{{GO_MODULE}}/pkg/email" "{{GO_MODULE}}/pkg/logging" "{{GO_MODULE}}/pkg/notify" "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port" @@ -12,31 +13,49 @@ import ( // Compile-time interface check. var _ port.EmailSender = (*NotifySender)(nil) -// NotifySender sends emails via the orchard9 notify service. +// NotifySender sends transactional emails via the orchard9 notify service. +// It renders HTML using the email.Renderer before sending so every email +// has a styled layout with inline CSS. type NotifySender struct { - client *notify.Client - host string - from string - logger *logging.Logger + client *notify.Client + renderer *emailpkg.Renderer + host string + from string + logger *logging.Logger } -// NewNotifySender creates a new notify-backed email sender. -func NewNotifySender(client *notify.Client, host, from string, logger *logging.Logger) *NotifySender { +// NewNotifySender creates a notify-backed email sender with HTML rendering. +func NewNotifySender(client *notify.Client, renderer *emailpkg.Renderer, host, from string, logger *logging.Logger) *NotifySender { return &NotifySender{ - client: client, - host: host, - from: from, - logger: logger.WithComponent("EmailSender"), + client: client, + renderer: renderer, + host: host, + from: from, + logger: logger.WithComponent("EmailSender"), } } func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose string) error { + // Map (purpose, code) to the correct template context. + emailCtx := purposeToContext(purpose, code) + + rendered, err := s.renderer.Render(purpose, emailCtx) + if err != nil { + s.logger.Error("failed to render email template", "purpose", purpose, "error", err) + return fmt.Errorf("render email %s: %w", purpose, err) + } + if rendered.CSSInlineErr != nil { + s.logger.Warn("CSS inlining failed for email, styles may be degraded in some clients", + "purpose", purpose, "error", rendered.CSSInlineErr) + } + resp, err := s.client.SendEmail(ctx, ¬ify.SendRequest{ To: toEmail, From: s.from, Content: notify.Content{ - Subject: subjectForPurpose(purpose), - Text: bodyForPurpose(purpose, code), + Subject: rendered.Subject, + HTML: rendered.HTML, + Text: rendered.PlainText, }, Meta: notify.Meta{ Host: s.host, @@ -44,6 +63,7 @@ func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose Tags: []string{"auth", purpose}, }, Options: notify.Options{ + // Stable idempotency key: same user + same code = same key, safe to retry. IdempotencyKey: fmt.Sprintf("auth:%s:%s:%s", toEmail, purpose, code), }, }) @@ -55,3 +75,38 @@ func (s *NotifySender) SendAuthCode(ctx context.Context, toEmail, code, purpose s.logger.Info("email queued via notify", "to", toEmail, "purpose", purpose, "message_id", resp.MessageID) return nil } + +// purposeToContext maps (purpose, code) to an EmailContext for template rendering. +// code may be an OTP digit string or a URL depending on the purpose. +func purposeToContext(purpose, code string) emailpkg.EmailContext { + switch purpose { + case "login_otp": + return emailpkg.EmailContext{ + Code: code, + ExpiresIn: 10, + Purpose: "sign in", + } + case "magic_link": + return emailpkg.EmailContext{ + ActionURL: emailpkg.SafeURL(code), + ButtonText: "Sign In \u2192", + ExpiresIn: 15, + } + case "password_reset": + return emailpkg.EmailContext{ + ActionURL: emailpkg.SafeURL(code), + ButtonText: "Reset Password \u2192", + ExpiresIn: 60, + } + case "email_verify": + return emailpkg.EmailContext{ + Code: code, + ExpiresIn: 30, + Purpose: "verify your email", + } + default: + return emailpkg.EmailContext{ + Code: code, + } + } +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl index 531cdd7..b179276 100644 --- a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl @@ -4,6 +4,7 @@ package api import ( "time" + emailpkg "{{GO_MODULE}}/pkg/email" "{{GO_MODULE}}/pkg/app" "{{GO_MODULE}}/pkg/auth" "{{GO_MODULE}}/pkg/middleware" @@ -44,6 +45,13 @@ func RegisterRoutes(application *app.App, deps *Dependencies) { Issuer: "{{PROJECT_NAME}}", }) + // Dev email preview (development only — not mounted in production). + if cfg.AppConfig.Environment == "development" && deps.EmailRenderer != nil { + devHandler := emailpkg.NewDevHandler(deps.EmailRenderer) + application.Router().Get("/dev/emails", devHandler.List) + application.Router().Get("/dev/emails/{purpose}", devHandler.Preview) + } + // Register API routes under /api/{service-name} to match ingress path routing. // The ingress routes /api/{{COMPONENT_NAME}}/* to this service. application.Route("/api/{{COMPONENT_NAME}}", func(r app.Router) { @@ -156,4 +164,5 @@ type Dependencies struct { SSEHub *realtime.SSEHub Store storage.Store MediaRepo port.MediaRepository + EmailRenderer *emailpkg.Renderer } diff --git a/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl b/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl index fc94e4e..f4f7bc1 100644 --- a/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/config/config.go.tmpl @@ -29,6 +29,13 @@ type Config struct { NotifyAPIKey string NotifyHost string NotifyFrom string + + // Email branding — injected into every transactional email. + AppName string // APP_NAME, default: "{{COMPONENT_NAME}}" + AppURL string // APP_URL, default: "" + SupportEmail string // SUPPORT_EMAIL, default: NOTIFY_FROM value + LogoURL string // LOGO_URL, default: "" (hides logo area) + BrandColor string // BRAND_COLOR, default: "#6366f1" } // Load reads configuration from environment variables. @@ -38,7 +45,9 @@ func Load() *Config { regEnabled = strings.EqualFold(v, "true") } - return &Config{ + notifyFrom := getEnvDefault("NOTIFY_FROM", "noreply@{{PROJECT_NAME}}.com") + + cfg := &Config{ AppConfig: config.ReadAppConfig(), Server: config.ReadServerConfig(), Database: config.ReadDatabaseConfig(), @@ -52,8 +61,16 @@ func Load() *Config { NotifyURL: os.Getenv("NOTIFY_URL"), NotifyAPIKey: os.Getenv("NOTIFY_API_KEY"), NotifyHost: os.Getenv("NOTIFY_HOST"), - NotifyFrom: getEnvDefault("NOTIFY_FROM", "noreply@{{PROJECT_NAME}}.com"), + NotifyFrom: notifyFrom, + + AppName: getEnvDefault("APP_NAME", "{{COMPONENT_NAME}}"), + AppURL: os.Getenv("APP_URL"), + SupportEmail: getEnvDefault("SUPPORT_EMAIL", notifyFrom), + LogoURL: os.Getenv("LOGO_URL"), + BrandColor: getEnvDefault("BRAND_COLOR", "#6366f1"), } + + return cfg } func getEnvDefault(key, defaultVal string) string { diff --git a/internal/adapter/templates/templates/components/service/internal/email/embed.go.tmpl b/internal/adapter/templates/templates/components/service/internal/email/embed.go.tmpl new file mode 100644 index 0000000..c705107 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/embed.go.tmpl @@ -0,0 +1,8 @@ +// Package email provides embedded email templates for {{COMPONENT_NAME}} transactional emails. +// The TemplateFS is passed to pkg/email.NewRendererFromFS at startup. +package email + +import "embed" + +//go:embed all:templates +var TemplateFS embed.FS diff --git a/internal/adapter/templates/templates/components/service/internal/email/renderer_test.go.tmpl b/internal/adapter/templates/templates/components/service/internal/email/renderer_test.go.tmpl new file mode 100644 index 0000000..8a13a39 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/renderer_test.go.tmpl @@ -0,0 +1,202 @@ +package email_test + +import ( + "strings" + "testing" + + emailpkg "{{GO_MODULE}}/pkg/email" + "{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/email" +) + +var testBrand = emailpkg.BrandConfig{ + AppName: "Test App", + AppURL: "https://example.com", + SupportEmail: "support@example.com", + PrimaryColor: "#6366f1", +} + +func newTestRenderer(t *testing.T) *emailpkg.Renderer { + t.Helper() + r, err := emailpkg.NewRendererFromFS(email.TemplateFS, "templates", testBrand) + if err != nil { + t.Fatalf("NewRendererFromFS: %v", err) + } + return r +} + +func TestRendererLoads(t *testing.T) { + r := newTestRenderer(t) + + purposes := r.Purposes() + want := []string{"email_verify", "login_otp", "magic_link", "password_reset", "welcome"} + if len(purposes) != len(want) { + t.Fatalf("expected %d purposes, got %d: %v", len(want), len(purposes), purposes) + } + for i, p := range want { + if purposes[i] != p { + t.Errorf("purpose[%d]: want %q, got %q", i, p, purposes[i]) + } + } +} + +func TestRenderLoginOTP(t *testing.T) { + r := newTestRenderer(t) + out, err := r.Render("login_otp", emailpkg.EmailContext{ + Code: "482916", + ExpiresIn: 10, + Purpose: "sign in", + }) + if err != nil { + t.Fatalf("Render login_otp: %v", err) + } + if out.Subject == "" { + t.Error("Subject is empty") + } + if !strings.Contains(out.Subject, "Test App") { + t.Errorf("Subject %q does not contain app name", out.Subject) + } + if !strings.Contains(out.HTML, "482916") { + t.Error("HTML does not contain OTP code") + } + if !strings.Contains(out.HTML, "code-box") { + t.Error("HTML does not contain code-box element") + } + if out.PlainText == "" { + t.Error("PlainText is empty") + } + if !strings.Contains(out.PlainText, "482916") { + t.Error("PlainText does not contain OTP code") + } + if out.Preheader == "" { + t.Error("Preheader is empty") + } +} + +func TestRenderMagicLink(t *testing.T) { + r := newTestRenderer(t) + out, err := r.Render("magic_link", emailpkg.EmailContext{ + ActionURL: "https://example.com/auth/verify?token=abc123", + ButtonText: "Sign In \u2192", + ExpiresIn: 15, + }) + if err != nil { + t.Fatalf("Render magic_link: %v", err) + } + if !strings.Contains(out.HTML, "Sign In") { + t.Error("HTML does not contain button text") + } + if !strings.Contains(out.HTML, "auth/verify") { + t.Error("HTML does not contain action URL") + } + if out.PlainText == "" { + t.Error("PlainText is empty") + } +} + +func TestRenderPasswordReset(t *testing.T) { + r := newTestRenderer(t) + out, err := r.Render("password_reset", emailpkg.EmailContext{ + ActionURL: "https://example.com/auth/reset?token=xyz789", + ButtonText: "Reset Password \u2192", + ExpiresIn: 60, + }) + if err != nil { + t.Fatalf("Render password_reset: %v", err) + } + if !strings.Contains(out.HTML, "Reset Password") { + t.Error("HTML does not contain button text") + } + if !strings.Contains(out.Subject, "Reset") { + t.Errorf("Subject %q does not mention reset", out.Subject) + } +} + +func TestRenderVerifyEmail(t *testing.T) { + r := newTestRenderer(t) + out, err := r.Render("email_verify", emailpkg.EmailContext{ + Code: "738201", + ExpiresIn: 30, + Purpose: "verify your email", + }) + if err != nil { + t.Fatalf("Render email_verify: %v", err) + } + if !strings.Contains(out.HTML, "738201") { + t.Error("HTML does not contain verification code") + } + if !strings.Contains(out.HTML, "code-box") { + t.Error("HTML does not contain code-box element") + } +} + +func TestRenderWelcome(t *testing.T) { + r := newTestRenderer(t) + out, err := r.Render("welcome", emailpkg.EmailContext{ + ActionURL: "https://example.com/dashboard", + ButtonText: "Get Started \u2192", + Name: "Jordan", + }) + if err != nil { + t.Fatalf("Render welcome: %v", err) + } + if !strings.Contains(out.HTML, "Jordan") { + t.Error("HTML does not contain user name") + } + if !strings.Contains(out.HTML, "Welcome") { + t.Error("HTML does not contain welcome heading") + } +} + +func TestBrandColorInjection(t *testing.T) { + r := newTestRenderer(t) + out, err := r.Render("login_otp", emailpkg.EmailContext{ + Code: "123456", + ExpiresIn: 10, + }) + if err != nil { + t.Fatalf("Render: %v", err) + } + // Brand primary color should appear in the inlined styles. + if !strings.Contains(out.HTML, "#6366f1") { + t.Error("HTML does not contain brand color #6366f1") + } +} + +func TestUnknownPurposeReturnsError(t *testing.T) { + r := newTestRenderer(t) + _, err := r.Render("nonexistent_type", emailpkg.EmailContext{}) + if err == nil { + t.Error("expected error for unknown purpose, got nil") + } +} + +func TestAllTemplatesHaveSubjectAndPreheader(t *testing.T) { + r := newTestRenderer(t) + contexts := map[string]emailpkg.EmailContext{ + "login_otp": {Code: "111111", ExpiresIn: 10}, + "magic_link": {ActionURL: "https://example.com/auth", ButtonText: "Sign In", ExpiresIn: 15}, + "password_reset": {ActionURL: "https://example.com/reset", ButtonText: "Reset", ExpiresIn: 60}, + "email_verify": {Code: "222222", ExpiresIn: 30}, + "welcome": {ActionURL: "https://example.com", ButtonText: "Get Started", Name: "Alex"}, + } + for _, purpose := range r.Purposes() { + ctx := contexts[purpose] + out, err := r.Render(purpose, ctx) + if err != nil { + t.Errorf("%s: render error: %v", purpose, err) + continue + } + if out.Subject == "" { + t.Errorf("%s: Subject is empty", purpose) + } + if out.Preheader == "" { + t.Errorf("%s: Preheader is empty", purpose) + } + if out.HTML == "" { + t.Errorf("%s: HTML is empty", purpose) + } + if out.PlainText == "" { + t.Errorf("%s: PlainText is empty", purpose) + } + } +} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/_button.html b/internal/adapter/templates/templates/components/service/internal/email/templates/_button.html new file mode 100644 index 0000000..bbfd435 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/_button.html @@ -0,0 +1,4 @@ +{{define "button"}}
Hi there,
+Enter this code to verify your email address for {{.AppName}}:
+ +{{template "code_box" .}} + +This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email.
+{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/email_verify/meta.yaml b/internal/adapter/templates/templates/components/service/internal/email/templates/email_verify/meta.yaml new file mode 100644 index 0000000..8978ac6 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/email_verify/meta.yaml @@ -0,0 +1,4 @@ +purpose: email_verify +category: transactional +subject: "Verify your {{.AppName}} email address" +preheader: "{{.Code}} is your email verification code — expires in {{.ExpiresIn}} minutes." diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/body.html b/internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/body.html new file mode 100644 index 0000000..5d4315d --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/body.html @@ -0,0 +1,8 @@ +{{define "body"}} +Hi there,
+Here is your sign in code for {{.AppName}}:
+ +{{template "code_box" .}} + +This code expires in {{.ExpiresIn}} minutes. If you didn't request this, you can safely ignore this email — your account is secure.
+{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/meta.yaml b/internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/meta.yaml new file mode 100644 index 0000000..1eac2d6 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/login_otp/meta.yaml @@ -0,0 +1,4 @@ +purpose: login_otp +category: transactional +subject: "Your {{.AppName}} sign in code" +preheader: "{{.Code}} is your sign in code — expires in {{.ExpiresIn}} minutes." diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/body.html b/internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/body.html new file mode 100644 index 0000000..9e7044a --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/body.html @@ -0,0 +1,13 @@ +{{define "body"}} +Click the button below to sign in. This link expires in {{.ExpiresIn}} minutes.
+ +{{template "button" .}} + +Or copy this link into your browser:
+{{.ActionURL}}
If you didn't request this sign-in link, you can safely ignore this email — your account is secure.
+{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/meta.yaml b/internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/meta.yaml new file mode 100644 index 0000000..08ebb3d --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/magic_link/meta.yaml @@ -0,0 +1,4 @@ +purpose: magic_link +category: transactional +subject: "Sign in to {{.AppName}}" +preheader: "Click to sign in to your {{.AppName}} account — link expires in {{.ExpiresIn}} minutes." diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/body.html b/internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/body.html new file mode 100644 index 0000000..e2bdcb6 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/body.html @@ -0,0 +1,13 @@ +{{define "body"}} +Click the button below to choose a new password. This link expires in {{.ExpiresIn}} minutes.
+ +{{template "button" .}} + +Or copy this link into your browser:
+{{.ActionURL}}
If you didn't request a password reset, you can safely ignore this email — your password won't change.
+{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/meta.yaml b/internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/meta.yaml new file mode 100644 index 0000000..67e8b7f --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/password_reset/meta.yaml @@ -0,0 +1,4 @@ +purpose: password_reset +category: transactional +subject: "Reset your {{.AppName}} password" +preheader: "You requested a password reset for {{.AppName}} — link expires in {{.ExpiresIn}} minutes." diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/welcome/body.html b/internal/adapter/templates/templates/components/service/internal/email/templates/welcome/body.html new file mode 100644 index 0000000..2eac89b --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/welcome/body.html @@ -0,0 +1,10 @@ +{{define "body"}} +Your account is ready. Start exploring everything {{.AppName}} has to offer.
+ +{{template "button" .}} + +{{- if .SupportEmail}} +Have questions? Reach us at {{.SupportEmail}}.
+{{- end}} +{{end}} diff --git a/internal/adapter/templates/templates/components/service/internal/email/templates/welcome/meta.yaml b/internal/adapter/templates/templates/components/service/internal/email/templates/welcome/meta.yaml new file mode 100644 index 0000000..4f9bc92 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/email/templates/welcome/meta.yaml @@ -0,0 +1,4 @@ +purpose: welcome +category: transactional +subject: "Welcome to {{.AppName}}" +preheader: "Your {{.AppName}} account is ready. Get started today." diff --git a/internal/adapter/templates/templates/skeleton/pkg/email/dev_handler.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/email/dev_handler.go.tmpl new file mode 100644 index 0000000..1443bdc --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/email/dev_handler.go.tmpl @@ -0,0 +1,122 @@ +package email + +import ( + "fmt" + "html/template" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" +) + +// DevHandler serves live HTML previews of email templates. +// It is ONLY safe to mount in development environments — never in production. +type DevHandler struct { + renderer *Renderer +} + +// NewDevHandler creates a new DevHandler backed by the given renderer. +func NewDevHandler(r *Renderer) *DevHandler { + return &DevHandler{renderer: r} +} + +// List serves an HTML page listing all available email template previews. +// Mount at GET /dev/emails. +func (h *DevHandler) List(w http.ResponseWriter, r *http.Request) { + purposes := h.renderer.Purposes() + + var sb strings.Builder + sb.WriteString(` + + + +Development preview — not visible in production.
+