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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
81
backend/internal/pkg/email/template.go
Normal file
81
backend/internal/pkg/email/template.go
Normal 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
|
||||
}
|
||||
165
backend/internal/pkg/email/template_test.go
Normal file
165
backend/internal/pkg/email/template_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
60
backend/internal/pkg/email/templates/base.html
Normal file
60
backend/internal/pkg/email/templates/base.html
Normal 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;">⚔</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;"> </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;">
|
||||
© {{.Year}} Marktvogt ·
|
||||
<a href="{{.BaseURL}}" style="color:#1a6b3a;text-decoration:none;">marktvogt.de</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
36
backend/internal/pkg/email/templates/magic_link.html
Normal file
36
backend/internal/pkg/email/templates/magic_link.html
Normal 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ü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}}
|
||||
53
backend/internal/pkg/email/templates/market_submission.html
Normal file
53
backend/internal/pkg/email/templates/market_submission.html
Normal 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ü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}} – {{.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üfen</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{end}}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user