Files
marktvogt.de/backend/internal/pkg/email/email.go
T
vikingowl e07f4c4c64 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
2026-02-27 11:36:59 +01:00

99 lines
2.2 KiB
Go

package email
import (
"context"
"crypto/rand"
"fmt"
"log/slog"
"net/smtp"
)
type Message struct {
To string
Subject string
Body string
HTML string
}
type Sender interface {
Send(ctx context.Context, msg Message) error
}
type SMTPSender struct {
host string
port int
user string
pass string
from string
}
func NewSMTPSender(host string, port int, user, pass, from string) *SMTPSender {
return &SMTPSender{host: host, port: port, user: user, pass: pass, from: from}
}
func (s *SMTPSender) Send(_ context.Context, msg Message) error {
addr := fmt.Sprintf("%s:%d", s.host, s.port)
var auth smtp.Auth
if s.user != "" {
auth = smtp.PlainAuth("", s.user, s.pass, s.host)
}
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 {
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
}
func New(host string, port int, user, pass, from string) Sender {
if host == "" {
return &LogSender{}
}
return NewSMTPSender(host, port, user, pass, from)
}