Files
Gabriel Radureau b3027d2669
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 11s
CI/CD Pipeline / CI Pipeline (push) Successful in 5m23s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 5s
feat(bdd): pkg/bdd/mailpit/ HTTP client + integration tests (ADR-0030 Phase A.2) (#60)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 10:51:33 +02:00

181 lines
5.9 KiB
Go

// 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:<addr> 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:<addr> 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
}