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" .}}
+ |
+
+
+
+ |
+
+ |
+
+
+ |
+
+
+
+
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.
+ |
+
+
+ |
+
+ |
+
+
+
+ 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}}
+ |
+
+
+ |
+
+
+ |
+
+ |
+
+
+{{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