198 lines
5.3 KiB
Go
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
|
|
}
|