e07f4c4c64
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
99 lines
2.2 KiB
Go
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)
|
|
}
|