test(04-04): close mail safety and Mailpit SMTP receipt coverage

- Assert memory recipients, HTML safety, and SMTP TLS without credential leaks
- Prove real SMTP delivery through Mailpit HTTP API when Docker is available
- Add scripts/check-phase4.sh as the phase vet, test, race, and SMTP gate

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-18 14:12:28 +02:00
parent 918e941e69
commit 9f6fbadce8
5 changed files with 414 additions and 0 deletions

View File

@@ -179,6 +179,13 @@ func TestHelloPluginSendsMail(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
err = mail.Send(t.Context(), postcard.Message{
Template: "golem15.hello::mail.does-not-exist",
To: []string{"ada@example.test"},
})
if err == nil || !strings.Contains(err.Error(), "golem15.hello::mail.does-not-exist") {
t.Fatalf("unknown template error = %v", err)
}
} }
func TestTypedItemRoute(t *testing.T) { func TestTypedItemRoute(t *testing.T) {

178
postcard/mailpit_test.go Normal file
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
}

65
postcard/smtp_test.go Normal file
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)
}
}
}

128
postcard/templates_test.go Normal file
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)
}
})
}

36
scripts/check-phase4.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Repeatable Phase 4 verification: root vet/test including Mailpit SMTP
# receipt, nested hello app vet/test, and a focused race run.
# Docker is required; unavailable Docker is a failed gate, never a skip.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if ! command -v docker >/dev/null 2>&1; then
echo "refuse: docker is required for Phase 4 Mailpit" >&2
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "refuse: docker daemon is not available" >&2
exit 1
fi
echo "==> docker is available"
echo "==> root go vet"
go vet ./...
echo "==> root go test (includes Mailpit SMTP receipt)"
go test ./...
echo "==> hello go vet"
go -C examples/hello vet ./...
echo "==> hello go test"
go -C examples/hello test ./...
echo "==> race internal/build phrasebook postcard"
go test -race ./internal/build ./phrasebook ./postcard
echo "phase4 check passed"