Files
galaxy-game/integration/testenv/mailpit.go
T
2026-05-06 10:14:55 +03:00

198 lines
5.3 KiB
Go

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
}