refactor(10.2-01): nest framework packages under modules

- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
Jakub Zych
2026-09-28 02:21:02 +02:00
parent ac1f6d14f4
commit 5e50b166ef
277 changed files with 303 additions and 303 deletions

View File

@@ -0,0 +1,14 @@
name = "Default"
==
{{ .Content }}
==
<!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" />
<style type="text/css">{{ .brandCss }}{{ .css }}</style>
</head>
<body>
<div class="content-body">{{ .Content }}</div>
</body>
</html>

303
modules/postcard/drivers.go Normal file
View File

@@ -0,0 +1,303 @@
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.
type Driver interface {
Send(ctx context.Context, msg RenderedMessage) error
}
// RenderedMessage is the validated payload a driver transmits.
type RenderedMessage struct {
To []string
Cc []string
Bcc []string
From string
ReplyTo string
Subject string
HTML string
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
messages []RenderedMessage
}
// NewMemoryDriver returns an empty in-memory driver.
func NewMemoryDriver() *MemoryDriver {
return &MemoryDriver{}
}
// Send appends msg to the in-memory log.
func (d *MemoryDriver) Send(_ context.Context, msg RenderedMessage) error {
if d == nil {
return fmt.Errorf("postcard: memory driver is nil")
}
d.mu.Lock()
defer d.mu.Unlock()
d.messages = append(d.messages, cloneRendered(msg))
return nil
}
// Messages returns a copy of stored messages in send order.
func (d *MemoryDriver) Messages() []RenderedMessage {
if d == nil {
return nil
}
d.mu.Lock()
defer d.mu.Unlock()
out := make([]RenderedMessage, len(d.messages))
for i, msg := range d.messages {
out[i] = cloneRendered(msg)
}
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
}

180
modules/postcard/mailer.go Normal file
View File

