package testenv import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "testing" "time" "github.com/testcontainers/testcontainers-go" tcnetwork "github.com/testcontainers/testcontainers-go/network" "github.com/testcontainers/testcontainers-go/wait" ) // Mailpit holds an axllent/mailpit testcontainer that captures // outbound SMTP from backend. The HTTP API is exposed for mail // inspection from tests. type Mailpit struct { container testcontainers.Container SMTPHost string SMTPPort int APIBase string } // StartMailpit starts an axllent/mailpit container attached to network. func StartMailpit(t *testing.T, network string) *Mailpit { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() req := testcontainers.ContainerRequest{ Image: "axllent/mailpit:latest", ExposedPorts: []string{"1025/tcp", "8025/tcp"}, WaitingFor: wait.ForHTTP("/api/v1/info").WithPort("8025/tcp"), } gcr := &testcontainers.GenericContainerRequest{ContainerRequest: req} if network != "" { netOpt := tcnetwork.WithNetwork([]string{"mailpit"}, &testcontainers.DockerNetwork{Name: network}) _ = netOpt.Customize(gcr) } gcr.Started = true container, err := testcontainers.GenericContainer(ctx, *gcr) if err != nil { t.Skipf("mailpit container unavailable: %v", err) } t.Cleanup(func() { if err := testcontainers.TerminateContainer(container); err != nil { t.Logf("terminate mailpit: %v", err) } }) host, err := container.Host(ctx) if err != nil { t.Fatalf("mailpit host: %v", err) } smtpPort, err := container.MappedPort(ctx, "1025/tcp") if err != nil { t.Fatalf("mailpit smtp port: %v", err) } apiPort, err := container.MappedPort(ctx, "8025/tcp") if err != nil { t.Fatalf("mailpit api port: %v", err) } return &Mailpit{ container: container, SMTPHost: host, SMTPPort: int(smtpPort.Num()), APIBase: fmt.Sprintf("http://%s:%d", host, apiPort.Num()), } } // Message is a single mailpit message summary. type Message struct { ID string `json:"ID"` From MessageAddress `json:"From"` To []MessageAddress `json:"To"` Subject string `json:"Subject"` Snippet string `json:"Snippet"` } // MessageAddress is one address in From/To. type MessageAddress struct { Address string `json:"Address"` Name string `json:"Name"` } type messagesResponse struct { Messages []Message `json:"messages"` Total int `json:"total"` } // MessageBody fetches the rendered body (text) of message id. func (m *Mailpit) MessageBody(ctx context.Context, id string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.APIBase+"/api/v1/message/"+url.PathEscape(id), nil) if err != nil { return "", err } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("mailpit message %s: status %d", id, resp.StatusCode) } var body struct { Text string `json:"Text"` HTML string `json:"HTML"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return "", err } if body.Text != "" { return body.Text, nil } return body.HTML, nil } // Search returns messages matching the mailpit search expression. See // https://mailpit.axllent.org/docs/usage/search-filters/. func (m *Mailpit) Search(ctx context.Context, query string) ([]Message, error) { u := m.APIBase + "/api/v1/search?query=" + url.QueryEscape(query) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("mailpit search: status %d: %s", resp.StatusCode, string(body)) } var out messagesResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, err } return out.Messages, nil } // WaitForMessage polls Search until a message matching query is seen // or the deadline elapses. func (m *Mailpit) WaitForMessage(ctx context.Context, query string, timeout time.Duration) (Message, error) { deadline := time.Now().Add(timeout) for { msgs, err := m.Search(ctx, query) if err == nil && len(msgs) > 0 { return msgs[0], nil } if time.Now().After(deadline) { if err == nil { err = fmt.Errorf("no messages match %q", query) } return Message{}, err } select { case <-ctx.Done(): return Message{}, ctx.Err() case <-time.After(200 * time.Millisecond): } } } // DeleteAll clears the mailpit inbox. Useful between phases of a test. func (m *Mailpit) DeleteAll(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, m.APIBase+"/api/v1/messages", nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode/100 != 2 { return fmt.Errorf("mailpit delete: status %d", resp.StatusCode) } return nil } // ContainsLine reports whether body contains a line that begins with // prefix; helpful for extracting login codes from the text body. func ContainsLine(body, prefix string) bool { for _, line := range strings.Split(body, "\n") { if strings.HasPrefix(strings.TrimSpace(line), prefix) { return true } } return false }