From e07f4c4c64b6fd6096b9418a262d48fb28535f92 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 27 Feb 2026 11:36:59 +0100 Subject: [PATCH] feat: add HTML email templates with medieval aesthetic Add styled HTML email templates for market submission notifications and magic link authentication, matching the frontend's forest green and gold design language. - Template engine using go:embed with clone-per-content pattern - Multipart/alternative MIME (text/plain + text/html) in SMTPSender - Base layout: dark green header, gold accent, parchment tones - Market submission template with details table and admin CTA - Magic link template with CTA button and fallback URL - Wire email sending into MagicLinkHandler (replaces TODO) - Add FRONTEND_URL config for template links --- backend/internal/config/config.go | 6 +- backend/internal/domain/auth/magiclink.go | 58 ++++-- backend/internal/domain/market/service.go | 41 ++++- backend/internal/pkg/email/email.go | 45 ++++- backend/internal/pkg/email/template.go | 81 +++++++++ backend/internal/pkg/email/template_test.go | 165 ++++++++++++++++++ .../internal/pkg/email/templates/base.html | 60 +++++++ .../pkg/email/templates/magic_link.html | 36 ++++ .../email/templates/market_submission.html | 53 ++++++ backend/internal/server/routes.go | 14 +- 10 files changed, 524 insertions(+), 35 deletions(-) create mode 100644 backend/internal/pkg/email/template.go create mode 100644 backend/internal/pkg/email/template_test.go create mode 100644 backend/internal/pkg/email/templates/base.html create mode 100644 backend/internal/pkg/email/templates/magic_link.html create mode 100644 backend/internal/pkg/email/templates/market_submission.html diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 7f61941..93c5b5b 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -102,7 +102,8 @@ type TurnstileConfig struct { } type NotificationConfig struct { - AdminEmail string + AdminEmail string + FrontendURL string } func Load() (*Config, error) { @@ -236,7 +237,8 @@ func Load() (*Config, error) { SecretKey: envStr("TURNSTILE_SECRET_KEY", ""), }, Notification: NotificationConfig{ - AdminEmail: envStr("ADMIN_EMAIL", ""), + AdminEmail: envStr("ADMIN_EMAIL", ""), + FrontendURL: envStr("FRONTEND_URL", "http://localhost:5173"), }, }, nil } diff --git a/backend/internal/domain/auth/magiclink.go b/backend/internal/domain/auth/magiclink.go index 648a5ab..b2f529f 100644 --- a/backend/internal/domain/auth/magiclink.go +++ b/backend/internal/domain/auth/magiclink.go @@ -14,22 +14,27 @@ import ( "marktvogt.de/backend/internal/config" "marktvogt.de/backend/internal/domain/user" "marktvogt.de/backend/internal/pkg/apierror" + "marktvogt.de/backend/internal/pkg/email" "marktvogt.de/backend/internal/pkg/validate" ) type MagicLinkHandler struct { - authRepo Repository - userRepo user.Repository - service *Service - cfg config.MagicLinkConfig + authRepo Repository + userRepo user.Repository + service *Service + cfg config.MagicLinkConfig + email email.Sender + frontendURL string } -func NewMagicLinkHandler(authRepo Repository, userRepo user.Repository, service *Service, cfg config.MagicLinkConfig) *MagicLinkHandler { +func NewMagicLinkHandler(authRepo Repository, userRepo user.Repository, service *Service, cfg config.MagicLinkConfig, emailSender email.Sender, frontendURL string) *MagicLinkHandler { return &MagicLinkHandler{ - authRepo: authRepo, - userRepo: userRepo, - service: service, - cfg: cfg, + authRepo: authRepo, + userRepo: userRepo, + service: service, + cfg: cfg, + email: emailSender, + frontendURL: frontendURL, } } @@ -58,10 +63,39 @@ func (h *MagicLinkHandler) RequestMagicLink(c *gin.Context) { return } - // TODO: Send email with magic link - // For now, log the link in development magicURL := fmt.Sprintf("%s?token=%s", h.cfg.BaseURL, token) - slog.Info("magic link created (send via email in production)", "email", req.Email, "url", magicURL) + + if h.email != nil { + go func() { + expiresMin := int(h.cfg.TTL.Minutes()) + msg := email.Message{ + To: req.Email, + Subject: "Dein Anmeldelink – Marktvogt", + Body: fmt.Sprintf("Klicke auf den folgenden Link, um dich anzumelden:\n\n%s\n\nDer Link ist %d Minuten gültig.", magicURL, expiresMin), + } + + html, err := email.Render("magic_link", email.TemplateData{ + PreheaderText: "Dein Anmeldelink für Marktvogt", + BaseURL: h.frontendURL, + Year: time.Now().Year(), + Content: email.MagicLinkData{ + MagicURL: magicURL, + ExpiresMin: expiresMin, + }, + }) + if err != nil { + slog.Error("failed to render magic link email template", "error", err) + } else { + msg.HTML = html + } + + if err := h.email.Send(context.Background(), msg); err != nil { + slog.Error("failed to send magic link email", "email", req.Email, "error", err) + } + }() + } else { + slog.Info("magic link created (no email sender configured)", "email", req.Email, "url", magicURL) + } c.JSON(http.StatusOK, MessageResponse{Data: MessageData{ Message: "if an account exists with that email, a magic link has been sent", diff --git a/backend/internal/domain/market/service.go b/backend/internal/domain/market/service.go index 6d094a5..ce24ef7 100644 --- a/backend/internal/domain/market/service.go +++ b/backend/internal/domain/market/service.go @@ -15,22 +15,24 @@ import ( ) type Service struct { - repo Repository - email email.Sender - turnstile turnstile.Verifier - adminEmail string + repo Repository + email email.Sender + turnstile turnstile.Verifier + adminEmail string + frontendURL string } func NewService(repo Repository) *Service { return &Service{repo: repo} } -func NewServiceFull(repo Repository, emailSender email.Sender, ts turnstile.Verifier, adminEmail string) *Service { +func NewServiceFull(repo Repository, emailSender email.Sender, ts turnstile.Verifier, adminEmail, frontendURL string) *Service { return &Service{ - repo: repo, - email: emailSender, - turnstile: ts, - adminEmail: adminEmail, + repo: repo, + email: emailSender, + turnstile: ts, + adminEmail: adminEmail, + frontendURL: frontendURL, } } @@ -227,6 +229,27 @@ func (s *Service) SubmitMarket(ctx context.Context, req SubmitMarketRequest, rem req.SubmitterName, req.SubmitterEmail, ), } + + html, err := email.Render("market_submission", email.TemplateData{ + PreheaderText: fmt.Sprintf("Neuer Markt: %s", req.Name), + BaseURL: s.frontendURL, + Year: time.Now().Year(), + Content: email.MarketSubmissionData{ + MarketName: req.Name, + City: req.City, + StartDate: req.StartDate, + EndDate: req.EndDate, + SubmitterName: req.SubmitterName, + SubmitterEmail: req.SubmitterEmail, + AdminURL: s.frontendURL + "/admin/maerkte", + }, + }) + if err != nil { + slog.Error("failed to render submission email template", "error", err) + } else { + msg.HTML = html + } + if err := s.email.Send(context.Background(), msg); err != nil { slog.Error("failed to send submission notification", "error", err) } diff --git a/backend/internal/pkg/email/email.go b/backend/internal/pkg/email/email.go index 24a965b..9f589a5 100644 --- a/backend/internal/pkg/email/email.go +++ b/backend/internal/pkg/email/email.go @@ -2,6 +2,7 @@ package email import ( "context" + "crypto/rand" "fmt" "log/slog" "net/smtp" @@ -11,6 +12,7 @@ type Message struct { To string Subject string Body string + HTML string } type Sender interface { @@ -37,23 +39,54 @@ func (s *SMTPSender) Send(_ context.Context, msg Message) error { auth = smtp.PlainAuth("", s.user, s.pass, s.host) } - body := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", - s.from, msg.To, msg.Subject, msg.Body) - - if err := smtp.SendMail(addr, auth, s.from, []string{msg.To}, []byte(body)); err != nil { + raw := buildMIME(s.from, msg) + if err := smtp.SendMail(addr, auth, s.from, []string{msg.To}, raw); err != nil { return fmt.Errorf("sending email to %s: %w", msg.To, err) } return nil } +func buildMIME(from string, msg Message) []byte { + headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\n", + from, msg.To, msg.Subject) + + if msg.HTML == "" { + return []byte(headers + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + msg.Body) + } + + boundary := randomBoundary() + headers += fmt.Sprintf("Content-Type: multipart/alternative; boundary=%q\r\n\r\n", boundary) + + body := headers + body += "--" + boundary + "\r\n" + body += "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + body += msg.Body + "\r\n" + body += "--" + boundary + "\r\n" + body += "Content-Type: text/html; charset=UTF-8\r\n\r\n" + body += msg.HTML + "\r\n" + body += "--" + boundary + "--\r\n" + + return []byte(body) +} + +func randomBoundary() string { + var buf [16]byte + _, _ = rand.Read(buf[:]) + return fmt.Sprintf("=_%x", buf) +} + type LogSender struct{} func (l *LogSender) Send(_ context.Context, msg Message) error { - slog.Info("email (dev mode)", + attrs := []any{ "to", msg.To, "subject", msg.Subject, "body_len", len(msg.Body), - ) + } + if msg.HTML != "" { + attrs = append(attrs, "html_len", len(msg.HTML)) + } + slog.Info("email (dev mode)", attrs...) return nil } diff --git a/backend/internal/pkg/email/template.go b/backend/internal/pkg/email/template.go new file mode 100644 index 0000000..29c6315 --- /dev/null +++ b/backend/internal/pkg/email/template.go @@ -0,0 +1,81 @@ +package email + +import ( + "bytes" + "embed" + "fmt" + "html/template" +) + +//go:embed templates/*.html +var templateFS embed.FS + +var templates map[string]*template.Template + +func init() { + base, err := template.New("base").Parse(mustRead("templates/base.html")) + if err != nil { + panic(fmt.Sprintf("parsing base template: %v", err)) + } + + contentFiles := []string{ + "market_submission", + "magic_link", + } + + templates = make(map[string]*template.Template, len(contentFiles)) + for _, name := range contentFiles { + clone, err := base.Clone() + if err != nil { + panic(fmt.Sprintf("cloning base for %s: %v", name, err)) + } + _, err = clone.Parse(mustRead(fmt.Sprintf("templates/%s.html", name))) + if err != nil { + panic(fmt.Sprintf("parsing template %s: %v", name, err)) + } + templates[name] = clone + } +} + +func mustRead(path string) string { + data, err := templateFS.ReadFile(path) + if err != nil { + panic(fmt.Sprintf("reading embedded file %s: %v", path, err)) + } + return string(data) +} + +type TemplateData struct { + PreheaderText string + BaseURL string + Year int + Content any +} + +type MarketSubmissionData struct { + MarketName string + City string + StartDate string + EndDate string + SubmitterName string + SubmitterEmail string + AdminURL string +} + +type MagicLinkData struct { + MagicURL string + ExpiresMin int +} + +func Render(name string, data TemplateData) (string, error) { + tmpl, ok := templates[name] + if !ok { + return "", fmt.Errorf("unknown email template: %q", name) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("executing template %q: %w", name, err) + } + return buf.String(), nil +} diff --git a/backend/internal/pkg/email/template_test.go b/backend/internal/pkg/email/template_test.go new file mode 100644 index 0000000..571245e --- /dev/null +++ b/backend/internal/pkg/email/template_test.go @@ -0,0 +1,165 @@ +package email + +import ( + "strings" + "testing" +) + +func TestRenderMarketSubmission(t *testing.T) { + data := MarketSubmissionData{ + MarketName: "Mittelaltermarkt Nürnberg", + City: "Nürnberg", + StartDate: "2026-06-15", + EndDate: "2026-06-17", + SubmitterName: "Hans Müller", + SubmitterEmail: "hans@example.com", + AdminURL: "https://marktvogt.de/admin/maerkte", + } + + html, err := Render("market_submission", TemplateData{ + PreheaderText: "Neuer Markt eingereicht", + BaseURL: "https://marktvogt.de", + Year: 2026, + Content: data, + }) + if err != nil { + t.Fatalf("Render failed: %v", err) + } + + checks := []string{ + "Mittelaltermarkt Nürnberg", + "Nürnberg", + "2026-06-15", + "2026-06-17", + "Hans Müller", + "hans@example.com", + "https://marktvogt.de/admin/maerkte", + "Marktvogt", + "2026", + } + for _, want := range checks { + if !strings.Contains(html, want) { + t.Errorf("rendered HTML missing %q", want) + } + } +} + +func TestRenderMagicLink(t *testing.T) { + data := MagicLinkData{ + MagicURL: "https://marktvogt.de/auth/magic-link/verify?token=abc123", + ExpiresMin: 15, + } + + html, err := Render("magic_link", TemplateData{ + PreheaderText: "Dein Anmeldelink", + BaseURL: "https://marktvogt.de", + Year: 2026, + Content: data, + }) + if err != nil { + t.Fatalf("Render failed: %v", err) + } + + checks := []string{ + "https://marktvogt.de/auth/magic-link/verify?token=abc123", + "15", + "Marktvogt", + } + for _, want := range checks { + if !strings.Contains(html, want) { + t.Errorf("rendered HTML missing %q", want) + } + } +} + +func TestRenderUnknownTemplate(t *testing.T) { + _, err := Render("nonexistent", TemplateData{ + BaseURL: "https://marktvogt.de", + Year: 2026, + }) + if err == nil { + t.Fatal("expected error for unknown template, got nil") + } + if !strings.Contains(err.Error(), "nonexistent") { + t.Errorf("error should mention template name, got: %v", err) + } +} + +func TestRenderProducesValidHTML(t *testing.T) { + html, err := Render("market_submission", TemplateData{ + PreheaderText: "Test", + BaseURL: "https://marktvogt.de", + Year: 2026, + Content: MarketSubmissionData{ + MarketName: "Test", + City: "Berlin", + StartDate: "2026-01-01", + EndDate: "2026-01-02", + SubmitterName: "Test User", + SubmitterEmail: "test@example.com", + AdminURL: "https://marktvogt.de/admin", + }, + }) + if err != nil { + t.Fatalf("Render failed: %v", err) + } + + if !strings.HasPrefix(strings.TrimSpace(html), "") { + t.Error("HTML should end with closing html tag") + } +} + +func TestBuildMIMEPlainOnly(t *testing.T) { + msg := Message{ + To: "test@example.com", + Subject: "Test", + Body: "Hello plain", + } + raw := string(buildMIME("from@example.com", msg)) + + if strings.Contains(raw, "multipart") { + t.Error("plain-only message should not be multipart") + } + if !strings.Contains(raw, "text/plain") { + t.Error("should contain text/plain content type") + } + if !strings.Contains(raw, "Hello plain") { + t.Error("should contain body text") + } +} + +func TestBuildMIMEMultipart(t *testing.T) { + msg := Message{ + To: "test@example.com", + Subject: "Test", + Body: "Hello plain", + HTML: "

