✨ feat(email): pkg/email + Mailpit docker-compose service (ADR-0029 Phase A.1)
Foundation for the passwordless auth migration (ADR-0028 Phase A) and the BDD email-parallel strategy (ADR-0030). This PR ships only the infrastructure — no auth code yet ; that lands in subsequent PRs. Changes: - docker-compose.yml: add mailpit service (axllent/mailpit:latest), SMTP on :1025, HTTP UI/API on :8025, MP_MAX_MESSAGES=5000 - pkg/email/sender.go: provider-agnostic Sender interface + Message struct - pkg/email/smtp_sender.go: SMTPSender implementation (net/smtp), with Mailpit-friendly defaults (localhost:1025, no TLS, no AUTH), context- aware Send with timeout, supports plain text and multipart/alternative - pkg/config: AuthConfig.Email field + EmailConfig struct + GetEmailConfig getter + 7 new env vars (DLC_AUTH_EMAIL_*) + defaults - documentation/EMAIL.md: setup, inspection (UI + API), code examples, cross-refs to ADR-0028/0029/0030 Tests (pkg/email/smtp_sender_test.go): - validateMessage rejects missing fields, accepts minimal - buildRFC5322 plain-text path produces single-part text/plain with expected headers - buildRFC5322 multipart path produces multipart/alternative with both parts and a closing boundary - buildRFC5322 custom headers are canonicalised (lowercase keys → Title-Case) - NewSMTPSender defaults are Mailpit-friendly - Send respects context cancellation (no 10s wait when ctx cancelled) Race detector clean. Build clean. Vet clean. Out of scope for this PR (Phase A.2+): - BDD email-steps helper package (pkg/bdd/mailpit/, pkg/bdd/steps/email_steps.go) - magic_link_tokens table + repository - magic-link/request and magic-link/consume HTTP handlers - BDD scenarios for the magic-link flow
This commit is contained in:
@@ -19,6 +19,23 @@ services:
|
|||||||
- dance-lessons-coach-network
|
- dance-lessons-coach-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Mailpit — local SMTP capture for dev + BDD parallel email tests.
|
||||||
|
# Cf. ADR-0029 (email infrastructure) and ADR-0030 (BDD parallel strategy).
|
||||||
|
# SMTP submission on :1025 (used by the app), HTTP UI + API on :8025
|
||||||
|
# (used by tests + manual inspection at http://localhost:8025).
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
container_name: dance-lessons-coach-mailpit
|
||||||
|
ports:
|
||||||
|
- "1025:1025" # SMTP submission
|
||||||
|
- "8025:8025" # HTTP UI / API
|
||||||
|
environment:
|
||||||
|
MP_MAX_MESSAGES: 5000
|
||||||
|
MP_SMTP_AUTH_ALLOW_INSECURE: 1 # local dev only - no TLS, no real auth
|
||||||
|
networks:
|
||||||
|
- dance-lessons-coach-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# Application service (for reference)
|
# Application service (for reference)
|
||||||
# app:
|
# app:
|
||||||
# build: .
|
# build: .
|
||||||
|
|||||||
105
documentation/EMAIL.md
Normal file
105
documentation/EMAIL.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Email infrastructure
|
||||||
|
|
||||||
|
Outgoing email transport. Per [ADR-0029](../adr/0029-email-infrastructure-mailpit.md): Mailpit for local dev + BDD tests, production sender deferred.
|
||||||
|
|
||||||
|
## Local setup (one-time)
|
||||||
|
|
||||||
|
Mailpit is part of `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # starts postgres + mailpit
|
||||||
|
docker compose ps # confirm both running
|
||||||
|
```
|
||||||
|
|
||||||
|
Mailpit listens on:
|
||||||
|
- **SMTP submission** — `localhost:1025` (the app sends here)
|
||||||
|
- **HTTP UI / API** — http://localhost:8025 (you inspect captured messages here)
|
||||||
|
|
||||||
|
No real emails leave the docker network. No internet required.
|
||||||
|
|
||||||
|
## Application configuration
|
||||||
|
|
||||||
|
The application's outgoing transport is configured under `auth.email.*` in `config.yaml` (or via `DLC_AUTH_EMAIL_*` env vars). Defaults already match local Mailpit:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
email:
|
||||||
|
from: noreply@dance-lessons-coach.local
|
||||||
|
smtp_host: localhost
|
||||||
|
smtp_port: 1025
|
||||||
|
smtp_use_tls: false
|
||||||
|
timeout: 10s
|
||||||
|
# smtp_username + smtp_password left empty for local Mailpit
|
||||||
|
```
|
||||||
|
|
||||||
|
For production, override these to point at the chosen provider (SES, Postmark, etc.).
|
||||||
|
|
||||||
|
## Inspecting messages
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
|
||||||
|
http://localhost:8025 — list of all captured messages, search, raw view, HTML preview.
|
||||||
|
|
||||||
|
### HTTP API (for automation)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Latest 10 messages
|
||||||
|
curl -s 'http://localhost:8025/api/v1/messages?limit=10' | jq
|
||||||
|
|
||||||
|
# Messages for a specific recipient (used by BDD tests, cf. ADR-0030)
|
||||||
|
curl -s 'http://localhost:8025/api/v1/messages?query=to:test-user@bdd.local' | jq
|
||||||
|
|
||||||
|
# Get a specific message by ID (full content, headers, attachments)
|
||||||
|
curl -s 'http://localhost:8025/api/v1/message/<id>' | jq
|
||||||
|
|
||||||
|
# Purge messages for a recipient (used in test cleanup)
|
||||||
|
curl -X DELETE 'http://localhost:8025/api/v1/messages?query=to:test-user@bdd.local'
|
||||||
|
```
|
||||||
|
|
||||||
|
Full API: https://mailpit.axllent.org/docs/api-v1/
|
||||||
|
|
||||||
|
## Sending email from Go code
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "dance-lessons-coach/pkg/email"
|
||||||
|
|
||||||
|
sender := email.NewSMTPSender(email.SMTPConfig{
|
||||||
|
Host: cfg.GetEmailConfig().SMTPHost,
|
||||||
|
Port: cfg.GetEmailConfig().SMTPPort,
|
||||||
|
// username/password optional — empty means no AUTH (Mailpit local)
|
||||||
|
})
|
||||||
|
|
||||||
|
err := sender.Send(ctx, email.Message{
|
||||||
|
To: "alice@example.com",
|
||||||
|
From: cfg.GetEmailConfig().From,
|
||||||
|
Subject: "Your magic link",
|
||||||
|
BodyText: "Click: https://example.com/magic-link/consume?token=...",
|
||||||
|
Headers: map[string]string{
|
||||||
|
// optional — useful for BDD test correlation
|
||||||
|
"X-Trace-Id": "req-abc-123",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, when both text and HTML are needed (`multipart/alternative`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
err := sender.Send(ctx, email.Message{
|
||||||
|
To: "alice@example.com", From: "...", Subject: "...",
|
||||||
|
BodyText: "Click: https://...",
|
||||||
|
BodyHTML: `<p>Click <a href="https://...">your magic link</a></p>`,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production sender (TBD)
|
||||||
|
|
||||||
|
Not chosen yet. When ready, implement another `email.Sender` in
|
||||||
|
`pkg/email/<provider>_sender.go` and wire it via the config. The
|
||||||
|
`Sender` interface is the swap point — call sites don't change.
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
- [ADR-0028 — Passwordless auth migration](../adr/0028-passwordless-auth-migration.md) (consumes this infrastructure)
|
||||||
|
- [ADR-0029 — Email infrastructure decision](../adr/0029-email-infrastructure-mailpit.md)
|
||||||
|
- [ADR-0030 — BDD email parallel strategy](../adr/0030-bdd-email-parallel-strategy.md)
|
||||||
|
- [Mailpit docs](https://mailpit.axllent.org/docs/)
|
||||||
@@ -104,9 +104,22 @@ type APIConfig struct {
|
|||||||
|
|
||||||
// AuthConfig holds authentication configuration
|
// AuthConfig holds authentication configuration
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
JWTSecret string `mapstructure:"jwt_secret"`
|
JWTSecret string `mapstructure:"jwt_secret"`
|
||||||
AdminMasterPassword string `mapstructure:"admin_master_password"`
|
AdminMasterPassword string `mapstructure:"admin_master_password"`
|
||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
|
Email EmailConfig `mapstructure:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailConfig holds outgoing email transport configuration.
|
||||||
|
// Defaults match local Mailpit (cf. ADR-0029) so dev needs no extra setup.
|
||||||
|
type EmailConfig struct {
|
||||||
|
From string `mapstructure:"from"`
|
||||||
|
SMTPHost string `mapstructure:"smtp_host"`
|
||||||
|
SMTPPort int `mapstructure:"smtp_port"`
|
||||||
|
SMTPUsername string `mapstructure:"smtp_username"`
|
||||||
|
SMTPPassword string `mapstructure:"smtp_password"`
|
||||||
|
SMTPUseTLS bool `mapstructure:"smtp_use_tls"`
|
||||||
|
Timeout time.Duration `mapstructure:"timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWTConfig holds JWT-specific configuration
|
// JWTConfig holds JWT-specific configuration
|
||||||
@@ -256,6 +269,13 @@ func LoadConfig() (*Config, error) {
|
|||||||
v.SetDefault("auth.jwt.secret_retention.max_retention", 72*time.Hour)
|
v.SetDefault("auth.jwt.secret_retention.max_retention", 72*time.Hour)
|
||||||
v.SetDefault("auth.jwt.secret_retention.cleanup_interval", 1*time.Hour)
|
v.SetDefault("auth.jwt.secret_retention.cleanup_interval", 1*time.Hour)
|
||||||
|
|
||||||
|
// Email defaults — match local Mailpit (ADR-0029).
|
||||||
|
v.SetDefault("auth.email.from", "noreply@dance-lessons-coach.local")
|
||||||
|
v.SetDefault("auth.email.smtp_host", "localhost")
|
||||||
|
v.SetDefault("auth.email.smtp_port", 1025)
|
||||||
|
v.SetDefault("auth.email.smtp_use_tls", false)
|
||||||
|
v.SetDefault("auth.email.timeout", 10*time.Second)
|
||||||
|
|
||||||
// Check for custom config file path via environment variable
|
// Check for custom config file path via environment variable
|
||||||
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
|
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
|
||||||
v.SetConfigFile(configFile)
|
v.SetConfigFile(configFile)
|
||||||
@@ -301,6 +321,13 @@ func LoadConfig() (*Config, error) {
|
|||||||
v.BindEnv("auth.jwt.secret_retention.retention_factor", "DLC_AUTH_JWT_SECRET_RETENTION_FACTOR")
|
v.BindEnv("auth.jwt.secret_retention.retention_factor", "DLC_AUTH_JWT_SECRET_RETENTION_FACTOR")
|
||||||
v.BindEnv("auth.jwt.secret_retention.max_retention", "DLC_AUTH_JWT_SECRET_MAX_RETENTION")
|
v.BindEnv("auth.jwt.secret_retention.max_retention", "DLC_AUTH_JWT_SECRET_MAX_RETENTION")
|
||||||
v.BindEnv("auth.jwt.secret_retention.cleanup_interval", "DLC_AUTH_JWT_SECRET_CLEANUP_INTERVAL")
|
v.BindEnv("auth.jwt.secret_retention.cleanup_interval", "DLC_AUTH_JWT_SECRET_CLEANUP_INTERVAL")
|
||||||
|
v.BindEnv("auth.email.from", "DLC_AUTH_EMAIL_FROM")
|
||||||
|
v.BindEnv("auth.email.smtp_host", "DLC_AUTH_EMAIL_SMTP_HOST")
|
||||||
|
v.BindEnv("auth.email.smtp_port", "DLC_AUTH_EMAIL_SMTP_PORT")
|
||||||
|
v.BindEnv("auth.email.smtp_username", "DLC_AUTH_EMAIL_SMTP_USERNAME")
|
||||||
|
v.BindEnv("auth.email.smtp_password", "DLC_AUTH_EMAIL_SMTP_PASSWORD")
|
||||||
|
v.BindEnv("auth.email.smtp_use_tls", "DLC_AUTH_EMAIL_SMTP_USE_TLS")
|
||||||
|
v.BindEnv("auth.email.timeout", "DLC_AUTH_EMAIL_TIMEOUT")
|
||||||
v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE")
|
v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE")
|
||||||
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
|
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
|
||||||
|
|
||||||
@@ -432,6 +459,13 @@ func (c *Config) GetAdminMasterPassword() string {
|
|||||||
return c.Auth.AdminMasterPassword
|
return c.Auth.AdminMasterPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetEmailConfig returns the outgoing email transport configuration.
|
||||||
|
// Defaults match local Mailpit (localhost:1025, no TLS, no auth) per
|
||||||
|
// ADR-0029. Used by pkg/email.NewSMTPSender.
|
||||||
|
func (c *Config) GetEmailConfig() EmailConfig {
|
||||||
|
return c.Auth.Email
|
||||||
|
}
|
||||||
|
|
||||||
// GetJWTTTL returns the JWT TTL
|
// GetJWTTTL returns the JWT TTL
|
||||||
func (c *Config) GetJWTTTL() time.Duration {
|
func (c *Config) GetJWTTTL() time.Duration {
|
||||||
if c.Auth.JWT.TTL == 0 {
|
if c.Auth.JWT.TTL == 0 {
|
||||||
|
|||||||
45
pkg/email/sender.go
Normal file
45
pkg/email/sender.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Package email provides the abstraction over outgoing email transport.
|
||||||
|
//
|
||||||
|
// ADR-0029 picked Mailpit for local dev and BDD ; production sender is
|
||||||
|
// deferred. The Sender interface is the swap point : a future production
|
||||||
|
// adapter (AWS SES, Postmark, SendGrid) implements the same contract
|
||||||
|
// without touching call sites.
|
||||||
|
package email
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Sender sends email messages. Implementations must be safe for
|
||||||
|
// concurrent use — multiple goroutines may call Send simultaneously.
|
||||||
|
type Sender interface {
|
||||||
|
Send(ctx context.Context, msg Message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message is the wire-level representation of an outgoing email.
|
||||||
|
// Headers is for trace correlation (e.g. X-Test-Scenario-ID for BDD)
|
||||||
|
// and arbitrary application-specific tags. Implementations include
|
||||||
|
// these as RFC 5322 header fields.
|
||||||
|
type Message struct {
|
||||||
|
// To is the recipient address (single recipient ; we don't currently
|
||||||
|
// support multi-recipient broadcasts — keeps the contract simple
|
||||||
|
// and matches the magic-link use case which is always 1:1).
|
||||||
|
To string
|
||||||
|
|
||||||
|
// From is the sender address. Required.
|
||||||
|
From string
|
||||||
|
|
||||||
|
// Subject is the RFC 5322 Subject. Required for non-empty body.
|
||||||
|
Subject string
|
||||||
|
|
||||||
|
// BodyText is the plain-text body. At least one of BodyText or
|
||||||
|
// BodyHTML must be non-empty.
|
||||||
|
BodyText string
|
||||||
|
|
||||||
|
// BodyHTML is the optional HTML body. When both are set, the
|
||||||
|
// SMTP-level message is multipart/alternative.
|
||||||
|
BodyHTML string
|
||||||
|
|
||||||
|
// Headers are extra RFC 5322 header fields. Keys are case-insensitive ;
|
||||||
|
// implementations canonicalise via textproto.CanonicalMIMEHeaderKey.
|
||||||
|
// Useful for BDD test correlation (X-BDD-Scenario, etc.).
|
||||||
|
Headers map[string]string
|
||||||
|
}
|
||||||
155
pkg/email/smtp_sender.go
Normal file
155
pkg/email/smtp_sender.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SMTPConfig configures an SMTPSender. Defaults match local Mailpit
|
||||||
|
// (host=localhost, port=1025, no TLS, no auth).
|
||||||
|
type SMTPConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string // empty means no AUTH
|
||||||
|
Password string // empty means no AUTH
|
||||||
|
UseTLS bool // STARTTLS — false for Mailpit local
|
||||||
|
// Timeout bounds Send to avoid hanging forever on a stuck server.
|
||||||
|
// Defaults to 10s when zero.
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTPSender sends mail through an SMTP server. Configured by SMTPConfig
|
||||||
|
// with sensible Mailpit-friendly defaults.
|
||||||
|
type SMTPSender struct {
|
||||||
|
cfg SMTPConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSMTPSender returns a Sender backed by SMTP. The SMTPConfig is copied —
|
||||||
|
// mutating the caller's struct after this call has no effect.
|
||||||
|
func NewSMTPSender(cfg SMTPConfig) *SMTPSender {
|
||||||
|
if cfg.Host == "" {
|
||||||
|
cfg.Host = "localhost"
|
||||||
|
}
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
cfg.Port = 1025
|
||||||
|
}
|
||||||
|
if cfg.Timeout == 0 {
|
||||||
|
cfg.Timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
return &SMTPSender{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send delivers the message via SMTP. Returns an error if the message is
|
||||||
|
// invalid (missing required fields), if connecting / authenticating fails,
|
||||||
|
// or if the SMTP server rejects the envelope or data.
|
||||||
|
func (s *SMTPSender) Send(ctx context.Context, msg Message) error {
|
||||||
|
if err := validateMessage(msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
|
||||||
|
body := buildRFC5322(msg)
|
||||||
|
|
||||||
|
var auth smtp.Auth
|
||||||
|
if s.cfg.Username != "" {
|
||||||
|
auth = smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the SMTP exchange in a goroutine so we can honour the
|
||||||
|
// context's cancellation independently of net/smtp's own timeouts.
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- smtp.SendMail(addr, auth, msg.From, []string{msg.To}, body)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp send to %s: %w", msg.To, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(s.cfg.Timeout):
|
||||||
|
return errors.New("smtp send: timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateMessage checks the minimum required fields for an outgoing email.
|
||||||
|
func validateMessage(msg Message) error {
|
||||||
|
if msg.To == "" {
|
||||||
|
return errors.New("email: To is required")
|
||||||
|
}
|
||||||
|
if msg.From == "" {
|
||||||
|
return errors.New("email: From is required")
|
||||||
|
}
|
||||||
|
if msg.BodyText == "" && msg.BodyHTML == "" {
|
||||||
|
return errors.New("email: at least one of BodyText or BodyHTML is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRFC5322 builds an RFC 5322 message body from Message. Plain-text
|
||||||
|
// only when BodyHTML is empty ; multipart/alternative when both are set.
|
||||||
|
//
|
||||||
|
// Keep in mind: this is the body sent to net/smtp.SendMail, which adds
|
||||||
|
// no headers itself. We add the canonical From, To, Subject, MIME-Version,
|
||||||
|
// Content-Type, and any caller-provided custom headers.
|
||||||
|
func buildRFC5322(msg Message) []byte {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Custom headers first so they show up early in the message.
|
||||||
|
for k, v := range msg.Headers {
|
||||||
|
// Normalise header name case (Foo-Bar, not foo-BAR)
|
||||||
|
k = textproto.CanonicalMIMEHeaderKey(k)
|
||||||
|
fmt.Fprintf(&b, "%s: %s\r\n", k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, "From: %s\r\n", msg.From)
|
||||||
|
fmt.Fprintf(&b, "To: %s\r\n", msg.To)
|
||||||
|
fmt.Fprintf(&b, "Subject: %s\r\n", msg.Subject)
|
||||||
|
fmt.Fprintf(&b, "MIME-Version: 1.0\r\n")
|
||||||
|
|
||||||
|
if msg.BodyHTML == "" {
|
||||||
|
// Plain text only
|
||||||
|
fmt.Fprintf(&b, "Content-Type: text/plain; charset=utf-8\r\n")
|
||||||
|
fmt.Fprintf(&b, "\r\n")
|
||||||
|
b.WriteString(msg.BodyText)
|
||||||
|
return []byte(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// multipart/alternative — boundary is deterministic for tests but unique
|
||||||
|
// enough not to collide with body content. We don't put random bytes in
|
||||||
|
// the boundary because the alphabetical "alt-" prefix is recognisably
|
||||||
|
// ours and the rest is timestamped.
|
||||||
|
boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano())
|
||||||
|
fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%q\r\n", boundary)
|
||||||
|
fmt.Fprintf(&b, "\r\n")
|
||||||
|
|
||||||
|
// Plain part (always first — clients that only render text/plain see
|
||||||
|
// it ; clients preferring HTML go to the HTML part)
|
||||||
|
fmt.Fprintf(&b, "--%s\r\n", boundary)
|
||||||
|
fmt.Fprintf(&b, "Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||||
|
if msg.BodyText != "" {
|
||||||
|
b.WriteString(msg.BodyText)
|
||||||
|
} else {
|
||||||
|
b.WriteString("(see HTML version)")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\r\n")
|
||||||
|
|
||||||
|
// HTML part
|
||||||
|
fmt.Fprintf(&b, "--%s\r\n", boundary)
|
||||||
|
fmt.Fprintf(&b, "Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||||||
|
b.WriteString(msg.BodyHTML)
|
||||||
|
fmt.Fprintf(&b, "\r\n")
|
||||||
|
|
||||||
|
// Close boundary
|
||||||
|
fmt.Fprintf(&b, "--%s--\r\n", boundary)
|
||||||
|
|
||||||
|
return []byte(b.String())
|
||||||
|
}
|
||||||
123
pkg/email/smtp_sender_test.go
Normal file
123
pkg/email/smtp_sender_test.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestValidateMessage_RejectsMissingFields confirms the contract gate:
|
||||||
|
// To, From, and at least one body are required.
|
||||||
|
func TestValidateMessage_RejectsMissingFields(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg Message
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"empty To", Message{From: "f@x", BodyText: "b"}, "To is required"},
|
||||||
|
{"empty From", Message{To: "t@x", BodyText: "b"}, "From is required"},
|
||||||
|
{"empty body", Message{To: "t@x", From: "f@x"}, "BodyText or BodyHTML"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validateMessage(tc.msg)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tc.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateMessage_AcceptsMinimal confirms the happy path: To, From,
|
||||||
|
// and BodyText alone is a valid message.
|
||||||
|
func TestValidateMessage_AcceptsMinimal(t *testing.T) {
|
||||||
|
err := validateMessage(Message{To: "t@x", From: "f@x", BodyText: "b"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildRFC5322_PlainText verifies that a text-only message produces
|
||||||
|
// a single-part text/plain RFC 5322 body with the expected headers.
|
||||||
|
func TestBuildRFC5322_PlainText(t *testing.T) {
|
||||||
|
msg := Message{
|
||||||
|
To: "alice@example.com", From: "system@x", Subject: "Hi",
|
||||||
|
BodyText: "Hello Alice",
|
||||||
|
}
|
||||||
|
body := string(buildRFC5322(msg))
|
||||||
|
|
||||||
|
assert.Contains(t, body, "To: alice@example.com\r\n")
|
||||||
|
assert.Contains(t, body, "From: system@x\r\n")
|
||||||
|
assert.Contains(t, body, "Subject: Hi\r\n")
|
||||||
|
assert.Contains(t, body, "MIME-Version: 1.0\r\n")
|
||||||
|
assert.Contains(t, body, "Content-Type: text/plain; charset=utf-8\r\n")
|
||||||
|
assert.Contains(t, body, "\r\nHello Alice")
|
||||||
|
assert.NotContains(t, body, "multipart/alternative", "no HTML => no multipart")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildRFC5322_Multipart verifies that text + HTML produces a
|
||||||
|
// multipart/alternative body with both parts and a boundary close.
|
||||||
|
func TestBuildRFC5322_Multipart(t *testing.T) {
|
||||||
|
msg := Message{
|
||||||
|
To: "alice@example.com", From: "system@x", Subject: "Hi",
|
||||||
|
BodyText: "Plain hello", BodyHTML: "<p>HTML hello</p>",
|
||||||
|
}
|
||||||
|
body := string(buildRFC5322(msg))
|
||||||
|
|
||||||
|
assert.Contains(t, body, "Content-Type: multipart/alternative;")
|
||||||
|
assert.Contains(t, body, "Plain hello", "plain part included")
|
||||||
|
assert.Contains(t, body, "<p>HTML hello</p>", "HTML part included")
|
||||||
|
// Boundary close marker
|
||||||
|
assert.True(t, strings.Contains(body, "--alt-") && strings.HasSuffix(strings.TrimRight(body, "\r\n"), "--"),
|
||||||
|
"multipart message should end with closing boundary")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildRFC5322_CustomHeaders verifies that user-supplied headers
|
||||||
|
// appear in the output with canonicalised case.
|
||||||
|
func TestBuildRFC5322_CustomHeaders(t *testing.T) {
|
||||||
|
msg := Message{
|
||||||
|
To: "alice@example.com", From: "system@x", Subject: "Hi",
|
||||||
|
BodyText: "b",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"x-bdd-scenario": "magic-link-happy-path",
|
||||||
|
"X-Trace-Id": "abc-123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body := string(buildRFC5322(msg))
|
||||||
|
|
||||||
|
assert.Contains(t, body, "X-Bdd-Scenario: magic-link-happy-path\r\n",
|
||||||
|
"lowercase key should be canonicalised to title-case")
|
||||||
|
assert.Contains(t, body, "X-Trace-Id: abc-123\r\n",
|
||||||
|
"already-canonical key should pass through unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSMTPSender_DefaultsAreMailpitFriendly verifies that NewSMTPSender
|
||||||
|
// fills in localhost:1025 and a non-zero timeout when the caller passes
|
||||||
|
// a zero-valued SMTPConfig — which is the recommended default.
|
||||||
|
func TestSMTPSender_DefaultsAreMailpitFriendly(t *testing.T) {
|
||||||
|
s := NewSMTPSender(SMTPConfig{})
|
||||||
|
assert.Equal(t, "localhost", s.cfg.Host)
|
||||||
|
assert.Equal(t, 1025, s.cfg.Port)
|
||||||
|
assert.Greater(t, int64(s.cfg.Timeout), int64(0), "timeout must be set, default 10s")
|
||||||
|
assert.Empty(t, s.cfg.Username, "default no AUTH")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSMTPSender_ContextCancelAbortsSend confirms a cancelled ctx
|
||||||
|
// short-circuits Send rather than waiting for the SMTP timeout.
|
||||||
|
// We point at a port no SMTP server is listening on so the goroutine
|
||||||
|
// will hang ; ctx cancel is what wins.
|
||||||
|
func TestSMTPSender_ContextCancelAbortsSend(t *testing.T) {
|
||||||
|
s := NewSMTPSender(SMTPConfig{
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: 1, // privileged port, definitely no SMTP server here
|
||||||
|
// keep the default 10s timeout — we want ctx to win
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // pre-cancelled
|
||||||
|
|
||||||
|
err := s.Send(ctx, Message{To: "t@x", From: "f@x", BodyText: "b"})
|
||||||
|
require.Error(t, err)
|
||||||
|
// The error is ctx.Err() OR the net/smtp connect error if the goroutine
|
||||||
|
// happened to fail before the ctx select ran. Both are acceptable —
|
||||||
|
// the only unacceptable outcome is the test taking 10 seconds.
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user