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
This commit is contained in:
2026-02-27 11:36:59 +01:00
parent af7703b644
commit e07f4c4c64
10 changed files with 524 additions and 35 deletions

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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), "<!DOCTYPE html") {
t.Error("HTML should start with DOCTYPE")
}
if !strings.Contains(html, "</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: "<p>Hello HTML</p>",
}
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, "<p>Hello HTML</p>") {
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")
}
}

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Marktvogt</title>
<!--[if mso]><style>table,td{font-family:Georgia,'Times New Roman',serif!important}</style><![endif]-->
</head>
<body style="margin:0;padding:0;background-color:#f5f0e8;font-family:Georgia,'Times New Roman',serif;">
{{if .PreheaderText}}<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">{{.PreheaderText}}</div>{{end}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f5f0e8;">
<tr>
<td align="center" style="padding:24px 16px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
<!-- Header -->
<tr>
<td align="center" style="background-color:#14472a;padding:28px 24px;border-radius:8px 8px 0 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="padding-bottom:12px;">
<div style="width:48px;height:48px;border-radius:50%;background-color:#d4a63a;text-align:center;line-height:48px;font-size:22px;color:#14472a;">&#9876;</div>
</td>
</tr>
<tr>
<td align="center">
<span style="font-size:26px;font-weight:bold;color:#d4a63a;letter-spacing:1px;">Marktvogt</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- Gold accent line -->
<tr>
<td style="background-color:#d4a63a;height:3px;font-size:1px;line-height:1px;">&nbsp;</td>
</tr>
<!-- Content -->
<tr>
<td style="background-color:#ffffff;padding:32px 28px;">
{{template "content" .}}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color:#f5f3f0;padding:20px 28px;border-radius:0 0 8px 8px;border-top:1px solid #e8e5e0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="font-size:13px;color:#8a8580;line-height:1.5;">
&copy; {{.Year}} Marktvogt &middot;
<a href="{{.BaseURL}}" style="color:#1a6b3a;text-decoration:none;">marktvogt.de</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,36 @@
{{define "content"}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-size:20px;font-weight:bold;color:#14472a;padding-bottom:8px;">
Dein Anmeldelink
</td>
</tr>
<tr>
<td style="font-size:15px;color:#4a4745;padding-bottom:24px;line-height:1.5;">
Klicke auf den Button, um dich bei Marktvogt anzumelden. Der Link ist {{.Content.ExpiresMin}} Minuten g&uuml;ltig.
</td>
</tr>
<tr>
<td align="center" style="padding-bottom:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="background-color:#1a6b3a;border-radius:6px;">
<a href="{{.Content.MagicURL}}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:bold;color:#ffffff;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">Jetzt anmelden</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="font-size:13px;color:#8a8580;line-height:1.5;border-top:1px solid #e8e5e0;padding-top:16px;">
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br/>
<a href="{{.Content.MagicURL}}" style="color:#1a6b3a;word-break:break-all;text-decoration:none;">{{.Content.MagicURL}}</a>
</td>
</tr>
<tr>
<td style="font-size:13px;color:#8a8580;padding-top:16px;line-height:1.5;">
Du hast diese E-Mail nicht angefordert? Dann kannst du sie ignorieren.
</td>
</tr>
</table>
{{end}}

View File

@@ -0,0 +1,53 @@
{{define "content"}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-size:20px;font-weight:bold;color:#14472a;padding-bottom:8px;">
Neuer Markt eingereicht
</td>
</tr>
<tr>
<td style="font-size:15px;color:#4a4745;padding-bottom:20px;line-height:1.5;">
Ein neuer Markt wurde zur Pr&uuml;fung eingereicht.
</td>
</tr>
<tr>
<td>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #e8e5e0;border-radius:6px;">
<tr>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:13px;color:#8a8580;width:140px;">Marktname</td>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:15px;color:#3a3836;font-weight:bold;">{{.Content.MarketName}}</td>
</tr>
<tr>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:13px;color:#8a8580;">Stadt</td>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:15px;color:#3a3836;">{{.Content.City}}</td>
</tr>
<tr>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:13px;color:#8a8580;">Zeitraum</td>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:15px;color:#3a3836;">{{.Content.StartDate}} &ndash; {{.Content.EndDate}}</td>
</tr>
<tr>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:13px;color:#8a8580;">Eingereicht von</td>
<td style="padding:12px 16px;border-bottom:1px solid #e8e5e0;font-size:15px;color:#3a3836;">{{.Content.SubmitterName}}</td>
</tr>
<tr>
<td style="padding:12px 16px;font-size:13px;color:#8a8580;">E-Mail</td>
<td style="padding:12px 16px;font-size:15px;color:#3a3836;">
<a href="mailto:{{.Content.SubmitterEmail}}" style="color:#1a6b3a;text-decoration:none;">{{.Content.SubmitterEmail}}</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding-top:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="background-color:#1a6b3a;border-radius:6px;">
<a href="{{.Content.AdminURL}}" style="display:inline-block;padding:12px 28px;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;font-family:Georgia,'Times New Roman',serif;">Im Admin-Panel pr&uuml;fen</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
{{end}}

View File

@@ -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