Hello HTML

", + } + raw := string(buildMIME("from@example.com", msg)) + + if !strings.Contains(raw, "multipart/alternative") { + t.Error("should be multipart/alternative") + } + if !strings.Contains(raw, "text/plain") { + t.Error("should contain text/plain part") + } + if !strings.Contains(raw, "text/html") { + t.Error("should contain text/html part") + } + if !strings.Contains(raw, "Hello plain") { + t.Error("should contain plain text body") + } + if !strings.Contains(raw, "

Hello HTML

") { + t.Error("should contain HTML body") + } + + // Plain text part should come before HTML (RFC 2046) + plainIdx := strings.Index(raw, "text/plain") + htmlIdx := strings.Index(raw, "text/html") + if plainIdx >= htmlIdx { + t.Error("text/plain should appear before text/html per RFC 2046") + } +} diff --git a/backend/internal/pkg/email/templates/base.html b/backend/internal/pkg/email/templates/base.html new file mode 100644 index 0000000..30fbc07 --- /dev/null +++ b/backend/internal/pkg/email/templates/base.html @@ -0,0 +1,60 @@ + + + + + + Marktvogt + + + + {{if .PreheaderText}}
{{.PreheaderText}}
{{end}} + + + + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+
+
+ Marktvogt +
+
 
