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{"