Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
181 lines
5.9 KiB
Go
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
|
|
}
|