@@ -0,0 +1,180 @@
package postcard
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
)
// Message is the Send contract for plugin callers.
type Message struct {
Template string
To []string
Cc []string
Bcc []string
ReplyTo string
Vars map[string]any
Subject string
}
// Mailer sends a registered template through the configured driver.
type Mailer interface {
Send(ctx context.Context, msg Message) error
}
// Options configure an app-scoped mailer.
type Options struct {
From string
CSS string
BrandCSS string
}
type mailer struct {
catalog *Catalog
driver Driver
opts Options
}
// NewMailer returns a mailer that renders from cat and delivers through driver.
func NewMailer(cat *Catalog, driver Driver, opts Options) Mailer {
if cat == nil {
cat = NewCatalog()
}
return &mailer{catalog: cat, driver: driver, opts: opts}
}
// Send renders msg.Template and delivers it. It performs no locale selection;
// the caller must pass the full dotted name, including any -en suffix.
func (m *mailer) Send(ctx context.Context, msg Message) error {
if m == nil {
return fmt.Errorf("postcard: mailer is nil")
}
if m.driver == nil {
return fmt.Errorf("postcard: driver is nil")
}
if msg.Template == "" {
return fmt.Errorf("postcard: template is empty")
}
if len(msg.To) == 0 && len(msg.Cc) == 0 && len(msg.Bcc) == 0 {
return fmt.Errorf("postcard: no recipients")
}
rendered, err := m.catalog.render(msg, renderOptions{
css: m.opts.CSS,
brandCSS: m.opts.BrandCSS,
})
if err != nil {
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)
}
return nil
}
// Activate publishes an app-scoped Mailer before plugin Boot. Catalog files
// are validated later at each plugin's Boot transition.
func Activate[P interface{ ID() string }](app *backpack.App, _ []P) error {
if app == nil {
return fmt.Errorf("postcard: app is nil")
}
driver, opts, err := driverFromApp(app)
if err != nil {
return err
}
m := &mailer{
catalog: NewCatalog(),
driver: driver,
opts: opts,
}
if err := app.Publish[Mailer](m); err != nil {
return fmt.Errorf("postcard: %w", err)
}
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 {
hm, ok := any(p).(pact.HasMailTemplates)
if !ok {
return nil
}
got, ok := app.Lookup[Mailer]()
if !ok {
return fmt.Errorf("postcard: mailer not published")
}
m, ok := got.(*mailer)
if !ok || m == nil || m.catalog == nil {
return fmt.Errorf("postcard: mailer not published")
}
return m.catalog.Register(p.ID(), hm.MailTemplatesFS(), hm.MailTemplates(), hm.MailLayouts())
}

View File

@@ -0,0 +1,377 @@
package postcard
import (
"bytes"
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
)
func TestMailRenderSmoke(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"views/mail/hello.htm": {Data: []byte(`subject = "Witaj {{ .Name }}"
description = "Hello greeting"
layout = "default"
==
Witaj **{{ .Name }}**.
[Strona]({{ .URL }})
`)},
"views/mail/hello-en.htm": {Data: []byte(`subject = "Hello {{ .Name }}"
description = "English hello greeting"
layout = "default"
==
Hello **{{ .Name }}**.
[Site]({{ .URL }})
`)},
}
cat := NewCatalog()
err := cat.Register("golem15.hello", fsys, []string{
"golem15.hello::mail.hello",
"golem15.hello::mail.hello-en",
}, nil)
if err != nil {
t.Fatalf("Register: %v", err)
}
drv := NewMemoryDriver()
mail := NewMailer(cat, drv, Options{})
ctx := context.Background()
vars := map[string]any{"Name": "Ada", "URL": "https://example.test"}
if err := mail.Send(ctx, Message{
Template: "golem15.hello::mail.hello",
To: []string{"ada@example.test"},
Vars: vars,
}); err != nil {
t.Fatalf("Send hello: %v", err)
}
if err := mail.Send(ctx, Message{
Template: "golem15.hello::mail.hello-en",
To: []string{"ada@example.test"},
Vars: vars,
}); err != nil {
t.Fatalf("Send hello-en: %v", err)
}
got := drv.Messages()
if len(got) != 2 {
t.Fatalf("stored %d messages, want 2", len(got))
}
pl, en := got[0], got[1]
if pl.Subject != "Witaj Ada" {
t.Fatalf("default locale subject = %q", pl.Subject)
}
if en.Subject != "Hello Ada" {
t.Fatalf("-en subject = %q", en.Subject)
}
if !strings.Contains(pl.Text, "Witaj **Ada**.") || strings.Contains(pl.Text, "Hello **Ada**.") {
t.Fatalf("default locale text = %q", pl.Text)
}
if !strings.Contains(en.Text, "Hello **Ada**.") || strings.Contains(en.Text, "Witaj **Ada**.") {
t.Fatalf("-en text = %q", en.Text)
}
if !strings.Contains(pl.HTML, "<strong>Ada</strong>") || !strings.Contains(pl.HTML, `href="https://example.test"`) {
t.Fatalf("default locale HTML = %q", pl.HTML)
}
if !strings.Contains(en.HTML, "<strong>Ada</strong>") || !strings.Contains(en.HTML, `href="https://example.test"`) {
t.Fatalf("-en HTML = %q", en.HTML)
}
if !strings.Contains(pl.HTML, `class="content-body"`) || !strings.Contains(en.HTML, `class="content-body"`) {
t.Fatalf("neutral default layout missing from HTML")
}
t.Run("unsafe vars stay out of HTML", func(t *testing.T) {
drv := NewMemoryDriver()
mail := NewMailer(cat, drv, Options{})
if err := mail.Send(ctx, Message{
Template: "golem15.hello::mail.hello-en",
To: []string{"ada@example.test"},
Vars: map[string]any{
"Name": `<script>alert(1)</script>`,
"URL": "javascript:alert(1)",
},
}); err != nil {
t.Fatalf("Send: %v", err)
}
html := drv.Messages()[0].HTML
if strings.Contains(strings.ToLower(html), "<script") || strings.Contains(strings.ToLower(html), "javascript:") {
t.Fatalf("unsafe HTML leaked: %s", html)
}
})
}
func TestMailLayoutSmoke(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"views/mail/note.htm": {Data: []byte(`subject = "Hi {{ .Name }}"
layout = "hello"
==
Body **{{ .Name }}**.
`)},
"views/mail/layouts/hello.htm": {Data: []byte(`name = "Hello"
==
-- {{ .Content }} --
==
<div class="layout-hello">{{ .Content }}</div>
<style>{{ .brandCss }}{{ .css }}</style>
`)},
}
cat := NewCatalog()
if err := cat.Register("golem15.hello", fsys, []string{"golem15.hello::mail.note"}, map[string]string{
"hello": "golem15.hello::mail.layouts.hello",
}); err != nil {
t.Fatalf("Register: %v", err)
}
drv := NewMemoryDriver()
mail := NewMailer(cat, drv, Options{CSS: "p{color:red}", BrandCSS: ".brand{color:blue}"})
if err := mail.Send(context.Background(), Message{
Template: "golem15.hello::mail.note",
To: []string{"ada@example.test"},
Vars: map[string]any{"Name": "Ada"},
}); err != nil {
t.Fatalf("Send: %v", err)
}
got := drv.Messages()
if len(got) != 1 {
t.Fatalf("stored %d messages", len(got))
}
if !strings.Contains(got[0].Text, "-- Body **Ada**. --") {
t.Fatalf("text wrapper = %q", got[0].Text)
}
if !strings.Contains(got[0].HTML, `class="layout-hello"`) || !strings.Contains(got[0].HTML, "<strong>Ada</strong>") {
t.Fatalf("HTML wrapper = %q", got[0].HTML)
}
if !strings.Contains(got[0].HTML, "p{color:red}") || !strings.Contains(got[0].HTML, ".brand{color:blue}") {
t.Fatalf("shared css missing: %q", got[0].HTML)
}
t.Run("missing template fails with name", func(t *testing.T) {
err := NewCatalog().Register("golem15.hello", fsys, []string{"golem15.hello::mail.missing"}, nil)
if err == nil || !strings.Contains(err.Error(), "golem15.hello::mail.missing") {
t.Fatalf("missing template error = %v", err)
}
})
t.Run("unknown layout fails with name", func(t *testing.T) {
bad := fstest.MapFS{
"views/mail/note.htm": {Data: []byte("subject = \"Hi\"\nlayout = \"plytarium\"\n==\nHi.\n")},
}
err := NewCatalog().Register("golem15.hello", bad, []string{"golem15.hello::mail.note"}, nil)
if err == nil || !strings.Contains(err.Error(), "plytarium") {
t.Fatalf("unknown layout error = %v", err)
}
})
t.Run("missing layout file fails with full name", func(t *testing.T) {
err := NewCatalog().Register("golem15.hello", fsys, nil, map[string]string{
"hello": "golem15.hello::mail.layouts.missing",
})
if err == nil || !strings.Contains(err.Error(), "golem15.hello::mail.layouts.missing") {
t.Fatalf("missing layout error = %v", err)
}
})
}
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
}