+ {{template "content" .}} +
+ + + + +
+ © {{.Year}} Marktvogt · + marktvogt.de +
+
+
+ + diff --git a/backend/internal/pkg/email/templates/magic_link.html b/backend/internal/pkg/email/templates/magic_link.html new file mode 100644 index 0000000..252221b --- /dev/null +++ b/backend/internal/pkg/email/templates/magic_link.html @@ -0,0 +1,36 @@ +{{define "content"}} + + + + + + + + + + + + + + + + +
+ Dein Anmeldelink +
+ Klicke auf den Button, um dich bei Marktvogt anzumelden. Der Link ist {{.Content.ExpiresMin}} Minuten gültig. +
+ + + + +
+ Jetzt anmelden +
+
+ Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
+ {{.Content.MagicURL}} +
+ Du hast diese E-Mail nicht angefordert? Dann kannst du sie ignorieren. +
+{{end}} diff --git a/backend/internal/pkg/email/templates/market_submission.html b/backend/internal/pkg/email/templates/market_submission.html new file mode 100644 index 0000000..fd3447c --- /dev/null +++ b/backend/internal/pkg/email/templates/market_submission.html @@ -0,0 +1,53 @@ +{{define "content"}} + + + + + + + + + + + + + +
+ Neuer Markt eingereicht +
+ Ein neuer Markt wurde zur Prüfung eingereicht. +
+ + + + + + + + + + + + + + + + + + + + + +
Marktname{{.Content.MarketName}}
Stadt{{.Content.City}}
Zeitraum{{.Content.StartDate}} – {{.Content.EndDate}}
Eingereicht von{{.Content.SubmitterName}}
E-Mail + {{.Content.SubmitterEmail}} +
+
+ + + + +
+ Im Admin-Panel prüfen +
+
+{{end}} diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go index edb88e1..24df7e6 100644 --- a/backend/internal/server/routes.go +++ b/backend/internal/server/routes.go @@ -32,8 +32,14 @@ func (s *Server) registerRoutes() { oauthHandler := auth.NewOAuthHandler(s.cfg.OAuth, authSvc, userRepo, authRepo) auth.RegisterOAuthRoutes(v1, oauthHandler) + // Shared email sender + emailSender := email.New( + s.cfg.SMTP.Host, s.cfg.SMTP.Port, + s.cfg.SMTP.User, s.cfg.SMTP.Password, s.cfg.SMTP.From, + ) + // Magic link routes - magicLinkHandler := auth.NewMagicLinkHandler(authRepo, userRepo, authSvc, s.cfg.Magic) + magicLinkHandler := auth.NewMagicLinkHandler(authRepo, userRepo, authSvc, s.cfg.Magic, emailSender, s.cfg.Notification.FrontendURL) auth.RegisterMagicLinkRoutes(v1, magicLinkHandler) // User profile routes @@ -42,14 +48,10 @@ func (s *Server) registerRoutes() { user.RegisterRoutes(v1, userHandler, requireAuth) // Market routes (public + submission + admin) - emailSender := email.New( - s.cfg.SMTP.Host, s.cfg.SMTP.Port, - s.cfg.SMTP.User, s.cfg.SMTP.Password, s.cfg.SMTP.From, - ) tsVerifier := turnstile.New(s.cfg.Turnstile.SecretKey) marketRepo := market.NewRepository(s.db) - marketSvc := market.NewServiceFull(marketRepo, emailSender, tsVerifier, s.cfg.Notification.AdminEmail) + marketSvc := market.NewServiceFull(marketRepo, emailSender, tsVerifier, s.cfg.Notification.AdminEmail, s.cfg.Notification.FrontendURL) marketHandler := market.NewHandler(marketSvc) submissionHandler := market.NewSubmissionHandler(marketSvc) submitLimit := middleware.RateLimit(3.0/3600.0, 3) // 3 per hour per IP