From 1b7245661d54b16549145b5725713f92dd7f1c47 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 18 Sep 2026 14:02:23 +0200 Subject: [PATCH] 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 --- examples/hello/config/mail.yaml | 9 ++ go.mod | 1 + go.sum | 2 + postcard/drivers.go | 236 ++++++++++++++++++++++++++++++++ postcard/mailer.go | 75 +++++++++- postcard/mailer_test.go | 202 +++++++++++++++++++++++++++ 6 files changed, 519 insertions(+), 6 deletions(-) create mode 100644 examples/hello/config/mail.yaml diff --git a/examples/hello/config/mail.yaml b/examples/hello/config/mail.yaml new file mode 100644 index 0000000..2351cd1 --- /dev/null +++ b/examples/hello/config/mail.yaml @@ -0,0 +1,9 @@ +driver: memory +from: Hello +smtp: + host: 127.0.0.1 + port: 1025 + username: "" + password: "" + tls: none + timeout: 10s diff --git a/go.mod b/go.mod index 6fe51d9..d75595d 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/testcontainers/testcontainers-go v0.44.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 + github.com/wneessen/go-mail v0.8.1 github.com/yuin/goldmark v1.8.6 golang.org/x/term v0.46.0 golang.org/x/text v0.40.0 diff --git a/go.sum b/go.sum index c37e881..81583e0 100644 --- a/go.sum +++ b/go.sum @@ -160,6 +160,8 @@ github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRU github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM= +github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w= github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= diff --git a/postcard/drivers.go b/postcard/drivers.go index 812741b..e9e90a1 100644 --- a/postcard/drivers.go +++ b/postcard/drivers.go @@ -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 +} diff --git a/postcard/mailer.go b/postcard/mailer.go index c675be9..b223ecb 100644 --- a/postcard/mailer.go +++ b/postcard/mailer.go @@ -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 { diff --git a/postcard/mailer_test.go b/postcard/mailer_test.go index fb8d28e..df481af 100644 --- a/postcard/mailer_test.go +++ b/postcard/mailer_test.go @@ -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: "

html body

", + }) + 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 +}