View File

@@ -0,0 +1,178 @@
package postcard
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
const mailpitImage = "axllent/mailpit:v1.31.1"
type mailpitList struct {
Total int `json:"total"`
Count int `json:"count"`
Messages []struct {
ID string `json:"ID"`
Subject string `json:"Subject"`
} `json:"messages"`
}
type mailpitMessage struct {
ID string `json:"ID"`
Subject string `json:"Subject"`
HTML string `json:"HTML"`
Text string `json:"Text"`
To []struct {
Address string `json:"Address"`
} `json:"To"`
}
func TestSMTPMailpit(t *testing.T) {
if testing.Short() {
t.Skip("requires testcontainers mailpit")
}
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
defer cancel()
ctr, err := testcontainers.Run(ctx, mailpitImage,
testcontainers.WithExposedPorts("1025/tcp", "8025/tcp"),
testcontainers.WithWaitStrategy(
wait.ForListeningPort("1025/tcp"),
wait.ForHTTP("/api/v1/info").WithPort("8025/tcp"),
),
)
if err != nil {
t.Fatalf("mailpit: testcontainers: %v", err)
}
t.Cleanup(func() {
_ = testcontainers.TerminateContainer(ctr)
})
host, err := ctr.Host(ctx)
if err != nil {
t.Fatal(err)
}
smtpPort, err := ctr.MappedPort(ctx, "1025/tcp")
if err != nil {
t.Fatal(err)
}
httpPort, err := ctr.MappedPort(ctx, "8025/tcp")
if err != nil {
t.Fatal(err)
}
portNum, err := strconv.Atoi(smtpPort.Port())
if err != nil {
t.Fatal(err)
}
api := "http://" + host + ":" + httpPort.Port()
driver, err := NewSMTPDriver(SMTPConfig{
Host: host,
Port: portNum,
TLS: "none",
Timeout: 10 * time.Second,
})
if err != nil {
t.Fatal(err)
}
mail := NewMailer(smokeCatalog(t), driver, Options{From: "from@example.test"})
if err := mail.Send(ctx, Message{
Template: "golem15.hello::mail.hello",
To: []string{"ada@example.test"},
Vars: map[string]any{"Name": "Ada", "URL": "https://example.test"},
}); err != nil {
t.Fatalf("Send: %v", err)
}
got := waitMailpitMessage(t, ctx, api)
if len(got.To) == 0 || got.To[0].Address != "ada@example.test" {
t.Fatalf("mailpit To = %+v", got.To)
}
if got.Subject != "Witaj Ada" {
t.Fatalf("mailpit subject = %q", got.Subject)
}
if !strings.Contains(got.HTML, "<strong>Ada</strong>") {
t.Fatalf("mailpit HTML = %q", got.HTML)
}
if !strings.Contains(got.Text, "Witaj **Ada**.") {
t.Fatalf("mailpit text = %q", got.Text)
}
}
func waitMailpitMessage(t *testing.T, ctx context.Context, api string) mailpitMessage {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
var last string
for time.Now().Before(deadline) {
if err := ctx.Err(); err != nil {
t.Fatal(err)
}
list, err := fetchMailpitList(ctx, api)
if err == nil && list.Total > 0 && len(list.Messages) > 0 {
msg, err := fetchMailpitMessage(ctx, api, list.Messages[0].ID)
if err == nil && msg.ID != "" {
return msg
}
last = fmt.Sprintf("message: %v", err)
} else if err != nil {
last = err.Error()
}
time.Sleep(150 * time.Millisecond)
}
t.Fatalf("mailpit did not receive a message: %s", last)
return mailpitMessage{}
}
func fetchMailpitList(ctx context.Context, api string) (mailpitList, error) {
var out mailpitList
body, err := getMailpit(ctx, api+"/api/v1/messages")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, err
}
return out, nil
}
func fetchMailpitMessage(ctx context.Context, api, id string) (mailpitMessage, error) {
var out mailpitMessage
body, err := getMailpit(ctx, api+"/api/v1/message/"+id)
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, err
}
return out, nil
}
func getMailpit(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", resp.Status, body)
}
return body, nil
}

