From 9f6fbadce8730851ec2a39326a3acf079764f2b7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 18 Sep 2026 14:12:28 +0200 Subject: [PATCH] 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 --- examples/hello/hello_test.go | 7 ++ postcard/mailpit_test.go | 178 +++++++++++++++++++++++++++++++++++ postcard/smtp_test.go | 65 +++++++++++++ postcard/templates_test.go | 128 +++++++++++++++++++++++++ scripts/check-phase4.sh | 36 +++++++ 5 files changed, 414 insertions(+) create mode 100644 postcard/mailpit_test.go create mode 100644 postcard/smtp_test.go create mode 100644 postcard/templates_test.go create mode 100755 scripts/check-phase4.sh diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index 1302ea9..db91692 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -179,6 +179,13 @@ func TestHelloPluginSendsMail(t *testing.T) { }); err != nil { 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) { diff --git a/postcard/mailpit_test.go b/postcard/mailpit_test.go new file mode 100644 index 0000000..c857448 --- /dev/null +++ b/postcard/mailpit_test.go @@ -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, "Ada") { + 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 +} diff --git a/postcard/smtp_test.go b/postcard/smtp_test.go new file mode 100644 index 0000000..105630d --- /dev/null +++ b/postcard/smtp_test.go @@ -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) + } + } +} diff --git a/postcard/templates_test.go b/postcard/templates_test.go new file mode 100644 index 0000000..1732d4c --- /dev/null +++ b/postcard/templates_test.go @@ -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, "Ada") || !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": ``, "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": ``, "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{"/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"