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 }