View File

@@ -0,0 +1,65 @@
package postcard
import (
"context"
"strings"
"testing"
"time"
)
func TestSMTPTLSPolicy(t *testing.T) {
t.Parallel()
for _, tls := range []string{"", "mandatory", "tls", "none", "notls", "starttls", "opportunistic"} {
_, err := NewSMTPDriver(SMTPConfig{Host: "127.0.0.1", Port: 1025, TLS: tls, Timeout: time.Second})
if err != nil {
t.Fatalf("tls %q: %v", tls, 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)
}
_, err = NewSMTPDriver(SMTPConfig{Host: " ", TLS: "mandatory"})
if err == nil || !strings.Contains(err.Error(), "mail.smtp.host") {
t.Fatalf("blank host error = %v", err)
}
}
func TestSMTPErrorsOmitCredentialsAndBodies(t *testing.T) {
cat := smokeCatalog(t)
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(context.Background(), Message{
Template: "golem15.hello::mail.hello",
To: []string{"ada@example.test"},
Vars: map[string]any{"Name": "Ada", "URL": "https://example.test"},
})
if err == nil {
t.Fatal("expected smtp failure")
}
msg := err.Error()
if !strings.Contains(msg, "smtp") {
t.Fatalf("error %q should mention smtp", msg)
}
for _, leaked := range []string{"secretpass", "Witaj **Ada**.", "text body"} {
if strings.Contains(msg, leaked) {
t.Fatalf("smtp error leaked %q: %v", leaked, err)
}
}
}

View File

@@ -0,0 +1,434 @@
package postcard
import (
"bytes"
_ "embed"
"fmt"
"html/template"
"io/fs"
"regexp"
"strings"
"sync"
texttemplate "text/template"
"github.com/yuin/goldmark"
)
const (
defaultLayoutAlias = "default"
defaultLayoutName = "postcard::mail.layouts.default"
mailPrefix = "mail."
assetRoot = "views/mail/"
)
var (
dangerousScheme = regexp.MustCompile(`(?i)(?:javascript|vbscript|data):`)
eventHandler = regexp.MustCompile(`(?i)\son[a-z]+\s*=`)
rawUnsafeTag = regexp.MustCompile(`(?i)<(?:script|iframe|object|embed)\b`)
markdown = goldmark.New()
)
//go:embed assets/default.htm
var defaultLayoutSource string
// Catalog holds registered mail templates and layouts.
type Catalog struct {
mu sync.RWMutex
templates map[string]*parsedTemplate
layouts map[string]*parsedLayout
aliases map[string]map[string]string // pluginID -> short name -> full name
}
type parsedTemplate struct {
name string
pluginID string
subject string
description string
layout string // short name from header; empty means default
body string
}
type parsedLayout struct {
name string
pluginID string
headerName string
textWrap string
htmlWrap string
}
// NewCatalog returns an empty catalog that already contains postcard's
// neutral default layout.
func NewCatalog() *Catalog {
c := &Catalog{
templates: make(map[string]*parsedTemplate),
layouts: make(map[string]*parsedLayout),
aliases: make(map[string]map[string]string),
}
layout, err := parseLayout(defaultLayoutName, "postcard", defaultLayoutSource)
if err != nil {
panic("postcard: default layout: " + err.Error())
}
c.layouts[defaultLayoutName] = layout
return c
}
// Register loads declared dotted template and layout names from fsys.
// Each name must be owned by pluginID and map to views/mail/<name>.htm.
func (c *Catalog) Register(pluginID string, fsys fs.FS, templates []string, layouts map[string]string) error {
if c == nil {
return fmt.Errorf("postcard: catalog is nil")
}
if pluginID == "" {
return fmt.Errorf("postcard: plugin id is empty")
}
c.mu.Lock()
defer c.mu.Unlock()
alias := make(map[string]string, len(layouts))
for short, full := range layouts {
if short == "" || full == "" {
return fmt.Errorf("postcard: empty layout alias for %s", pluginID)
}
if short == defaultLayoutAlias {
return fmt.Errorf("postcard: layout alias %q is reserved", short)
}
if err := assertOwner(pluginID, full); err != nil {
return err
}
if _, exists := c.layouts[full]; exists {
return fmt.Errorf("postcard: duplicate layout %s", full)
}
path, err := assetPath(full)
if err != nil {
return err
}
raw, err := fs.ReadFile(fsys, path)
if err != nil {
return fmt.Errorf("postcard: missing layout %s", full)
}
parsed, err := parseLayout(full, pluginID, string(raw))
if err != nil {
return err
}
c.layouts[full] = parsed
alias[short] = full
}
if len(alias) > 0 {
if c.aliases[pluginID] == nil {
c.aliases[pluginID] = make(map[string]string, len(alias))
}
for short, full := range alias {
if _, exists := c.aliases[pluginID][short]; exists {
return fmt.Errorf("postcard: duplicate layout alias %s for %s", short, pluginID)
}
c.aliases[pluginID][short] = full
}
}
for _, name := range templates {
if err := assertOwner(pluginID, name); err != nil {
return err
}
if _, exists := c.templates[name]; exists {
return fmt.Errorf("postcard: duplicate template %s", name)
}
path, err := assetPath(name)
if err != nil {
return err
}
raw, err := fs.ReadFile(fsys, path)
if err != nil {
return fmt.Errorf("postcard: missing template %s", name)
}
parsed, err := parseTemplateFile(name, pluginID, string(raw))
if err != nil {
return err
}
if err := c.resolveLayoutLocked(parsed); err != nil {
return err
}
c.templates[name] = parsed
}
return nil
}
func (c *Catalog) resolveLayoutLocked(t *parsedTemplate) error {
short := strings.TrimSpace(t.layout)
if short == "" || short == defaultLayoutAlias {
t.layout = defaultLayoutAlias
return nil
}
full, ok := c.aliases[t.pluginID][short]
if !ok {
return fmt.Errorf("postcard: unknown layout %s", short)
}
if _, ok := c.layouts[full]; !ok {
return fmt.Errorf("postcard: missing layout %s", full)
}
t.layout = full
return nil
}
func (c *Catalog) lookupTemplate(name string) (*parsedTemplate, error) {
c.mu.RLock()
defer c.mu.RUnlock()
t, ok := c.templates[name]
if !ok {
return nil, fmt.Errorf("postcard: unknown template %s", name)
}
return t, nil
}
func (c *Catalog) lookupLayout(name string) (*parsedLayout, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if name == "" || name == defaultLayoutAlias {
name = defaultLayoutName
}
l, ok := c.layouts[name]
if !ok {
return nil, fmt.Errorf("postcard: missing layout %s", name)
}
return l, nil
}
func assertOwner(pluginID, fullName string) error {
prefix := pluginID + "::"
if !strings.HasPrefix(fullName, prefix) {
return fmt.Errorf("postcard: template %s is not owned by %s", fullName, pluginID)
}
return nil
}
func assetPath(fullName string) (string, error) {
_, rest, ok := strings.Cut(fullName, "::")
if !ok {
return "", fmt.Errorf("postcard: invalid mail name %s", fullName)
}
after, ok := strings.CutPrefix(rest, mailPrefix)
if !ok || after == "" {
return "", fmt.Errorf("postcard: invalid mail name %s", fullName)
}
if strings.Contains(after, "..") || strings.ContainsAny(after, `/\`) {
return "", fmt.Errorf("postcard: invalid mail name %s", fullName)
}
return assetRoot + strings.ReplaceAll(after, ".", "/") + ".htm", nil
}
func parseTemplateFile(name, pluginID, src string) (*parsedTemplate, error) {
header, body, err := splitTwo(src)
if err != nil {
return nil, fmt.Errorf("postcard: parse %s: %w", name, err)
}
fields, err := parseINI(header)
if err != nil {
return nil, fmt.Errorf("postcard: parse %s: %w", name, err)
}
return &parsedTemplate{
name: name,
pluginID: pluginID,
subject: fields["subject"],
description: fields["description"],
layout: fields["layout"],
body: strings.TrimSuffix(body, "\n"),
}, nil
}
func parseLayout(name, pluginID, src string) (*parsedLayout, error) {
header, textWrap, htmlWrap, err := splitThree(src)
if err != nil {
return nil, fmt.Errorf("postcard: parse %s: %w", name, err)
}
fields, err := parseINI(header)
if err != nil {
return nil, fmt.Errorf("postcard: parse %s: %w", name, err)
}
return &parsedLayout{
name: name,
pluginID: pluginID,
headerName: fields["name"],
textWrap: strings.TrimSuffix(textWrap, "\n"),
htmlWrap: strings.TrimSuffix(htmlWrap, "\n"),
}, nil
}
func splitTwo(src string) (header, body string, err error) {
parts, err := splitEQ(src, 2)
if err != nil {
return "", "", err
}
return parts[0], parts[1], nil
}
func splitThree(src string) (header, textWrap, htmlWrap string, err error) {
parts, err := splitEQ(src, 3)
if err != nil {
return "", "", "", err
}
return parts[0], parts[1], parts[2], nil
}
func splitEQ(src string, n int) ([]string, error) {
src = strings.ReplaceAll(src, "\r\n", "\n")
lines := strings.Split(src, "\n")
parts := make([]string, 0, n)
start := 0
for i, line := range lines {
if strings.TrimSpace(line) != "==" {
continue
}
parts = append(parts, strings.Join(lines[start:i], "\n"))
start = i + 1
if len(parts) == n-1 {
parts = append(parts, strings.Join(lines[start:], "\n"))
return parts, nil
}
}
return nil, fmt.Errorf("missing == separator")
}
func parseINI(header string) (map[string]string, error) {
out := make(map[string]string)
for _, line := range strings.Split(header, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("invalid header line %q", line)
}
key = strings.ToLower(strings.TrimSpace(key))
val = strings.TrimSpace(val)
if len(val) >= 2 {
if q := val[0]; (q == '"' || q == '\'') && val[len(val)-1] == q {
val = val[1 : len(val)-1]
}
}
if key == "" {
return nil, fmt.Errorf("invalid header line %q", line)
}
out[key] = val
}
return out, nil
}
type renderOptions struct {
css string
brandCSS string
}
func (c *Catalog) render(msg Message, opts renderOptions) (RenderedMessage, error) {
var out RenderedMessage
t, err := c.lookupTemplate(msg.Template)
if err != nil {
return out, err
}
vars := msg.Vars
if vars == nil {
vars = map[string]any{}
}
subjectSrc := t.subject
if strings.TrimSpace(msg.Subject) != "" {
subjectSrc = msg.Subject
}
subject, err := execHTML(t.name+".subject", subjectSrc, vars)
if err != nil {
return out, err
}
if strings.ContainsAny(subject, "\r\n") {
return out, fmt.Errorf("postcard: subject contains CR/LF")
}
markdownBody, err := execHTML(t.name+".body", t.body, vars)
if err != nil {
return out, err
}
htmlBody, err := markdownHTML(markdownBody)
if err != nil {
return out, err
}
if err := validateHTML(htmlBody); err != nil {
return out, err
}
layout, err := c.lookupLayout(t.layout)
if err != nil {
return out, err
}
text, err := execText(layout.name+".text", layout.textWrap, map[string]any{
"Content": markdownBody,
"Subject": subject,
"css": opts.css,
"brandCss": opts.brandCSS,
})
if err != nil {
return out, err
}
html, err := execHTML(layout.name+".html", layout.htmlWrap, map[string]any{
"Content": template.HTML(htmlBody),
"Subject": subject,
"css": template.CSS(opts.css),
"brandCss": template.CSS(opts.brandCSS),
})
if err != nil {
return out, err
}
if err := validateHTML(html); err != nil {
return out, err
}
out = RenderedMessage{
To: append([]string(nil), msg.To...),
Cc: append([]string(nil), msg.Cc...),
Bcc: append([]string(nil), msg.Bcc...),
ReplyTo: msg.ReplyTo,
Subject: subject,
HTML: html,
Text: text,
}
return out, nil
}
func markdownHTML(src string) (string, error) {
var buf bytes.Buffer
if err := markdown.Convert([]byte(src), &buf); err != nil {
return "", fmt.Errorf("postcard: markdown: %w", err)
}
return buf.String(), nil
}
func validateHTML(html string) error {
if rawUnsafeTag.MatchString(html) {
return fmt.Errorf("postcard: rendered HTML contains raw unsafe tags")
}
if eventHandler.MatchString(html) {
return fmt.Errorf("postcard: rendered HTML contains event handlers")
}
if dangerousScheme.MatchString(html) {
return fmt.Errorf("postcard: rendered HTML contains a dangerous URL scheme")
}
return nil
}
func execHTML(name, src string, data any) (string, error) {
tmpl, err := template.New(name).Option("missingkey=zero").Parse(src)
if err != nil {
return "", fmt.Errorf("postcard: parse %s: %w", name, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("postcard: execute %s: %w", name, err)
}
return buf.String(), nil
}
func execText(name, src string, data any) (string, error) {
tmpl, err := texttemplate.New(name).Option("missingkey=zero").Parse(src)
if err != nil {
return "", fmt.Errorf("postcard: parse %s: %w", name, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("postcard: execute %s: %w", name, err)
}
return buf.String(), nil
}

View File

@@ -0,0 +1,128 @@
package postcard
import (
"context"
"strings"
"testing"
"testing/fstest"
)
func TestMailRecipientsAndSafety(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"views/mail/hello.htm": {Data: []byte(`subject = "Witaj {{ .Name }}"
layout = "default"
==
Witaj **{{ .Name }}**.
[Strona]({{ .URL }})
`)},
}
cat := NewCatalog()
if err := cat.Register("golem15.hello", fsys, []string{"golem15.hello::mail.hello"}, nil); err != nil {
t.Fatal(err)
}
drv := NewMemoryDriver()
mail := NewMailer(cat, drv, Options{From: "from@example.test"})
ctx := context.Background()
if err := mail.Send(ctx, 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"},
}); err != nil {
t.Fatal(err)
}
got := drv.Messages()
if len(got) != 1 {
t.Fatalf("stored %d messages", len(got))
}
msg := got[0]
if len(msg.To) != 1 || msg.To[0] != "ada@example.test" {
t.Fatalf("To = %v", msg.To)
}
if len(msg.Cc) != 1 || msg.Cc[0] != "cc@example.test" {
t.Fatalf("Cc = %v", msg.Cc)
}
if len(msg.Bcc) != 1 || msg.Bcc[0] != "bcc@example.test" {
t.Fatalf("Bcc = %v", msg.Bcc)
}
if msg.ReplyTo != "reply@example.test" {
t.Fatalf("ReplyTo = %q", msg.ReplyTo)
}
if msg.Subject != "Witaj Ada" {
t.Fatalf("subject = %q", msg.Subject)
}
if !strings.Contains(msg.Text, "Witaj **Ada**.") {
t.Fatalf("text = %q", msg.Text)
}
if !strings.Contains(msg.HTML, "<strong>Ada</strong>") || !strings.Contains(msg.HTML, `href="https://example.test"`) {
t.Fatalf("html = %q", msg.HTML)
}
t.Run("raw tags markdown links and dangerous urls stay out of html", func(t *testing.T) {
cases := []map[string]any{
{"Name": `<script>alert(1)</script>`, "URL": "https://example.test"},
{"Name": "Ada", "URL": "javascript:alert(1)"},
{"Name": "Ada", "URL": "vbscript:msgbox(1)"},
{"Name": "Ada", "URL": "data:text/html;base64,PGh0bWw+"},
{"Name": `<iframe src="https://evil.test"></iframe>`, "URL": "https://example.test"},
}
for _, vars := range cases {
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"},
Vars: vars,
})
if err != nil {
if strings.Contains(err.Error(), "unsafe") || strings.Contains(err.Error(), "dangerous") {
continue
}
t.Fatalf("unexpected send error for %v: %v", vars, err)
}
html := strings.ToLower(drv.Messages()[0].HTML)
for _, banned := range []string{"<script", "<iframe", "javascript:", "vbscript:", "data:text/html"} {
if strings.Contains(html, banned) {
t.Fatalf("unsafe %q leaked for %v:\n%s", banned, vars, html)
}
}
}
})
}
func TestMailRegistrationBoundaries(t *testing.T) {
t.Parallel()
t.Run("reserved default alias", func(t *testing.T) {
err := NewCatalog().Register("golem15.hello", fstest.MapFS{}, nil, map[string]string{
"default": "golem15.hello::mail.layouts.default",
})
if err == nil || !strings.Contains(err.Error(), "reserved") {
t.Fatalf("reserved alias error = %v", err)
}
})
t.Run("template not owned by plugin", func(t *testing.T) {
err := NewCatalog().Register("golem15.hello", fstest.MapFS{}, []string{"golem15.other::mail.hello"}, nil)
if err == nil || !strings.Contains(err.Error(), "golem15.hello") || !strings.Contains(err.Error(), "golem15.other::mail.hello") {
t.Fatalf("ownership error = %v", err)
}
})
t.Run("invalid dotted name", func(t *testing.T) {
err := NewCatalog().Register("golem15.hello", fstest.MapFS{}, []string{"golem15.hello::mail.hello/../x"}, nil)
if err == nil || !strings.Contains(err.Error(), "invalid mail name") {
t.Fatalf("invalid name error = %v", err)
}
})
t.Run("missing template names plugin and dotted name", func(t *testing.T) {
err := NewCatalog().Register("golem15.hello", fstest.MapFS{}, []string{"golem15.hello::mail.missing"}, nil)
if err == nil || !strings.Contains(err.Error(), "golem15.hello::mail.missing") {
t.Fatalf("missing template error = %v", err)
}
})
}