feat(04-03): select memory, log, and SMTP mail drivers from config

- Load mail.* and SUMMER_MAIL__ overrides to pick memory, log, or smtp
- Log headers and text without credentials; SMTP uses explicit TLS and go-mail
- Reject CR/LF headers and invalid addresses and surface driver errors once

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-18 14:02:23 +02:00
parent 1e515971c9
commit 1b7245661d
6 changed files with 519 additions and 6 deletions

View File

@@ -3,7 +3,13 @@ package postcard
import (
"context"
"fmt"
"log/slog"
netmail "net/mail"
"strings"
"sync"
"time"
gomail "github.com/wneessen/go-mail"
)
// Driver delivers a fully rendered message.
@@ -23,6 +29,16 @@ type RenderedMessage struct {
Text string
}
// SMTPConfig is the compass mail.smtp section used by the SMTP driver.
type SMTPConfig struct {
Host string
Port int
Username string
Password string
TLS string
Timeout time.Duration
}
// MemoryDriver stores rendered messages for tests. It is concurrency-safe.
type MemoryDriver struct {
mu sync.Mutex
@@ -59,9 +75,229 @@ func (d *MemoryDriver) Messages() []RenderedMessage {
return out
}
// LogDriver writes rendered headers and the text part to a logger.
type LogDriver struct {
log *slog.Logger
}
// NewLogDriver logs through log. slog.Default is used when log is nil.
func NewLogDriver(log *slog.Logger) *LogDriver {
if log == nil {
log = slog.Default()
}
return &LogDriver{log: log}
}
// Send logs From/To/Cc/Bcc/ReplyTo/Subject and the text part. It never
// logs SMTP credentials or the HTML body.
func (d *LogDriver) Send(_ context.Context, msg RenderedMessage) error {
if d == nil || d.log == nil {
return fmt.Errorf("postcard: log driver is nil")
}
d.log.Info("postcard mail",
slog.String("from", msg.From),
slog.Any("to", msg.To),
slog.Any("cc", msg.Cc),
slog.Any("bcc", msg.Bcc),
slog.String("reply_to", msg.ReplyTo),
slog.String("subject", msg.Subject),
slog.String("text", msg.Text),
)
return nil
}
// SMTPDriver sends through go-mail with an explicit TLS policy.
type SMTPDriver struct {
client *gomail.Client
}
// NewSMTPDriver builds a go-mail client. TLS defaults to mandatory; NoTLS
// is opt-in for local Mailpit and is never inferred.
func NewSMTPDriver(cfg SMTPConfig) (*SMTPDriver, error) {
if strings.TrimSpace(cfg.Host) == "" {
return nil, fmt.Errorf("postcard: mail.smtp.host is empty")
}
port := cfg.Port
if port == 0 {
port = 587
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
policy, err := parseTLSPolicy(cfg.TLS)
if err != nil {
return nil, err
}
opts := []gomail.Option{
gomail.WithPort(port),
gomail.WithTLSPolicy(policy),
gomail.WithTimeout(timeout),
}
if strings.TrimSpace(cfg.Username) != "" {
opts = append(opts,
gomail.WithSMTPAuth(gomail.SMTPAuthPlain),
gomail.WithUsername(cfg.Username),
gomail.WithPassword(cfg.Password),
)
}
client, err := gomail.NewClient(cfg.Host, opts...)
if err != nil {
return nil, fmt.Errorf("postcard: smtp client: %w", err)
}
return &SMTPDriver{client: client}, nil
}
// Send validates headers and recipients, then delivers with context.
func (d *SMTPDriver) Send(ctx context.Context, msg RenderedMessage) error {
if d == nil || d.client == nil {
return fmt.Errorf("postcard: smtp driver is nil")
}
if err := validateRendered(msg, true); err != nil {
return err
}
gm, err := buildSMTPMessage(msg)
if err != nil {
return err
}
if err := d.client.DialAndSendWithContext(ctx, gm); err != nil {
return fmt.Errorf("smtp: %w", err)
}
return nil
}
// FailDriver returns Err from Send. Tests use it as a deterministic failure.
type FailDriver struct {
Err error
sends int
sendsMu sync.Mutex
}
// Send returns Err without retrying.
func (d *FailDriver) Send(context.Context, RenderedMessage) error {
if d == nil {
return fmt.Errorf("postcard: fail driver is nil")
}
d.sendsMu.Lock()
d.sends++
d.sendsMu.Unlock()
if d.Err == nil {
return fmt.Errorf("postcard: driver failed")
}
return d.Err
}
func (d *FailDriver) sendCount() int {
d.sendsMu.Lock()
defer d.sendsMu.Unlock()
return d.sends
}
func cloneRendered(msg RenderedMessage) RenderedMessage {
msg.To = append([]string(nil), msg.To...)
msg.Cc = append([]string(nil), msg.Cc...)
msg.Bcc = append([]string(nil), msg.Bcc...)
return msg
}
func parseTLSPolicy(v string) (gomail.TLSPolicy, error) {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "mandatory", "tls":
return gomail.TLSMandatory, nil
case "opportunistic", "starttls":
return gomail.TLSOpportunistic, nil
case "none", "notls", "off":
return gomail.NoTLS, nil
default:
return 0, fmt.Errorf("postcard: unknown mail.smtp.tls %q (use mandatory, starttls, or none)", v)
}
}
func buildSMTPMessage(msg RenderedMessage) (*gomail.Msg, error) {
gm := gomail.NewMsg()
if err := gm.From(msg.From); err != nil {
return nil, fmt.Errorf("postcard: from: %w", err)
}
if len(msg.To) > 0 {
if err := gm.To(msg.To...); err != nil {
return nil, fmt.Errorf("postcard: to: %w", err)
}
}
if len(msg.Cc) > 0 {
if err := gm.Cc(msg.Cc...); err != nil {
return nil, fmt.Errorf("postcard: cc: %w", err)
}
}
if len(msg.Bcc) > 0 {
if err := gm.Bcc(msg.Bcc...); err != nil {
return nil, fmt.Errorf("postcard: bcc: %w", err)
}
}
if msg.ReplyTo != "" {
if err := gm.ReplyTo(msg.ReplyTo); err != nil {
return nil, fmt.Errorf("postcard: reply-to: %w", err)
}
}
gm.Subject(msg.Subject)
gm.SetBodyString(gomail.TypeTextPlain, msg.Text)
gm.AddAlternativeString(gomail.TypeTextHTML, msg.HTML)
return gm, nil
}
func validateRendered(msg RenderedMessage, requireFrom bool) error {
if err := rejectCRLF("subject", msg.Subject); err != nil {
return err
}
if err := rejectCRLF("from", msg.From); err != nil {
return err
}
if requireFrom || msg.From != "" {
if err := validateAddr("from", msg.From); err != nil {
return err
}
}
if err := rejectCRLF("reply-to", msg.ReplyTo); err != nil {
return err
}
if msg.ReplyTo != "" {
if err := validateAddr("reply-to", msg.ReplyTo); err != nil {
return err
}
}
for _, addr := range msg.To {
if err := validateAddr("to", addr); err != nil {
return err
}
}
for _, addr := range msg.Cc {
if err := validateAddr("cc", addr); err != nil {
return err
}
}
for _, addr := range msg.Bcc {
if err := validateAddr("bcc", addr); err != nil {
return err
}
}
return nil
}
func validateAddr(kind, addr string) error {
if err := rejectCRLF(kind, addr); err != nil {
return err
}
if strings.TrimSpace(addr) == "" {
return fmt.Errorf("postcard: empty %s address", kind)
}
if _, err := netmail.ParseAddress(addr); err != nil {
return fmt.Errorf("postcard: invalid %s address", kind)
}
return nil
}
func rejectCRLF(kind, value string) error {
if strings.ContainsAny(value, "\r\n") {
return fmt.Errorf("postcard: %s contains CR/LF", kind)
}
return nil
}

View File

@@ -3,9 +3,12 @@ package postcard
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/compass"
"git.golem15.com/golem15/summercms/pact"
)
@@ -69,6 +72,10 @@ func (m *mailer) Send(ctx context.Context, msg Message) error {
return err
}
rendered.From = m.opts.From
_, smtp := m.driver.(*SMTPDriver)
if err := validateRendered(rendered, smtp); err != nil {
return err
}
if err := m.driver.Send(ctx, rendered); err != nil {
return fmt.Errorf("postcard: %w", err)
}
@@ -81,15 +88,13 @@ func Activate[P interface{ ID() string }](app *backpack.App, _ []P) error {
if app == nil {
return fmt.Errorf("postcard: app is nil")
}
opts := Options{}
if app.Config != nil {
opts.From = strings.TrimSpace(app.Config.String("mail.from"))
opts.CSS = app.Config.String("mail.css")
opts.BrandCSS = app.Config.String("mail.brandCss")
driver, opts, err := driverFromApp(app)
if err != nil {
return err
}
m := &mailer{
catalog: NewCatalog(),
driver: NewMemoryDriver(),
driver: driver,
opts: opts,
}
if err := app.Publish[Mailer](m); err != nil {
@@ -98,6 +103,64 @@ func Activate[P interface{ ID() string }](app *backpack.App, _ []P) error {
return nil
}
func driverFromApp(app *backpack.App) (Driver, Options, error) {
opts := Options{}
name := "memory"
smtp := SMTPConfig{Port: 587, Timeout: 10 * time.Second}
if app != nil && app.Config != nil {
c := app.Config
if v := strings.TrimSpace(c.String("mail.driver")); v != "" {
name = strings.ToLower(v)
}
opts.From = strings.TrimSpace(c.String("mail.from"))
opts.CSS = c.String("mail.css")
opts.BrandCSS = c.String("mail.brandCss")
smtp = smtpFromConfig(c)
}
switch name {
case "memory":
return NewMemoryDriver(), opts, nil
case "log":
return NewLogDriver(loggerFromApp(app)), opts, nil
case "smtp":
d, err := NewSMTPDriver(smtp)
if err != nil {
return nil, opts, err
}
return d, opts, nil
default:
return nil, opts, fmt.Errorf("postcard: unknown mail.driver %q (use memory, log, or smtp)", name)
}
}
func smtpFromConfig(c *compass.Config) SMTPConfig {
cfg := SMTPConfig{
Host: strings.TrimSpace(c.String("mail.smtp.host")),
Port: c.Int("mail.smtp.port"),
Username: c.String("mail.smtp.username"),
Password: c.String("mail.smtp.password"),
TLS: strings.TrimSpace(c.String("mail.smtp.tls")),
Timeout: 10 * time.Second,
}
if raw := strings.TrimSpace(c.String("mail.smtp.timeout")); raw != "" {
if d, err := time.ParseDuration(raw); err == nil {
cfg.Timeout = d
} else if n := c.Int("mail.smtp.timeout"); n > 0 {
cfg.Timeout = time.Duration(n) * time.Second
}
}
return cfg
}
func loggerFromApp(app *backpack.App) *slog.Logger {
if app != nil {
if log, ok := app.Lookup[*slog.Logger](); ok && log != nil {
return log
}
}
return slog.Default()
}
// BootPlugin registers a plugin's declared mail templates and layouts.
// Missing files and unknown layout aliases fail with the dotted name.
func BootPlugin[P interface{ ID() string }](app *backpack.App, p P) error {

View File

@@ -1,10 +1,19 @@
package postcard
import (
"bytes"
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/compass"
)
func TestMailRenderSmoke(t *testing.T) {
@@ -173,3 +182,196 @@ Body **{{ .Name }}**.
}
})
}
func TestMailDriverSmoke(t *testing.T) {
cat := smokeCatalog(t)
ctx := context.Background()
msg := Message{
Template: "golem15.hello::mail.hello",
To: []string{"ada@example.test"},
Cc: []string{"cc@example.test"},
Bcc: []string{"bcc@example.test"},
ReplyTo: "reply@example.test",
Vars: map[string]any{"Name": "Ada", "URL": "https://example.test"},
}
t.Run("config selects memory log smtp and env override", func(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "mail.yaml"), "driver: memory\nfrom: from@example.test\n")
d, opts, err := driverFromApp(backpack.New(loadMailCfg(t, dir, nil)))
if err != nil {
t.Fatal(err)
}
if _, ok := d.(*MemoryDriver); !ok || opts.From != "from@example.test" {
t.Fatalf("memory: %T from=%q", d, opts.From)
}
writeFile(t, filepath.Join(dir, "mail.yaml"), "driver: log\nfrom: from@example.test\n")
d, _, err = driverFromApp(backpack.New(loadMailCfg(t, dir, nil)))
if err != nil {
t.Fatal(err)
}
if _, ok := d.(*LogDriver); !ok {
t.Fatalf("log: %T", d)
}
writeFile(t, filepath.Join(dir, "mail.yaml"), "driver: smtp\nfrom: from@example.test\nsmtp:\n host: 127.0.0.1\n port: 1025\n tls: none\n")
d, _, err = driverFromApp(backpack.New(loadMailCfg(t, dir, nil)))
if err != nil {
t.Fatal(err)
}
if _, ok := d.(*SMTPDriver); !ok {
t.Fatalf("smtp: %T", d)
}
d, _, err = driverFromApp(backpack.New(loadMailCfg(t, dir, []string{"SUMMER_MAIL__DRIVER=log"})))
if err != nil {
t.Fatal(err)
}
if _, ok := d.(*LogDriver); !ok {
t.Fatalf("SUMMER_MAIL__DRIVER override: %T", d)
}
writeFile(t, filepath.Join(dir, "mail.yaml"), "driver: fax\n")
_, _, err = driverFromApp(backpack.New(loadMailCfg(t, dir, nil)))
if err == nil || !strings.Contains(err.Error(), "fax") {
t.Fatalf("unknown driver error = %v", err)
}
})
t.Run("log driver writes headers and text without credentials", func(t *testing.T) {
var buf bytes.Buffer
mail := NewMailer(cat, NewLogDriver(slog.New(slog.NewTextHandler(&buf, nil))), Options{From: "from@example.test"})
if err := mail.Send(ctx, msg); err != nil {
t.Fatal(err)
}
out := buf.String()
for _, want := range []string{"from@example.test", "ada@example.test", "Witaj Ada", "Witaj **Ada**."} {
if !strings.Contains(out, want) {
t.Fatalf("log missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "password") || strings.Contains(out, "secret") {
t.Fatalf("log leaked credentials:\n%s", out)
}
})
t.Run("failing driver surfaces error without retry", func(t *testing.T) {
want := errors.New("driver down")
fail := &FailDriver{Err: want}
mail := NewMailer(cat, fail, Options{From: "from@example.test"})
err := mail.Send(ctx, msg)
if !errors.Is(err, want) {
t.Fatalf("Send error = %v", err)
}
if fail.sendCount() != 1 {
t.Fatalf("sends = %d, want 1 (no retry)", fail.sendCount())
}
})
t.Run("unsafe headers and addresses are rejected", func(t *testing.T) {
drv := NewMemoryDriver()
mail := NewMailer(cat, drv, Options{From: "from@example.test"})
err := mail.Send(ctx, Message{
Template: "golem15.hello::mail.hello",
To: []string{"ada@example.test"},
Subject: "Hi\r\nBcc: evil@example.test",
Vars: map[string]any{"Name": "Ada", "URL": "https://example.test"},
})
if err == nil || !strings.Contains(err.Error(), "CR/LF") {
t.Fatalf("CR/LF error = %v", err)
}
err = mail.Send(ctx, Message{
Template: "golem15.hello::mail.hello",
To: []string{"not-an-email"},
Vars: map[string]any{"Name": "Ada", "URL": "https://example.test"},
})
if err == nil || !strings.Contains(err.Error(), "invalid to address") {
t.Fatalf("invalid address error = %v", err)
}
if len(drv.Messages()) != 0 {
t.Fatal("rejected messages must not be stored")
}
})
t.Run("smtp constructs a message and propagates errors", func(t *testing.T) {
gm, err := buildSMTPMessage(RenderedMessage{
From: "from@example.test",
To: []string{"ada@example.test"},
Cc: []string{"cc@example.test"},
Bcc: []string{"bcc@example.test"},
ReplyTo: "reply@example.test",
Subject: "Hi Ada",
Text: "text body",
HTML: "<p>html body</p>",
})
if err != nil || gm == nil {
t.Fatalf("buildSMTPMessage: %v", err)
}
d, err := NewSMTPDriver(SMTPConfig{
Host: "127.0.0.1",
Port: 1,
Username: "user",
Password: "secretpass",
TLS: "none",
Timeout: 80 * time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
mail := NewMailer(cat, d, Options{From: "from@example.test"})
err = mail.Send(ctx, msg)
if err == nil {
t.Fatal("expected smtp failure")
}
if !strings.Contains(err.Error(), "smtp") {
t.Fatalf("error %q should mention smtp", err)
}
if strings.Contains(err.Error(), "secretpass") || strings.Contains(err.Error(), "text body") {
t.Fatalf("smtp error leaked secrets or body: %v", err)
}
_, err = NewSMTPDriver(SMTPConfig{Host: "127.0.0.1", TLS: "maybe"})
if err == nil || !strings.Contains(err.Error(), "mail.smtp.tls") {
t.Fatalf("unknown tls error = %v", err)
}
_, err = NewSMTPDriver(SMTPConfig{TLS: "none"})
if err == nil || !strings.Contains(err.Error(), "mail.smtp.host") {
t.Fatalf("empty host error = %v", err)
}
})
}
func smokeCatalog(t *testing.T) *Catalog {
t.Helper()
cat := NewCatalog()
err := cat.Register("golem15.hello", fstest.MapFS{
"views/mail/hello.htm": {Data: []byte(`subject = "Witaj {{ .Name }}"
layout = "default"
==
Witaj **{{ .Name }}**.
`)},
}, []string{"golem15.hello::mail.hello"}, nil)
if err != nil {
t.Fatal(err)
}
return cat
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func loadMailCfg(t *testing.T, dir string, environ []string) *compass.Config {
t.Helper()
if environ == nil {
environ = []string{}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: environ})
if err != nil {
t.Fatal(err)
}
return cfg
}