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
156 lines
4.5 KiB
Go
156 lines
4.5 KiB
Go
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())
|
|
}
|