// Package mailpit is a thin client for the local Mailpit HTTP API, // used by BDD scenarios to assert on emails sent during a test. // // Per ADR-0030 (BDD email parallel strategy), each scenario uses a // unique recipient address so parallel scenarios cannot interfere. // The client exposes per-recipient query + delete + await operations. // // Production code MUST NOT depend on this package. It lives under // pkg/bdd/ specifically to signal "test-only". package mailpit import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" ) // DefaultBaseURL is the local Mailpit HTTP API root used by the // docker-compose service (cf. ADR-0029). const DefaultBaseURL = "http://localhost:8025" // Client is a Mailpit HTTP API client. Safe for concurrent use. type Client struct { BaseURL string HTTP *http.Client } // NewClient returns a Client pointing at the local Mailpit. The HTTP // client has a 5-second per-call timeout to fail fast in test setups // where Mailpit is down. func NewClient() *Client { return &Client{ BaseURL: DefaultBaseURL, HTTP: &http.Client{Timeout: 5 * time.Second}, } } // Message is the metadata + body view returned by the Mailpit detail // endpoint. Fields are a subset of what Mailpit returns — only what // BDD scenarios need to assert on. type Message struct { ID string `json:"ID"` From Address `json:"From"` To []Address `json:"To"` Subject string `json:"Subject"` Text string `json:"Text"` HTML string `json:"HTML"` Date time.Time `json:"Date"` Headers map[string]interface{} `json:"-"` // populated only via the Headers() helper } // Address is a Mailpit-formatted email address. type Address struct { Name string `json:"Name"` Address string `json:"Address"` } // listResponse is the shape of GET /api/v1/messages. type listResponse struct { Messages []messageSummary `json:"messages"` Total int `json:"total"` } type messageSummary struct { ID string `json:"ID"` Subject string `json:"Subject"` Created time.Time `json:"Created"` } // MessagesTo returns the list of message IDs currently in Mailpit // addressed to the given recipient. Empty slice + nil error means // "no messages yet". func (c *Client) MessagesTo(ctx context.Context, to string) ([]string, error) { // Mailpit's /api/v1/search supports the to: filter ; the more // obvious-looking /api/v1/messages does NOT (the `query` param there // is for pagination, not filtering — verified empirically 2026-05-05). u := fmt.Sprintf("%s/api/v1/search?query=%s", strings.TrimRight(c.BaseURL, "/"), url.QueryEscape("to:"+to)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("mailpit list: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("mailpit list: HTTP %d", resp.StatusCode) } var list listResponse if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { return nil, fmt.Errorf("mailpit list decode: %w", err) } ids := make([]string, 0, len(list.Messages)) for _, m := range list.Messages { ids = append(ids, m.ID) } return ids, nil } // Get fetches the full content of the message with the given ID. func (c *Client) Get(ctx context.Context, id string) (*Message, error) { u := fmt.Sprintf("%s/api/v1/message/%s", strings.TrimRight(c.BaseURL, "/"), url.PathEscape(id)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("mailpit get: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("mailpit get %s: HTTP %d", id, resp.StatusCode) } var m Message if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { return nil, fmt.Errorf("mailpit get decode: %w", err) } return &m, nil } // AwaitMessageTo polls Mailpit for a message addressed to the given // recipient. Returns the most recent matching message ; errors out if // the timeout elapses with no match. Polls every 50ms — Mailpit is // fast enough that this is rarely the limiting factor. // // Use this in BDD steps "Then I should receive an email ...". func (c *Client) AwaitMessageTo(ctx context.Context, to string, timeout time.Duration) (*Message, error) { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { ids, err := c.MessagesTo(ctx, to) if err == nil && len(ids) > 0 { // Most recent first per Mailpit's default sort return c.Get(ctx, ids[0]) } select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(50 * time.Millisecond): } } return nil, fmt.Errorf("mailpit: no message for %s within %s", to, timeout) } // PurgeMessagesTo deletes every message addressed to the given recipient. // Idempotent: calling against an empty inbox is fine. // // Use this at the start of a BDD scenario to clear leftovers from // prior runs of the same scenario (rare given the random suffix per // scenario, but defensive). func (c *Client) PurgeMessagesTo(ctx context.Context, to string) error { // Mailpit's /api/v1/search supports the to: filter ; the more // obvious-looking /api/v1/messages does NOT (the `query` param there // is for pagination, not filtering — verified empirically 2026-05-05). u := fmt.Sprintf("%s/api/v1/search?query=%s", strings.TrimRight(c.BaseURL, "/"), url.QueryEscape("to:"+to)) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil) if err != nil { return err } resp, err := c.HTTP.Do(req) if err != nil { return fmt.Errorf("mailpit delete: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { return fmt.Errorf("mailpit delete: HTTP %d", resp.StatusCode) } return nil }