✨ 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>
This commit was merged in pull request #60.
This commit is contained in:
180
pkg/bdd/mailpit/client.go
Normal file
180
pkg/bdd/mailpit/client.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user