#18: merge feature/18-submission-smtp-authenticator-preflight into main

This commit is contained in:
Lucas Rayan committed 2026-08-01 22:49:11 -03:00
commit 2e0e65b167
70 files changed
+5163 -659

No files matched your search

+46 -4
View File
@@ -8,23 +8,65 @@ REDIS_PASSWORD=change-me-in-local-dev
# --- Shared secrets (local dev values — never reuse in production) ---
JWT_SECRET=local-dev-jwt-secret-change-me
# Encrypts DKIM private keys and provider tokens at rest. Losing it makes every
# stored secret unrecoverable — back it up separately from the database.
LAMBDAMAIL_MASTER_KEY=local-dev-master-key-32-bytes-min
# --- Cloudflare (optional in F0 — DNS automation lands in F3) ---
CLOUDFLARE_API_TOKEN=
# --- Host identity ---
PRIMARY_MAIL_HOST=mail.localhost
MAIL_DOMAIN=localhost
PUBLIC_IPV4=127.0.0.1
PUBLIC_IPV6=
# --- TLS (F0 has no real certs — protocols stub reports degraded, never crashes) ---
# --- DNS automation (Cloudflare) ---
# The token needs Zone.DNS:Edit plus Zone.Zone:Read on the zone. Leave it empty
# to disable reconciliation; the service then logs that DNS sync is off.
CLOUDFLARE_API_TOKEN=
DNS_SYNC_INTERVAL_SECONDS=21600
# --- TLS ---
# traefik: read the certificate Traefik manages (default, DANE not possible).
# acme: obtain the certificate ourselves via DNS-01. Required for DANE,
# because only this mode can reuse a pre-generated key.
TLS_MODE=traefik
# Used only when TLS_MODE=acme. Leave ACME_DIRECTORY_URL empty for Let's
# Encrypt production; point it at the staging directory while testing, since
# production rate limits can lock a domain out for a week.
ACME_EMAIL=
ACME_DIRECTORY_URL=
COOLIFY_PROXY_DIR=./local/acme
TRAEFIK_ACME_FILENAME=acme.json
CERT_POLL_INTERVAL_SECONDS=60
# Static certificate files, used when no ACME store is available (local dev).
PROTOCOLS_CERT_PATH=/etc/lambdamail/certs/cert.pem
PROTOCOLS_KEY_PATH=/etc/lambdamail/certs/key.pem
PROTOCOLS_SPOOL_DIR=/var/lambdamail/spool
# --- Transport security policy ---
# Start at "testing": senders honour the policy but do not hard-fail on it.
# Promote to "enforce" only once TLS-RPT reports come back clean.
MTA_STS_MODE=testing
OUTBOUND_MTA_STS_ENABLED=true
# DANE stays off unless TLS_MODE=acme. Under Traefik-managed certificates the
# key changes at every renewal and the published TLSA record stops matching,
# which permanently rejects mail at every validating MTA.
OUTBOUND_DANE_ENABLED=false
DNSSEC_RESOLVER=1.1.1.1:53
MAX_MESSAGE_BYTES=52428800
# ARC seals accepted messages so a downstream receiver can still tell that the
# mail authenticated at the first hop. Only worth enabling on a host that
# forwards mail onward.
ARC_SEAL_ENABLED=false
# --- Smarthost (for hosts where outbound port 25 is blocked) ---
# Setting RELAY_HOST routes all delivery through the relay and adds it to the
# published SPF record, without which every relayed message fails SPF.
RELAY_HOST=
RELAY_PORT=587
RELAY_USER=
RELAY_PASS=
# --- Webmail ---
WEBMAIL_HOST=localhost:3000
ADMIN_HOST=
+7
View File
@@ -0,0 +1,7 @@
# Score thresholds from PLAN.md section 10.1. The principle behind them is
# that mail is refused during the SMTP session or delivered, never accepted
# and silently discarded: a silent discard loses legitimate mail with nobody
# aware of it.
reject = 15; # refuse with 550 5.7.1
add_header = 4; # tag as spam and let Sieve file it into Junk
greylist = 9; # ask the sender to retry
+2
View File
@@ -0,0 +1,2 @@
type = "console";
level = "info";
+5
View File
@@ -0,0 +1,5 @@
# DNS resolution is needed for RBL, SPF and DKIM lookups.
dns {
nameserver = ["1.1.1.1", "8.8.8.8"];
timeout = 3s;
}
+3
View File
@@ -0,0 +1,3 @@
# The protocols service reaches Rspamd over the internal network only; the
# backend network has no route to the internet.
bind_socket = "*:11333";
+209 -37
View File
@@ -2,42 +2,62 @@ x-logging: &default-logging
driver: json-file
options: { max-size: "10m", max-file: "3" }
x-restart: &restart unless-stopped
services:
# ---------------------------------------------------------------- DB
db:
image: postgres:16-alpine
restart: unless-stopped
restart: *restart
logging: *default-logging
environment:
POSTGRES_DB: ${POSTGRES_DB:?required}
POSTGRES_USER: ${POSTGRES_USER:?required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
ports: ["5432:5432"]
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required} # no default: fail fast
POSTGRES_INITDB_ARGS: "--data-checksums"
command: >
postgres -c shared_buffers=256MB -c max_connections=200
-c wal_level=replica
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
interval: 10s
timeout: 5s
retries: 10
retries: 5
start_period: 30s
mem_limit: 1g
cpus: 1.0
networks: [backend]
# ------------------------------------------------------------- REDIS
redis:
image: redis:7-alpine
restart: unless-stopped
restart: *restart
logging: *default-logging
command: >
redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:?required}
redis-server --appendonly yes --appendfsync everysec
--requirepass ${REDIS_PASSWORD:?required}
--maxmemory 384mb --maxmemory-policy noeviction
# REDIS_PASSWORD has to exist inside the container for the healthcheck:
# $${VAR} expands in the container shell, not in Compose.
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes: [redis_data:/data]
healthcheck:
test: ["CMD-SHELL", "redis-cli --no-auth-warning -a $${REDIS_PASSWORD} ping | grep -q PONG"]
interval: 5s
interval: 10s
timeout: 5s
retries: 10
retries: 5
mem_limit: 512m
cpus: 0.5
networks: [backend]
# ----------------------------------------------------------- MIGRATE
migrate:
image: migrate/migrate:v4.17.1
restart: "no"
logging: *default-logging
volumes: ["./migrations:/migrations"]
depends_on:
db: { condition: service_healthy }
@@ -47,91 +67,243 @@ services:
- -database
- postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
- up
networks: [backend]
# ------------------------------------------------------------ RSPAMD
rspamd:
image: ghcr.io/rspamd/rspamd:3.9
restart: *restart
logging: *default-logging
environment:
RSPAMD_REDIS_SERVERS: redis
RSPAMD_REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes:
- ./config/rspamd:/etc/rspamd/local.d:ro
- rspamd_data:/var/lib/rspamd
depends_on:
redis: { condition: service_healthy }
healthcheck:
test: ["CMD-SHELL", "rspamadm control stat >/dev/null 2>&1 || exit 1"]
interval: 30s
retries: 3
mem_limit: 768m
networks: [backend, egress] # egress: RBL/DNSBL lookups and fuzzy storage
# ------------------------------------------------------------ CLAMAV
clamav:
image: clamav/clamav:1.3
restart: *restart
logging: *default-logging
volumes: [clamav_data:/var/lib/clamav]
healthcheck:
test: ["CMD-SHELL", "clamdcheck.sh || exit 1"]
interval: 60s
retries: 5
start_period: 300s # the initial signature load is slow
mem_limit: 2g # ClamAV genuinely needs this
networks: [backend, egress] # egress: freshclam signature updates
# -------------------------------------------------------------- AUTH
auth:
build: { context: ./services/auth }
restart: unless-stopped
restart: *restart
logging: *default-logging
environment:
PORT: 3001
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
JWT_SECRET: ${JWT_SECRET:?required}
LAMBDAMAIL_MASTER_KEY: ${LAMBDAMAIL_MASTER_KEY:?required}
LAMBDAMAIL_MASTER_KEY: ${LAMBDAMAIL_MASTER_KEY:?required} # KEK for sealed secrets
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-}
PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:?required}
PUBLIC_IPV4: ${PUBLIC_IPV4:?required}
PUBLIC_IPV6: ${PUBLIC_IPV6:-}
depends_on:
db: { condition: service_healthy }
redis: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3001/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
retries: 3
start_period: 20s
mem_limit: 512m
networks: [backend, egress] # egress: Cloudflare API. Without it DNS sync
# fails silently, since backend is internal.
# ----------------------------------------------------------- STORAGE
storage:
build: { context: ./services/storage }
restart: unless-stopped
restart: *restart
logging: *default-logging
environment:
PORT: 3002
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
STORAGE_DRIVER: ${STORAGE_DRIVER:-local}
STORAGE_LOCAL_PATH: /var/lambdamail/mails
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_BUCKET: ${S3_BUCKET:-}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
volumes: [mail_storage:/var/lambdamail/mails]
depends_on:
db: { condition: service_healthy }
redis: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
interval: 15s
retries: 3
start_period: 20s
mem_limit: 1g
cpus: 1.0
networks: [backend, egress] # egress: external S3 when STORAGE_DRIVER=s3
# --------------------------------------------------------- PROTOCOLS
protocols:
build: { context: ./services/protocols }
restart: unless-stopped
restart: *restart
logging: *default-logging
ports: ["8080:8080", "25:25", "143:143"]
ports:
- "25:25" # SMTP MX
- "587:587" # Submission (STARTTLS)
- "465:465" # Submission (implicit TLS)
- "143:143" # IMAP (STARTTLS)
- "993:993" # IMAPS
- "110:110" # POP3 (STARTTLS)
- "995:995" # POP3S
- "4190:4190" # ManageSieve
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:-mail.localhost}
PROTOCOLS_CERT_PATH: /etc/lambdamail/certs/cert.pem
PROTOCOLS_KEY_PATH: /etc/lambdamail/certs/key.pem
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
RSPAMD_URL: http://rspamd:11333
CLAMAV_ADDR: clamav:3310
PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:?required}
MAIL_DOMAIN: ${MAIL_DOMAIN:?required}
LAMBDAMAIL_MASTER_KEY: ${LAMBDAMAIL_MASTER_KEY:?required} # required for DKIM keys
# --- TLS ---
TLS_MODE: ${TLS_MODE:-traefik} # traefik | acme
TRAEFIK_ACME_DIR: /etc/traefik # the DIRECTORY, not the file (section 8.2)
TRAEFIK_ACME_FILENAME: acme.json
CERT_POLL_INTERVAL_SECONDS: "60"
# --- Policy ---
MTA_STS_MODE: ${MTA_STS_MODE:-testing} # promote to enforce once TLS-RPT is clean
# DANE requires TLS_MODE=acme; the preflight refuses the combination
# with Traefik-managed certificates (section 5.1).
OUTBOUND_DANE_ENABLED: ${OUTBOUND_DANE_ENABLED:-false}
OUTBOUND_MTA_STS_ENABLED: "true"
DNSSEC_RESOLVER: ${DNSSEC_RESOLVER:-1.1.1.1:53}
# ARC only earns its keep on a host that forwards mail onward.
ARC_SEAL_ENABLED: ${ARC_SEAL_ENABLED:-false}
# Smarthost, for providers that block outbound port 25 (section 10.4).
# Setting RELAY_HOST also adds the relay to the published SPF record.
RELAY_HOST: ${RELAY_HOST:-}
RELAY_PORT: ${RELAY_PORT:-587}
RELAY_USER: ${RELAY_USER:-}
RELAY_PASS: ${RELAY_PASS:-}
MAX_MESSAGE_BYTES: "52428800"
# --- Self-managed ACME (mode B, section 8.3) ---
ACME_EMAIL: ${ACME_EMAIL:-}
ACME_DIRECTORY_URL: ${ACME_DIRECTORY_URL:-}
# --- DNS automation ---
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-}
PUBLIC_IPV4: ${PUBLIC_IPV4:?required}
PUBLIC_IPV6: ${PUBLIC_IPV6:-}
DNS_SYNC_INTERVAL_SECONDS: "21600" # 6 h drift detection
PROTOCOLS_SPOOL_DIR: /var/lambdamail/spool
volumes:
- ./local/certs:/etc/lambdamail/certs:ro
# Bind-mount the DIRECTORY: Traefik's atomic rename breaks a
# file-scoped mount and the watcher would go blind (section 8.2).
- ${COOLIFY_PROXY_DIR:-/data/coolify/proxy}:/etc/traefik:ro
- protocols_spool:/var/lambdamail/spool
depends_on:
db: { condition: service_healthy }
redis: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
rspamd: { condition: service_started }
clamav: { condition: service_started }
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health/ready || exit 1"]
interval: 10s
test: ["CMD", "/app/lambdamail-protocols", "healthcheck"]
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
retries: 3
start_period: 30s
mem_limit: 1g
cpus: 1.5
ulimits:
nofile: { soft: 65536, hard: 65536 }
networks: [backend, egress] # egress: port 25 delivery, DNSSEC, MTA-STS fetch
# -------------------------------------------------------------- WEB
web:
build: { context: ./services/webmail }
restart: unless-stopped
restart: *restart
logging: *default-logging
ports: ["3000:3000"]
environment:
PORT: 3000
AUTH_SERVICE_URL: http://auth:3001
STORAGE_SERVICE_URL: http://storage:3002
NEXT_PUBLIC_APP_URL: https://${WEBMAIL_HOST:?required}
# Isolation of the two surfaces (section 14.1). With ADMIN_HOST set the
# console answers only on that host; otherwise it lives under
# ${WEBMAIL_HOST}/admin.
ADMIN_HOST: ${ADMIN_HOST:-}
TOTP_ISSUER: ${TOTP_ISSUER:-LambdaMail}
MFA_CHALLENGE_TTL_SECONDS: "300"
ADMIN_SESSION_TTL_SECONDS: "28800" # 8 h
ADMIN_STEPUP_INTERVAL_SECONDS: "900" # re-require TOTP every 15 min
USER_SESSION_TTL_SECONDS: "2592000" # 30 d
TRUSTED_DEVICE_TTL_SECONDS: "2592000"
depends_on:
auth: { condition: service_healthy }
storage: { condition: service_healthy }
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
labels:
- traefik.enable=true
# With the service on two networks Traefik may route via the wrong one
# and return 504. This pins it to the proxy network.
- traefik.docker.network=${COOLIFY_PROXY_NETWORK:-coolify}
# Webmail (/user/*)
- traefik.http.routers.lm-web.rule=Host(`${WEBMAIL_HOST}`)
- traefik.http.routers.lm-web.tls.certresolver=letsencrypt
- traefik.http.services.lm-web.loadbalancer.server.port=3000
# Admin console. Nested fallback: with no ADMIN_HOST the console lives
# under the webmail host at /admin. Never use a fictitious host as the
# default - Traefik would loop on ACME for it and risk a rate limit.
- traefik.http.routers.lm-admin.rule=Host(`${ADMIN_HOST:-${WEBMAIL_HOST}}`) && PathPrefix(`/admin`)
- traefik.http.routers.lm-admin.priority=20
- traefik.http.routers.lm-admin.tls.certresolver=letsencrypt
# MTA-STS needs a valid certificate on mta-sts.<domain>
- traefik.http.routers.lm-mtasts.rule=Host(`mta-sts.${MAIL_DOMAIN}`)
- traefik.http.routers.lm-mtasts.tls.certresolver=letsencrypt
# Autoconfig (Thunderbird)
- traefik.http.routers.lm-autoconfig.rule=Host(`autoconfig.${MAIL_DOMAIN}`)
- traefik.http.routers.lm-autoconfig.tls.certresolver=letsencrypt
# CRITICAL: forces Traefik to issue a certificate for the mail host.
# Without this router mail.<domain> never appears in acme.json and every
# TLS listener comes up without a certificate (section 8.2, trap 2).
- traefik.http.routers.lm-mailhost.rule=Host(`${PRIMARY_MAIL_HOST}`)
- traefik.http.routers.lm-mailhost.tls.certresolver=letsencrypt
mem_limit: 512m
networks: [backend, proxy]
volumes:
postgres_data:
redis_data:
mail_storage:
rspamd_data:
clamav_data:
protocols_spool:
networks:
backend:
internal: true # no route to the internet: internal traffic only
# Controlled egress: only the services that must reach the internet join it
# (auth -> Cloudflare API; protocols -> remote MX, DNS, MTA-STS fetch;
# rspamd -> RBLs; clamav -> freshclam; storage -> external S3).
egress:
driver: bridge
proxy:
external: true
name: ${COOLIFY_PROXY_NETWORK:-coolify}
@@ -0,0 +1,135 @@
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
"lambdamail/protocols/internal/infrastructure/acme"
"lambdamail/protocols/internal/infrastructure/cloudflare"
"lambdamail/protocols/internal/infrastructure/postgres"
"lambdamail/protocols/internal/infrastructure/vault"
)
// startAcmeManager brings up mode B of PLAN.md section 8.3.
func startAcmeManager(ctx context.Context, cfg config, pool *pgxpool.Pool, secretVault *vault.SecretVault) (port.CertProvider, error) {
directoryURL := cfg.AcmeDirectoryURL
if directoryURL == "" {
directoryURL = acme.LetsEncryptProduction
}
store := postgres.NewAcmeRepository(pool, secretVault, directoryURL)
issuer := acme.NewIssuer(store, cfg.AcmeEmail, directoryURL, cfg.CloudflareToken)
publisher := &tlsaPublisher{
adapter: cloudflare.NewCloudflareAdapter(cfg.CloudflareToken),
domain: cfg.domain(),
}
// The hosts that need a certificate are the mail host itself plus the two
// service names of PLAN.md section 7.3, which have to present a valid
// certificate for MTA-STS and autoconfig to work at all.
extraHosts := []string{
"mta-sts." + cfg.domain(),
"autoconfig." + cfg.domain(),
}
manager := acme.NewManager(issuer, store, store, publisher, cfg.PrimaryMailHost, extraHosts, cfg.OutboundDane)
// Reconciling every six hours is frequent enough for a 90 day certificate
// and for the propagation waits the rollover schedules.
if err := manager.Start(ctx, 6*time.Hour); err != nil {
return nil, err
}
log.Printf("lambdamail-protocols managing its own certificate for %s (DANE: %v)", cfg.PrimaryMailHost, cfg.OutboundDane)
return manager, nil
}
// tlsaPublisher writes the DANE associations the rollover decides on.
//
// It replaces the full set every time rather than computing a delta: the
// rollover always hands over the complete desired state, and a delta would
// risk removing an association that is still needed.
type tlsaPublisher struct {
adapter *cloudflare.CloudflareAdapter
domain string
}
func (p *tlsaPublisher) PublishTlsa(ctx context.Context, mailHost string, digests []string) error {
if len(digests) == 0 {
return nil
}
zoneID, err := p.adapter.GetZoneID(ctx, p.domain)
if err != nil {
return fmt.Errorf("resolve zone for TLSA publication: %w", err)
}
existing, err := p.adapter.ListRecords(ctx, zoneID)
if err != nil {
return fmt.Errorf("list records for TLSA publication: %w", err)
}
name := fmt.Sprintf("_25._tcp.%s", mailHost)
desired := map[string]bool{}
for _, digest := range digests {
desired[fmt.Sprintf("3 1 1 %s", digest)] = true
}
// Create the ones that are missing before removing anything, so there is
// never a moment with fewer associations published than required.
published := map[string]string{}
for _, record := range existing {
if record.Type == "TLSA" && equalFoldTrim(record.Name, name) {
published[record.Value] = record.ID
}
}
for value := range desired {
if _, alreadyThere := published[value]; alreadyThere {
continue
}
record := entityTlsaRecord(name, value)
if err := p.adapter.CreateRecord(ctx, zoneID, record); err != nil {
return fmt.Errorf("publish TLSA %s: %w", value, err)
}
log.Printf("acme: published TLSA association %s", value)
}
for value, id := range published {
if desired[value] {
continue
}
if err := p.adapter.DeleteRecord(ctx, zoneID, id); err != nil {
// A stale association is harmless to delivery - it only means one
// extra candidate a validator may match - so this is logged
// rather than treated as a failure.
log.Printf("acme: could not remove the retired TLSA association %s: %v", value, err)
continue
}
log.Printf("acme: retired TLSA association %s", value)
}
return nil
}
func entityTlsaRecord(name, value string) entity.DnsRecord {
return entity.DnsRecord{
Type: "TLSA",
Name: name,
Value: value,
TTL: 1,
Comment: "LambdaMail DANE TLSA Record",
}
}
func equalFoldTrim(a, b string) bool {
return strings.EqualFold(strings.TrimSuffix(a, "."), strings.TrimSuffix(b, "."))
}
@@ -0,0 +1,137 @@
package main
import (
"os"
"strconv"
"strings"
"time"
)
// config is the resolved environment of PLAN.md section 13.
type config struct {
DatabaseURL string
PrimaryMailHost string
MailDomain string
SpoolDir string
MasterKey string
TLSMode string
TraefikAcmeDir string
TraefikAcmeFile string
CertPollInterval time.Duration
StaticCertPath string
StaticCertKeyPath string
RspamdURL string
ClamavAddr string
MtaStsMode string
OutboundDane bool
OutboundMtaSts bool
DnssecResolver string
MaxMessageBytes int64
RelayHost string
RelayPort int
RelayUser string
RelayPass string
// ArcSealEnabled adds an ARC set to accepted messages. It is off by
// default because it only earns its keep on a host that forwards mail
// onward; sealing mail we are the final destination for changes the
// stored message for no benefit (PLAN.md section 5).
ArcSealEnabled bool
// AcmeEmail and AcmeDirectoryURL configure mode B (PLAN.md section 8.3).
AcmeEmail string
AcmeDirectoryURL string
CloudflareToken string
PublicIPv4 string
PublicIPv6 string
DnsSyncInterval time.Duration
}
func loadConfig() config {
return config{
DatabaseURL: os.Getenv("DATABASE_URL"),
PrimaryMailHost: envOrDefault("PRIMARY_MAIL_HOST", "mail.localhost"),
MailDomain: os.Getenv("MAIL_DOMAIN"),
SpoolDir: envOrDefault("PROTOCOLS_SPOOL_DIR", "/var/lambdamail/spool"),
MasterKey: os.Getenv("LAMBDAMAIL_MASTER_KEY"),
TLSMode: strings.ToLower(envOrDefault("TLS_MODE", "traefik")),
TraefikAcmeDir: os.Getenv("TRAEFIK_ACME_DIR"),
TraefikAcmeFile: envOrDefault("TRAEFIK_ACME_FILENAME", "acme.json"),
CertPollInterval: time.Duration(envInt("CERT_POLL_INTERVAL_SECONDS", 60)) * time.Second,
StaticCertPath: os.Getenv("PROTOCOLS_CERT_PATH"),
StaticCertKeyPath: os.Getenv("PROTOCOLS_KEY_PATH"),
RspamdURL: os.Getenv("RSPAMD_URL"),
ClamavAddr: os.Getenv("CLAMAV_ADDR"),
MtaStsMode: envOrDefault("MTA_STS_MODE", "testing"),
// DANE stays off unless explicitly enabled: PLAN.md section 5.1
// explains why publishing TLSA under Traefik-managed certificates
// breaks mail sixty days later.
OutboundDane: envBool("OUTBOUND_DANE_ENABLED", false),
OutboundMtaSts: envBool("OUTBOUND_MTA_STS_ENABLED", true),
DnssecResolver: envOrDefault("DNSSEC_RESOLVER", "1.1.1.1:53"),
MaxMessageBytes: int64(envInt("MAX_MESSAGE_BYTES", 52428800)),
RelayHost: os.Getenv("RELAY_HOST"),
RelayPort: envInt("RELAY_PORT", 587),
RelayUser: os.Getenv("RELAY_USER"),
RelayPass: os.Getenv("RELAY_PASS"),
ArcSealEnabled: envBool("ARC_SEAL_ENABLED", false),
AcmeEmail: os.Getenv("ACME_EMAIL"),
AcmeDirectoryURL: os.Getenv("ACME_DIRECTORY_URL"),
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
PublicIPv4: os.Getenv("PUBLIC_IPV4"),
PublicIPv6: os.Getenv("PUBLIC_IPV6"),
// PLAN.md section 7.5 reconciles every six hours to detect drift.
DnsSyncInterval: time.Duration(envInt("DNS_SYNC_INTERVAL_SECONDS", 6*60*60)) * time.Second,
}
}
// domainOfMailHost derives the mail domain from the mail host when it was not
// configured explicitly: "mail.example.com" yields "example.com".
func (c config) domain() string {
if c.MailDomain != "" {
return c.MailDomain
}
if _, rest, found := strings.Cut(c.PrimaryMailHost, "."); found {
return rest
}
return c.PrimaryMailHost
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if parsed, err := strconv.Atoi(v); err == nil {
return parsed
}
}
return fallback
}
func envBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
if parsed, err := strconv.ParseBool(v); err == nil {
return parsed
}
}
return fallback
}
@@ -0,0 +1,12 @@
//go:build !unix
package main
import "errors"
// freeDiskPercent has no portable implementation outside unix. Returning an
// error makes the preflight report the check as skipped rather than inventing
// a number.
func freeDiskPercent(string) (float64, error) {
return 0, errors.New("free disk space check is only implemented on unix")
}
@@ -0,0 +1,22 @@
//go:build unix
package main
import "syscall"
// freeDiskPercent reports the free space on the filesystem holding path.
// PLAN.md section 15 treats a nearly full spool as a medium-severity finding:
// once it fills, accepted mail cannot be written and has to be refused.
func freeDiskPercent(path string) (float64, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, err
}
total := stat.Blocks
if total == 0 {
return 0, nil
}
// Bavail rather than Bfree: the blocks reserved for root are not usable
// by this process.
return float64(stat.Bavail) / float64(total) * 100, nil
}
@@ -1,86 +1,430 @@
// Command lambdamail-protocols is the entry point for the protocols service.
//
// Subcommands:
//
// (none) run every protocol listener
// preflight run the environment checks of PLAN.md section 15 and exit
// healthcheck probe the local HTTP health endpoint and exit
package main
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
gosmtp "github.com/emersion/go-smtp"
"github.com/jackc/pgx/v5/pgxpool"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/domain/valueobject"
"lambdamail/protocols/internal/infrastructure/arc"
"lambdamail/protocols/internal/infrastructure/clamav"
"lambdamail/protocols/internal/infrastructure/cloudflare"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/dkim"
"lambdamail/protocols/internal/infrastructure/mailauth"
"lambdamail/protocols/internal/infrastructure/netdns"
"lambdamail/protocols/internal/infrastructure/postgres"
"lambdamail/protocols/internal/infrastructure/rspamd"
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
imappresentation "lambdamail/protocols/internal/presentation/imap"
"lambdamail/protocols/internal/infrastructure/tlspolicy"
"lambdamail/protocols/internal/infrastructure/vault"
httppresentation "lambdamail/protocols/internal/presentation/http"
imappresentation "lambdamail/protocols/internal/presentation/imap"
managesievepresentation "lambdamail/protocols/internal/presentation/managesieve"
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
)
func main() {
ctx := context.Background()
cfg := loadConfig()
pool, err := postgres.NewPool(ctx, os.Getenv("DATABASE_URL"))
if len(os.Args) > 1 {
switch os.Args[1] {
case "preflight":
runPreflight(cfg)
return
case "healthcheck":
runHealthcheck()
return
default:
log.Fatalf("unknown subcommand %q (expected preflight or healthcheck)", os.Args[1])
}
}
run(cfg)
}
func run(cfg config) {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pool, err := postgres.NewPool(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalf("open database pool: %v", err)
}
defer pool.Close()
spoolDir := envOrDefault("PROTOCOLS_SPOOL_DIR", "/var/lambdamail/spool")
if err := os.MkdirAll(spoolDir, 0o755); err != nil {
log.Fatalf("create spool dir %s: %v", spoolDir, err)
if err := os.MkdirAll(cfg.SpoolDir, 0o755); err != nil {
log.Fatalf("create spool dir %s: %v", cfg.SpoolDir, err)
}
secretVault, vaultErr := vault.New(cfg.MasterKey)
certProvider := buildCertProvider(ctx, cfg, pool, secretVault, vaultErr)
// ---------------------------------------------------------------- repos
mailboxes := postgres.NewMailboxRepository(pool)
blobs := diskstorage.NewLocalDiskBlobStorage(pool, spoolDir)
blobs := diskstorage.NewLocalDiskBlobStorage(pool, cfg.SpoolDir)
blobReader := diskstorage.NewLocalDiskBlobReader(pool)
messages := postgres.NewInboundMessageRepository(pool)
useCase := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages)
authRepo := postgres.NewAuthRepository(pool)
imapFolders := postgres.NewImapFolderRepository(pool)
messageQuery := postgres.NewMessageQueryRepository(pool)
flagRepo := postgres.NewFlagRepository(pool)
expungeRepo := postgres.NewExpungeRepository(pool)
copyRepo := postgres.NewCopyRepository(pool)
sieveRepo := postgres.NewSieveRepository(pool)
reportRepo := postgres.NewReportRepository(pool)
outboundRepo := postgres.NewOutboundRepository(pool)
aliasRepo := postgres.NewAliasRepository(pool)
clamavAddr := os.Getenv("CLAMAV_ADDR")
rspamdURL := os.Getenv("RSPAMD_URL")
if clamavAddr != "" || rspamdURL != "" {
var scanners []port.ContentScanner
if clamavAddr != "" {
scanners = append(scanners, clamav.NewClamAVAdapter(clamavAddr))
}
if rspamdURL != "" {
scanners = append(scanners, rspamd.NewRspamdAdapter(rspamdURL))
}
pipeline := usecase.NewScanningPipeline(scanners...)
useCase.SetScanner(pipeline)
log.Printf("lambdamail-protocols antispam/antivirus scanner enabled (clamav: %s, rspamd: %s)", clamavAddr, rspamdURL)
// The vault is required for DKIM: without it the private keys could not
// be stored, and unsigned outbound mail fails the DMARC alignment that
// PLAN.md section 5 depends on.
var dkimRepo *postgres.DkimRepository
if vaultErr != nil {
log.Printf("WARNING: %v - DKIM signing is disabled, outbound mail will not be signed", vaultErr)
} else {
dkimRepo = postgres.NewDkimRepository(pool, secretVault)
}
certProvider, err := tlsprovider.NewLocalFileCertProvider(
os.Getenv("PROTOCOLS_CERT_PATH"),
os.Getenv("PROTOCOLS_KEY_PATH"),
)
if err != nil {
log.Printf("WARNING: could not load configured TLS certificate (%v) - falling back to an ephemeral self-signed certificate; this is degraded mode, not suitable for real deployments", err)
certProvider, err = tlsprovider.NewEphemeralSelfSignedCertProvider()
// ------------------------------------------------------------- inbound
inboundUC := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages)
inboundUC.SetAuthenticator(mailauth.NewAuthenticator(cfg.PrimaryMailHost))
if scanner := buildScanner(cfg); scanner != nil {
inboundUC.SetScanner(scanner)
}
// ------------------------------------------------------------ outbound
var signer port.DkimSigner
if dkimRepo != nil {
signer = dkim.NewSigner(dkimRepo)
}
submissionUC := usecase.NewProcessOutboundEmailUseCase(authRepo, outboundRepo, blobs, signer)
mxResolver := netdns.NewNetMXResolver()
outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, inboundUC, mailboxes, cfg.PrimaryMailHost)
outboundWorker.SetPolicyResolver(buildPolicyResolver(cfg))
if relay := relayConfig(cfg); relay.Configured() {
outboundWorker.SetRelay(relay)
log.Printf("lambdamail-protocols delivering through the smarthost %s", relay.Address())
}
// ARC needs a signing key, so it can only be enabled where DKIM is.
if cfg.ArcSealEnabled && dkimRepo != nil {
if sealer := buildArcSealer(ctx, cfg, dkimRepo); sealer != nil {
inboundUC.SetArcSealer(sealer)
log.Printf("lambdamail-protocols ARC sealing enabled for %s", cfg.domain())
}
}
// --------------------------------------------------------- access UCs
imapUseCase := usecase.NewImapSessionUseCase(authRepo, imapFolders, messageQuery, flagRepo, blobReader, expungeRepo, copyRepo)
inboundUC.SetTrackerManager(imapUseCase.GetTrackerManager(), imapFolders)
pop3UseCase := usecase.NewPop3SessionUseCase(authRepo, imapFolders, messageQuery, blobReader, expungeRepo)
sieveUseCase := usecase.NewManageSieveSessionUseCase(authRepo, sieveRepo)
// ----------------------------------------------------------- listeners
startSMTPListeners(cfg, certProvider, inboundUC, submissionUC)
startIMAPListeners(cfg, certProvider, imapUseCase)
startPOP3Listeners(certProvider, pop3UseCase)
startManageSieveListener(certProvider, sieveUseCase)
// ------------------------------------------------------- background jobs
go runDeliveryWorker(ctx, outboundWorker)
if cfg.CloudflareToken != "" {
go runDnsReconciler(ctx, cfg, aliasRepo, dkimRepo)
} else {
log.Printf("CLOUDFLARE_API_TOKEN is not set: DNS reconciliation is disabled")
}
// ------------------------------------------------------------- HTTP API
router := httppresentation.NewRouter(usecase.NewIngestReportsUseCase(reportRepo), func() error { return pool.Ping(ctx) })
applyMtaStsMode(router, cfg)
server := &http.Server{
Addr: ":8080",
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
log.Printf("lambdamail-protocols HTTP service listening on %s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http serve: %v", err)
}
log.Printf("lambdamail-protocols shut down")
}
// buildCertProvider picks the certificate source. Mode "traefik" reads the
// store Traefik maintains and keeps watching it, because the certificate is
// replaced every sixty days and a stale one takes every listener down
// (PLAN.md section 8).
func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secretVault *vault.SecretVault, vaultErr error) port.CertProvider {
// Mode B: LambdaMail obtains the certificate itself. This is the only
// mode DANE can run in, because it is the only one where the key for the
// next certificate exists before that certificate does
// (PLAN.md sections 5.1 and 8.3).
if cfg.TLSMode == "acme" {
if vaultErr != nil {
log.Fatalf("TLS_MODE=acme needs LAMBDAMAIL_MASTER_KEY to seal the account and certificate keys: %v", vaultErr)
}
if cfg.CloudflareToken == "" {
log.Fatalf("TLS_MODE=acme needs CLOUDFLARE_API_TOKEN: the DNS-01 challenge is answered through the Cloudflare API")
}
provider, err := startAcmeManager(ctx, cfg, pool, secretVault)
if err != nil {
log.Fatalf("generate fallback self-signed certificate: %v", err)
log.Fatalf("start self-managed ACME: %v", err)
}
return provider
}
if cfg.TraefikAcmeDir != "" {
watcher, err := tlsprovider.NewAcmeCertWatcher(cfg.TraefikAcmeDir, cfg.TraefikAcmeFile, cfg.PrimaryMailHost)
if err == nil {
if !watcher.HasCertificateFor(cfg.PrimaryMailHost) {
log.Printf("CRITICAL: the ACME store has no certificate for %s - declare a Traefik router for that host so ACME issues one (PLAN.md section 8.2)", cfg.PrimaryMailHost)
}
go watcher.Watch(ctx.Done(), cfg.CertPollInterval)
log.Printf("lambdamail-protocols reading TLS certificates from %s (polling every %s)", cfg.TraefikAcmeDir, cfg.CertPollInterval)
return watcher
}
log.Printf("WARNING: could not read the ACME store (%v) - falling back", err)
}
if cfg.StaticCertPath != "" {
if provider, err := tlsprovider.NewLocalFileCertProvider(cfg.StaticCertPath, cfg.StaticCertKeyPath); err == nil {
return provider
} else {
log.Printf("WARNING: could not load the configured TLS certificate: %v", err)
}
}
backend := smtppresentation.NewBackend(useCase)
smtpServer := gosmtp.NewServer(backend)
smtpServer.Addr = ":25"
smtpServer.Domain = envOrDefault("PRIMARY_MAIL_HOST", "mail.localhost")
smtpServer.MaxMessageBytes = 52428800 // 50 MB default; per-domain limit is a future iteration
smtpServer.MaxRecipients = 100
smtpServer.TLSConfig = &tls.Config{
log.Printf("CRITICAL: falling back to an ephemeral self-signed certificate; this is degraded mode, not suitable for a real deployment")
provider, err := tlsprovider.NewEphemeralSelfSignedCertProvider()
if err != nil {
log.Fatalf("generate fallback self-signed certificate: %v", err)
}
return provider
}
func relayConfig(cfg config) usecase.RelayConfig {
return usecase.RelayConfig{
Host: cfg.RelayHost,
Port: cfg.RelayPort,
Username: cfg.RelayUser,
Password: cfg.RelayPass,
}
}
// buildArcSealer signs ARC sets with the domain's RSA DKIM key, which is what
// a verifier resolves through the same selector.
func buildArcSealer(ctx context.Context, cfg config, keys port.DkimKeyRepository) port.ArcSealer {
domain := cfg.domain()
active, err := keys.FindActiveKeys(ctx, domain)
if err != nil || len(active) == 0 {
log.Printf("WARNING: ARC sealing requested but no DKIM key is active for %s yet; it will start once one is provisioned", domain)
return nil
}
for _, key := range active {
signer, err := dkim.ParsePrivateKey(key.PrivateKeyPEM)
if err != nil {
continue
}
return arc.NewSealer(domain, key.Selector, cfg.PrimaryMailHost, signer)
}
log.Printf("WARNING: none of the DKIM keys for %s could be used for ARC", domain)
return nil
}
func buildScanner(cfg config) port.ContentScanner {
var scanners []port.ContentScanner
if cfg.ClamavAddr != "" {
scanners = append(scanners, clamav.NewClamAVAdapter(cfg.ClamavAddr))
}
if cfg.RspamdURL != "" {
scanners = append(scanners, rspamd.NewRspamdAdapter(cfg.RspamdURL))
}
if len(scanners) == 0 {
return nil
}
log.Printf("lambdamail-protocols content scanning enabled (clamav: %q, rspamd: %q)", cfg.ClamavAddr, cfg.RspamdURL)
return usecase.NewScanningPipeline(scanners...)
}
// buildPolicyResolver assembles the outbound transport security policy of
// PLAN.md section 6.2.
func buildPolicyResolver(cfg config) port.TLSPolicyResolver {
// With a smarthost the relay takes responsibility for transport security
// (PLAN.md section 10.4), so applying DANE and MTA-STS here would only
// evaluate policies against the wrong hop.
if cfg.RelayHost != "" {
log.Printf("lambdamail-protocols relaying through %s: DANE and MTA-STS are handled by the relay", cfg.RelayHost)
return tlspolicy.NewResolver(tlspolicy.WithMtaSts(false), tlspolicy.WithDane(false, nil))
}
options := []tlspolicy.Option{tlspolicy.WithMtaSts(cfg.OutboundMtaSts)}
if cfg.OutboundDane {
options = append(options, tlspolicy.WithDane(true, netdns.NewTlsaResolver(cfg.DnssecResolver)))
}
log.Printf("lambdamail-protocols outbound TLS policy: dane=%v mta-sts=%v", cfg.OutboundDane, cfg.OutboundMtaSts)
return tlspolicy.NewResolver(options...)
}
func applyMtaStsMode(router *httppresentation.Router, cfg config) {
mode := valueobject.MtaStsMode(cfg.MtaStsMode)
switch mode {
case valueobject.MtaStsModeTesting, valueobject.MtaStsModeEnforce, valueobject.MtaStsModeNone:
router.SetMtaStsMode(mode)
log.Printf("lambdamail-protocols MTA-STS policy mode: %s", mode)
default:
log.Printf("WARNING: ignoring unknown MTA_STS_MODE %q, serving %q", mode, valueobject.MtaStsModeTesting)
}
}
// startSMTPListeners brings up the three SMTP ports of PLAN.md section 4. The
// MX port and the submission ports run different backends: port 25 never
// authenticates and only accepts local mail, while submission always
// authenticates and relays outward.
func startSMTPListeners(cfg config, certProvider port.CertProvider, inboundUC *usecase.ProcessInboundEmailUseCase, submissionUC *usecase.ProcessOutboundEmailUseCase) {
tlsConfig := mailTLSConfig(certProvider)
// Port 25: STARTTLS is always advertised but never required. RFC 5321
// obliges an MX to accept cleartext, and refusing it loses legitimate
// mail without improving any audit score (PLAN.md section 4).
mx := gosmtp.NewServer(smtppresentation.NewBackend(inboundUC))
mx.Addr = ":25"
mx.Domain = cfg.PrimaryMailHost
mx.MaxMessageBytes = cfg.MaxMessageBytes
mx.MaxRecipients = 100
mx.ReadTimeout = 5 * time.Minute
mx.WriteTimeout = 5 * time.Minute
mx.TLSConfig = tlsConfig
serveInBackground("SMTP MX", ":25", mx.ListenAndServe)
// Port 587: submission over STARTTLS. AllowInsecureAuth stays false, so
// go-smtp withholds the AUTH capability until the connection is
// encrypted - credentials never travel in the clear.
submission := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC))
submission.Addr = ":587"
submission.Domain = cfg.PrimaryMailHost
submission.MaxMessageBytes = cfg.MaxMessageBytes
submission.MaxRecipients = 100
submission.ReadTimeout = 5 * time.Minute
submission.WriteTimeout = 5 * time.Minute
submission.TLSConfig = tlsConfig
submission.AllowInsecureAuth = false
serveInBackground("SMTP submission", ":587", submission.ListenAndServe)
// Port 465: implicit TLS (RFC 8314), the recommended submission port.
submissions := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC))
submissions.Addr = ":465"
submissions.Domain = cfg.PrimaryMailHost
submissions.MaxMessageBytes = cfg.MaxMessageBytes
submissions.MaxRecipients = 100
submissions.ReadTimeout = 5 * time.Minute
submissions.WriteTimeout = 5 * time.Minute
submissions.TLSConfig = tlsConfig
serveInBackground("SMTPS submission", ":465", submissions.ListenAndServeTLS)
}
func startIMAPListeners(cfg config, certProvider port.CertProvider, imapUseCase *usecase.ImapSessionUseCase) {
newServer := func() *imapserver.Server {
return imapserver.New(&imapserver.Options{
NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
return imappresentation.NewSession(c, imapUseCase)
},
Caps: imap.CapSet{
imap.CapIMAP4rev1: {},
imap.CapMove: {},
imap.CapUIDPlus: {},
imap.CapIdle: {},
imap.CapCondStore: {},
},
TLSConfig: mailTLSConfig(certProvider),
// LOGINDISABLED is advertised before TLS: the client must
// upgrade before it may send credentials (PLAN.md section 4).
InsecureAuth: false,
})
}
serveInBackground("IMAP", ":143", func() error { return newServer().ListenAndServe(":143") })
serveInBackground("IMAPS", ":993", func() error {
return serveImplicitTLS(":993", mailTLSConfig(certProvider), newServer().Serve)
})
}
func startPOP3Listeners(certProvider port.CertProvider, pop3UseCase *usecase.Pop3SessionUseCase) {
serveInBackground("POP3", ":110", pop3presentation.NewServer(":110", pop3UseCase, certProvider).ListenAndServe)
serveInBackground("POP3S", ":995", func() error {
server := pop3presentation.NewServer(":995", pop3UseCase, certProvider)
return serveImplicitTLS(":995", mailTLSConfig(certProvider), server.Serve)
})
}
func startManageSieveListener(certProvider port.CertProvider, sieveUseCase *usecase.ManageSieveSessionUseCase) {
serveInBackground("ManageSieve", ":4190", managesievepresentation.NewServer(":4190", sieveUseCase, certProvider).ListenAndServe)
}
// serveImplicitTLS wraps a plain listener in TLS, which is what the implicit
// ports (465, 993, 995) need: the handshake happens before any protocol
// greeting rather than after a STARTTLS command.
func serveImplicitTLS(addr string, tlsConfig *tls.Config, serve func(net.Listener) error) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return serve(tls.NewListener(ln, tlsConfig))
}
// serveInBackground runs a listener and logs a fatal error if it dies. A
// protocol port that silently fails to bind would look healthy while being
// unreachable, so failure is loud.
func serveInBackground(name, addr string, serve func() error) {
go func() {
log.Printf("lambdamail-protocols %s listening on %s", name, addr)
if err := serve(); err != nil {
log.Fatalf("%s serve on %s: %v", name, addr, err)
}
}()
}
// mailTLSConfig is the cipher policy of PLAN.md section 4: TLS 1.2 floor,
// forward secrecy only, and the three curves named there.
func mailTLSConfig(certProvider port.CertProvider) *tls.Config {
return &tls.Config{
GetCertificate: certProvider.GetCertificate,
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
@@ -93,99 +437,120 @@ func main() {
},
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384},
}
}
go func() {
log.Printf("lambdamail-protocols SMTP listening on %s", smtpServer.Addr)
if err := smtpServer.ListenAndServe(); err != nil {
log.Fatalf("smtp serve: %v", err)
}
}()
func runDeliveryWorker(ctx context.Context, worker *usecase.OutboundWorkerUseCase) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
authRepo := postgres.NewAuthRepository(pool)
imapFolders := postgres.NewImapFolderRepository(pool)
messageQuery := postgres.NewMessageQueryRepository(pool)
flagRepo := postgres.NewFlagRepository(pool)
blobReader := diskstorage.NewLocalDiskBlobReader(pool)
expungeRepo := postgres.NewExpungeRepository(pool)
copyRepo := postgres.NewCopyRepository(pool)
imapUseCase := usecase.NewImapSessionUseCase(authRepo, imapFolders, messageQuery, flagRepo, blobReader, expungeRepo, copyRepo)
useCase.SetTrackerManager(imapUseCase.GetTrackerManager(), imapFolders)
imapServer := imapserver.New(&imapserver.Options{
NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
return imappresentation.NewSession(c, imapUseCase)
},
Caps: imap.CapSet{
imap.CapIMAP4rev1: {},
imap.CapMove: {},
imap.CapUIDPlus: {},
imap.CapIdle: {},
imap.CapCondStore: {},
},
TLSConfig: &tls.Config{GetCertificate: certProvider.GetCertificate, MinVersion: tls.VersionTLS12},
InsecureAuth: false,
})
go func() {
log.Printf("lambdamail-protocols IMAP listening on :143")
if err := imapServer.ListenAndServe(":143"); err != nil {
log.Fatalf("imap serve: %v", err)
}
}()
pop3UseCase := usecase.NewPop3SessionUseCase(authRepo, imapFolders, messageQuery, blobReader, expungeRepo)
pop3Server := pop3presentation.NewServer(":110", pop3UseCase, certProvider)
go func() {
log.Printf("lambdamail-protocols POP3 listening on :110")
if err := pop3Server.ListenAndServe(); err != nil {
log.Fatalf("pop3 serve: %v", err)
}
}()
sieveRepo := postgres.NewSieveRepository(pool)
sieveUseCase := usecase.NewManageSieveSessionUseCase(authRepo, sieveRepo)
sieveServer := managesievepresentation.NewServer(":4190", sieveUseCase, certProvider)
go func() {
log.Printf("lambdamail-protocols ManageSieve listening on :4190")
if err := sieveServer.ListenAndServe(); err != nil {
log.Fatalf("managesieve serve: %v", err)
}
}()
reportRepo := postgres.NewReportRepository(pool)
reportUseCase := usecase.NewIngestReportsUseCase(reportRepo)
outboundRepo := postgres.NewOutboundRepository(pool)
mxResolver := netdns.NewNetMXResolver()
outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, useCase, mailboxes, smtpServer.Domain)
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_, _ = outboundWorker.ProcessBatch(ctx, "protocols-worker-1", 10)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := worker.ProcessBatch(ctx, "protocols-worker-1", 10); err != nil {
log.Printf("delivery worker batch failed: %v", err)
}
}
}()
router := httppresentation.NewRouter(reportUseCase, func() error { return pool.Ping(ctx) })
addr := ":8080"
log.Printf("lambdamail-protocols HTTP service listening on %s", addr)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("http serve: %v", err)
}
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
// runDnsReconciler provisions the DKIM keys and reconciles the zone, then
// repeats on the interval of PLAN.md section 7.5 to detect drift.
func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAliasRepository, dkimRepo *postgres.DkimRepository) {
syncUC := usecase.NewSyncDnsRecordsUseCase(cloudflare.NewCloudflareAdapter(cfg.CloudflareToken), aliasRepo)
syncUC.SetVerifier(netdns.NewPublicVerifier())
var provisioner *usecase.ProvisionDkimKeysUseCase
if dkimRepo != nil {
provisioner = usecase.NewProvisionDkimKeysUseCase(dkimRepo, generateDkimKey)
}
reconcile := func() {
domain := cfg.domain()
input := usecase.SyncDnsRecordsInput{
DomainName: domain,
MailHost: cfg.PrimaryMailHost,
ServerIPv4: cfg.PublicIPv4,
ServerIPv6: cfg.PublicIPv6,
DaneEnabled: cfg.OutboundDane,
// A smarthost sends on the domain's behalf, so the hard-fail SPF
// has to authorise it or every relayed message fails
// (PLAN.md section 10.4).
RelaySpfInclude: relayConfig(cfg).SpfInclude(),
}
if provisioner != nil {
keys, err := provisioner.Execute(ctx, domain)
if err != nil {
log.Printf("DNS reconcile: could not provision DKIM keys for %s: %v", domain, err)
return
}
input.RsaDkimPubKey = keys.RsaPublicKey
input.EdDkimPubKey = keys.Ed25519PublicKey
if len(keys.Created) > 0 {
log.Printf("DNS reconcile: generated DKIM keys for %s: %v", domain, keys.Created)
}
}
out, err := syncUC.Execute(ctx, input)
if err != nil {
log.Printf("DNS reconcile for %s failed: %v", domain, err)
return
}
log.Printf("DNS reconcile for %s: %s (created=%d updated=%d unchanged=%d conflicts=%d)",
domain, out.Status, out.CreatedCount, out.UpdatedCount, out.UnchangedCount, out.ConflictCount)
// Conflicts and unresolvable records are reported individually
// because each needs a different operator action.
for _, conflict := range out.Conflicts {
log.Printf("DNS conflict (left untouched): %s", conflict)
}
for _, unverified := range out.Unverified {
log.Printf("DNS record not yet visible to public resolvers: %s", unverified)
}
for _, e := range out.Errors {
log.Printf("DNS reconcile error: %s", e)
}
}
reconcile()
ticker := time.NewTicker(cfg.DnsSyncInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
reconcile()
}
}
}
// generateDkimKey adapts the key generator to the use case's signature,
// keeping the crypto in the infrastructure layer.
func generateDkimKey(algorithm string) ([]byte, string, error) {
generated, err := dkim.Generate(algorithm)
if err != nil {
return nil, "", err
}
return generated.PrivateKeyPEM, generated.PublicKeyBase64, nil
}
func runHealthcheck() {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("http://127.0.0.1:8080/health")
if err != nil {
fmt.Fprintf(os.Stderr, "healthcheck failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "healthcheck failed: HTTP %d\n", resp.StatusCode)
os.Exit(1)
}
return fallback
}
@@ -0,0 +1,112 @@
package main
import (
"context"
"fmt"
"net"
"net/http"
"os"
"time"
"lambdamail/protocols/internal/application/usecase"
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
)
// runPreflight executes the environment checks of PLAN.md section 15 and
// exits non-zero when a critical one fails, so the deploy pipeline can gate
// on it.
func runPreflight(cfg config) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
report := usecase.NewPreflightUseCase(buildPreflightDeps(cfg)).Execute(ctx, usecase.PreflightInput{
PrimaryMailHost: cfg.PrimaryMailHost,
MailDomain: cfg.domain(),
PublicIPv4: cfg.PublicIPv4,
PublicIPv6: cfg.PublicIPv6,
DaneEnabled: cfg.OutboundDane,
TLSMode: cfg.TLSMode,
SmarthostSet: cfg.RelayHost != "",
})
fmt.Print(report)
if report.Blocking() {
fmt.Fprintln(os.Stderr, "\npreflight FAILED: at least one critical check did not pass")
os.Exit(1)
}
fmt.Println("\npreflight passed")
}
func buildPreflightDeps(cfg config) usecase.PreflightDeps {
resolver := net.DefaultResolver
deps := usecase.PreflightDeps{
DialPort25: func(ctx context.Context, address string) error {
dialer := net.Dialer{Timeout: 10 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return err
}
return conn.Close()
},
LookupAddr: resolver.LookupAddr,
LookupHost: resolver.LookupHost,
LookupTXT: resolver.LookupTXT,
FetchMtaSts: func(ctx context.Context, domain string) error {
url := fmt.Sprintf("https://mta-sts.%s/.well-known/mta-sts.txt", domain)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
// The certificate is validated on purpose: RFC 8461 section 3.3
// requires the policy to be served under a valid certificate for
// the mta-sts host, and a sender that cannot validate it ignores
// the policy entirely.
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil
},
FreeDiskPercent: func() (float64, error) { return freeDiskPercent(cfg.SpoolDir) },
Now: time.Now,
NetworkTime: httpDateTime,
}
// The certificate check only means something when a store is configured;
// otherwise it stays skipped rather than reporting a false pass.
if cfg.TraefikAcmeDir != "" {
if watcher, err := tlsprovider.NewAcmeCertWatcher(cfg.TraefikAcmeDir, cfg.TraefikAcmeFile, cfg.PrimaryMailHost); err == nil {
deps.HasCertificateFor = watcher.HasCertificateFor
}
}
return deps
}
// httpDateTime reads a trusted clock from the Date header of a well-known
// HTTPS endpoint. It is not NTP, but it is accurate to about a second, which
// is enough to catch the drift that breaks DKIM and TOTP.
func httpDateTime(ctx context.Context) (time.Time, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, "https://www.cloudflare.com/", nil)
if err != nil {
return time.Time{}, err
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return time.Time{}, err
}
defer resp.Body.Close()
dateHeader := resp.Header.Get("Date")
if dateHeader == "" {
return time.Time{}, fmt.Errorf("no Date header in the response")
}
return http.ParseTime(dateHeader)
}
+18 -9
View File
@@ -3,24 +3,33 @@ module lambdamail/protocols
go 1.25.0
require (
blitiri.com.ar/go/spf v1.5.1
github.com/alexedwards/argon2id v1.0.0
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2
github.com/emersion/go-msgauth v0.7.0
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/fergusstrange/embedded-postgres v1.30.0
github.com/go-acme/lego/v4 v4.35.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/miekg/dns v1.1.72
golang.org/x/crypto v0.54.0
)
require (
github.com/alexedwards/argon2id v1.0.0 // indirect
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/fergusstrange/embedded-postgres v1.30.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
)
+34 -9
View File
@@ -1,18 +1,30 @@
blitiri.com.ar/go/spf v1.5.1 h1:CWUEasc44OrANJD8CzceRnRn1Jv0LttY68cYym2/pbE=
blitiri.com.ar/go/spf v1.5.1/go.mod h1:E71N92TfL4+Yyd5lpKuE9CAF2pd4JrUq1xQfkTxoNdk=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/fergusstrange/embedded-postgres v1.30.0 h1:ewv1e6bBlqOIYtgGgRcEnNDpfGlmfPxB8T3PO9tV68Q=
github.com/fergusstrange/embedded-postgres v1.30.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk=
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -25,8 +37,11 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -35,22 +50,29 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -58,8 +80,9 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -72,12 +95,14 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -6,10 +6,15 @@ import (
"github.com/google/uuid"
)
// MailboxAuth is the subset of mailbox data needed to verify a login.
// MailboxAuth is the subset of mailbox data needed to verify a login and,
// once authenticated, to apply the sender policy of PLAN.md section 5.2.
type MailboxAuth struct {
ID uuid.UUID
PasswordHash string
EmailAddress string
DomainName string
// MaxRecipientsPerHour caps a compromised account's blast radius.
MaxRecipientsPerHour int
}
// AuthRepository resolves a login address to its stored credential.
@@ -4,11 +4,11 @@ import "context"
// ImapFolderRecord is the IMAP-relevant subset of a folder row.
type ImapFolderRecord struct {
ID string
Name string
UIDNext uint32
UIDValidity uint32
NumMessages uint32
ID string
Name string
UIDNext uint32
UIDValidity uint32
NumMessages uint32
HighestModSeq uint64
}
@@ -2,6 +2,9 @@ package port
import (
"context"
"time"
"github.com/google/uuid"
"lambdamail/protocols/internal/domain/entity"
)
@@ -10,4 +13,9 @@ type OutboundRepository interface {
Enqueue(ctx context.Context, job *entity.OutboundJob) error
FetchNextReady(ctx context.Context, workerID string, limit int) ([]*entity.OutboundJob, error)
UpdateJob(ctx context.Context, job *entity.OutboundJob) error
// CountRecipientsSince reports how many recipients a mailbox has queued
// since a point in time. It backs the per-mailbox send limit that keeps a
// compromised account from becoming a spam source (PLAN.md section 5.2).
CountRecipientsSince(ctx context.Context, mailboxID uuid.UUID, since time.Time) (int, error)
}
@@ -0,0 +1,140 @@
package usecase
import (
"bytes"
"context"
"net"
"strings"
"testing"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
)
type stubAuthenticator struct {
result port.InboundAuthResult
seen port.InboundAuthInput
}
func (s *stubAuthenticator) Authenticate(_ context.Context, input port.InboundAuthInput) port.InboundAuthResult {
s.seen = input
return s.result
}
func inboundInput(recipients int) ProcessInboundEmailInput {
in := ProcessInboundEmailInput{
Sender: "sender@sender.test",
Body: bytes.NewBufferString("From: sender@sender.test\r\n\r\nbody"),
}
for i := 0; i < recipients; i++ {
in.Recipients = append(in.Recipients, port.MailboxRecord{ID: uuid.New()})
in.RecipientAddresses = append(in.RecipientAddresses, "rcpt@example.test")
}
return in
}
// The verdicts must reach the database, otherwise the webmail and the DMARC
// dashboard have nothing to show (PLAN.md section 6.1).
func TestUseCase_Handle_PersistsAuthenticationVerdicts(t *testing.T) {
messages := &recordingMessageRepository{}
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
uc.SetAuthenticator(&stubAuthenticator{result: port.InboundAuthResult{
SPF: port.AuthResultPass, DKIM: port.AuthResultPass, DMARC: port.AuthResultPass,
}})
if err := uc.Handle(context.Background(), inboundInput(1)); err != nil {
t.Fatalf("Handle: %v", err)
}
got := messages.persisted[0]
if got.SPFResult != "pass" || got.DKIMResult != "pass" || got.DMARCResult != "pass" {
t.Errorf("stored verdicts = %q/%q/%q, want pass/pass/pass", got.SPFResult, got.DKIMResult, got.DMARCResult)
}
}
// RFC 8601: our own checks must be recorded in the delivered message so the
// spam filter and the user can see them.
func TestUseCase_Handle_PrependsAuthenticationResultsHeader(t *testing.T) {
blobs := &capturingBlobStorage{}
uc := NewProcessInboundEmailUseCase(nil, blobs, &recordingMessageRepository{})
uc.SetAuthenticator(&stubAuthenticator{result: port.InboundAuthResult{
SPF: port.AuthResultPass, DKIM: port.AuthResultNone, DMARC: port.AuthResultPass,
AuthenticationResults: "mail.lambdamail.test; spf=pass; dmarc=pass",
}})
if err := uc.Handle(context.Background(), inboundInput(1)); err != nil {
t.Fatalf("Handle: %v", err)
}
if !strings.HasPrefix(string(blobs.stored), "Authentication-Results: mail.lambdamail.test") {
t.Errorf("stored message does not lead with Authentication-Results:\n%s", blobs.stored)
}
}
// A sender that published p=reject asked for exactly this. Accepting the
// message instead would defeat the policy the sender is paying for.
func TestUseCase_Handle_RejectsOnDmarcFailWithRejectPolicy(t *testing.T) {
messages := &recordingMessageRepository{}
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
uc.SetAuthenticator(&stubAuthenticator{result: port.InboundAuthResult{
SPF: port.AuthResultFail, DKIM: port.AuthResultNone,
DMARC: port.AuthResultFail, DmarcPolicy: "reject",
}})
err := uc.Handle(context.Background(), inboundInput(1))
if err == nil {
t.Fatal("expected the message to be refused")
}
if !strings.HasPrefix(err.Error(), "550 ") {
t.Errorf("error = %q, want a permanent 550 rejection", err)
}
if len(messages.persisted) != 0 {
t.Error("a DMARC-rejected message was stored anyway")
}
}
// Quarantine is a filing decision, not a refusal: the message is accepted and
// the spam filter decides where it lands.
func TestUseCase_Handle_AcceptsOnDmarcFailWithQuarantinePolicy(t *testing.T) {
messages := &recordingMessageRepository{}
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, messages)
uc.SetAuthenticator(&stubAuthenticator{result: port.InboundAuthResult{
SPF: port.AuthResultFail, DMARC: port.AuthResultFail, DmarcPolicy: "quarantine",
}})
if err := uc.Handle(context.Background(), inboundInput(1)); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(messages.persisted) != 1 {
t.Errorf("persisted %d messages, want 1", len(messages.persisted))
}
}
// SPF is defined over the peer address and the HELO name, so both have to
// reach the authenticator.
func TestUseCase_Handle_PassesPeerIdentityToAuthenticator(t *testing.T) {
auth := &stubAuthenticator{result: port.InboundAuthResult{SPF: port.AuthResultNone}}
uc := NewProcessInboundEmailUseCase(nil, &capturingBlobStorage{}, &recordingMessageRepository{})
uc.SetAuthenticator(auth)
in := inboundInput(1)
in.ClientIP = parseTestIP("192.0.2.10")
in.HeloDomain = "mx.sender.test"
if err := uc.Handle(context.Background(), in); err != nil {
t.Fatalf("Handle: %v", err)
}
if auth.seen.ClientIP.String() != "192.0.2.10" {
t.Errorf("ClientIP = %v, want 192.0.2.10", auth.seen.ClientIP)
}
if auth.seen.HeloDomain != "mx.sender.test" {
t.Errorf("HeloDomain = %q, want mx.sender.test", auth.seen.HeloDomain)
}
if auth.seen.EnvelopeFrom != "sender@sender.test" {
t.Errorf("EnvelopeFrom = %q", auth.seen.EnvelopeFrom)
}
}
func parseTestIP(s string) net.IP { return net.ParseIP(s) }
@@ -0,0 +1,99 @@
package usecase
import (
"bytes"
"context"
"io"
"strings"
"testing"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/valueobject"
)
type capturingBlobStorage struct {
stored []byte
}
func (c *capturingBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobRef, error) {
b, err := io.ReadAll(r)
c.stored = b
return port.BlobRef{ID: uuid.New(), SizeBytes: int64(len(b))}, err
}
type stubScanner struct {
result *valueobject.ScanResult
}
func (s *stubScanner) Scan(_ context.Context, _ port.ScanInput) (*valueobject.ScanResult, error) {
return s.result, nil
}
// The scan verdict is only actionable by the user - and by their Sieve rules -
// if it reaches the stored message. PLAN.md section 6.1 persists the EML
// together with the verdicts, so the spam headers the scanner produced must be
// present in the delivered message.
func TestUseCase_Handle_WritesScanHeadersIntoStoredMessage(t *testing.T) {
blobs := &capturingBlobStorage{}
scanner := &stubScanner{result: &valueobject.ScanResult{
Verdict: valueobject.ScanVerdictSpamJunk,
Score: 9.5,
HeadersToAdd: map[string]string{
"X-Spam-Flag": "YES",
"X-Spam-Score": "9.50",
},
}}
uc := NewProcessInboundEmailUseCase(nil, blobs, &recordingMessageRepository{})
uc.SetScanner(scanner)
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Sender: "spammer@example.test",
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
RecipientAddresses: []string{"victim@example.test"},
Body: bytes.NewBufferString("Subject: buy now\r\n\r\nbody"),
})
if err != nil {
t.Fatalf("Handle: %v", err)
}
stored := string(blobs.stored)
for _, want := range []string{"X-Spam-Flag: YES", "X-Spam-Score: 9.50"} {
if !strings.Contains(stored, want) {
t.Errorf("stored message is missing %q:\n%s", want, stored)
}
}
if !strings.Contains(stored, "Subject: buy now") {
t.Errorf("original headers were lost:\n%s", stored)
}
// Added headers must precede the original ones and stay inside the header
// block, i.e. before the first empty line.
headerBlock, _, found := strings.Cut(stored, "\r\n\r\n")
if !found {
t.Fatalf("stored message has no header/body separator:\n%s", stored)
}
if !strings.Contains(headerBlock, "X-Spam-Flag: YES") {
t.Errorf("scan headers leaked into the body:\n%s", stored)
}
}
func TestUseCase_Handle_LeavesMessageUntouchedWithoutScanHeaders(t *testing.T) {
blobs := &capturingBlobStorage{}
uc := NewProcessInboundEmailUseCase(nil, blobs, &recordingMessageRepository{})
original := "Subject: hi\r\n\r\nbody"
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
RecipientAddresses: []string{"a@example.test"},
Body: bytes.NewBufferString(original),
})
if err != nil {
t.Fatalf("Handle: %v", err)
}
if string(blobs.stored) != original {
t.Errorf("message was rewritten without a scanner: got %q, want %q", blobs.stored, original)
}
}
@@ -12,10 +12,10 @@ import (
)
var (
ErrScriptNotFound = errors.New("script does not exist")
ErrActiveScriptDelete = errors.New("cannot delete active script")
ErrScriptNotFound = errors.New("script does not exist")
ErrActiveScriptDelete = errors.New("cannot delete active script")
ErrScriptAlreadyExists = errors.New("script with new name already exists")
ErrScriptQuotaExceeded = errors.New("script size exceeds storage limit")
ErrScriptQuotaExceeded = errors.New("script size exceeds storage limit")
)
const MaxScriptSizeBytes = 1024 * 1024 // 1 MB limit per Sieve script
@@ -135,7 +135,7 @@ func TestManageSieveSessionUseCase_Put_Get_List_SetActive_Delete(t *testing.T) {
t.Fatalf("SetActive failed: %v", err)
}
// 6. Delete active script fails per RFC 5804 §2.7
// 6. Delete active script fails per RFC 5804 section 2.7
if err := uc.DeleteScript(ctx, gotID, "main_rules"); !errors.Is(err, ErrActiveScriptDelete) {
t.Fatalf("expected ErrActiveScriptDelete, got %v", err)
}
@@ -0,0 +1,168 @@
package usecase
import (
"bufio"
"context"
"net"
"strings"
"testing"
"time"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
)
// startFakeMX serves one SMTP conversation, answering MAIL FROM with the given
// reply so the worker can be driven through a real permanent/temporary failure.
func startFakeMX(t *testing.T, mailReply string) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
c.SetDeadline(time.Now().Add(5 * time.Second))
r := bufio.NewReader(c)
c.Write([]byte("220 fake.mx ESMTP\r\n"))
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
c.Write([]byte("250 fake.mx\r\n"))
case strings.HasPrefix(cmd, "MAIL FROM"):
c.Write([]byte(mailReply + "\r\n"))
case strings.HasPrefix(cmd, "QUIT"):
c.Write([]byte("221 Bye\r\n"))
return
default:
c.Write([]byte("250 Ok\r\n"))
}
}
}(conn)
}
}()
return ln.Addr().String()
}
func newTestJob() *entity.OutboundJob {
mbID := uuid.New()
return &entity.OutboundJob{
ID: uuid.New(),
MailboxID: &mbID,
BlobID: uuid.New(),
EnvelopeFrom: "user@domain.test",
EnvelopeTo: "rcpt@remote.test",
DestinationDomain: "remote.test",
Status: entity.OutboundJobStatusQueued,
ExpiresAt: time.Now().Add(5 * 24 * time.Hour),
}
}
// PLAN.md section 6.3: a 5xx is a permanent failure and must bounce
// immediately with a DSN, not sit in the queue retrying for five days.
func TestOutboundWorker_PermanentSmtpFailureBouncesImmediately(t *testing.T) {
addr := startFakeMX(t, "550 5.1.1 User unknown")
repo := &fakeOutboundRepo{}
repo.Enqueue(context.Background(), newTestJob())
worker := NewOutboundWorkerUseCase(
repo,
&fakeMXResolver{hosts: []string{addr}},
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
nil, nil, "mail.local",
)
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
got := repo.jobs[0]
if got.Status != entity.OutboundJobStatusBounced {
t.Errorf("status = %s, want BOUNCED for a 5xx reply", got.Status)
}
if got.LastSmtpCode != "550" {
t.Errorf("LastSmtpCode = %q, want %q", got.LastSmtpCode, "550")
}
}
// A 4xx is temporary and must be deferred for a later retry.
func TestOutboundWorker_TemporarySmtpFailureIsDeferred(t *testing.T) {
addr := startFakeMX(t, "451 4.3.0 Try later")
repo := &fakeOutboundRepo{}
repo.Enqueue(context.Background(), newTestJob())
worker := NewOutboundWorkerUseCase(
repo,
&fakeMXResolver{hosts: []string{addr}},
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
nil, nil, "mail.local",
)
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
got := repo.jobs[0]
if got.Status != entity.OutboundJobStatusDeferred {
t.Errorf("status = %s, want DEFERRED for a 4xx reply", got.Status)
}
if got.LastSmtpCode != "451" {
t.Errorf("LastSmtpCode = %q, want %q", got.LastSmtpCode, "451")
}
}
// The DSN is delivered back to the original sender. When that sender address is
// an alias fanning out to several mailboxes, the recipient records and their
// envelope addresses must stay aligned - otherwise the delivery indexes past
// the end of the address list and takes the worker down.
func TestOutboundWorker_DsnToFanOutSenderDeliversToEveryTarget(t *testing.T) {
addr := startFakeMX(t, "550 5.1.1 User unknown")
repo := &fakeOutboundRepo{}
repo.Enqueue(context.Background(), newTestJob())
mailboxes := &fakeAliasFanOutRepository{targets: []port.MailboxRecord{
{ID: uuid.New(), QuotaBytes: 1000},
{ID: uuid.New(), QuotaBytes: 1000},
}}
messages := &recordingMessageRepository{}
inbound := NewProcessInboundEmailUseCase(mailboxes, &stubBlobStorage{ref: port.BlobRef{ID: uuid.New()}}, messages)
worker := NewOutboundWorkerUseCase(
repo,
&fakeMXResolver{hosts: []string{addr}},
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
inbound, mailboxes, "mail.local",
)
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
if len(messages.persisted) != 2 {
t.Fatalf("DSN persisted to %d mailboxes, want 2 (one per fan-out target)", len(messages.persisted))
}
for i, p := range messages.persisted {
if p.RecipientAddress != "user@domain.test" {
t.Errorf("persisted[%d].RecipientAddress = %q, want the original sender", i, p.RecipientAddress)
}
}
}
@@ -0,0 +1,190 @@
package usecase
import (
"bufio"
"context"
"fmt"
"net"
"strings"
"testing"
"time"
"lambdamail/protocols/internal/domain/entity"
)
// startPlaintextMX serves an SMTP conversation that never offers STARTTLS,
// which is what a downgrade attack looks like from our side.
func startPlaintextMX(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
c.SetDeadline(time.Now().Add(5 * time.Second))
r := bufio.NewReader(c)
c.Write([]byte("220 plain.mx ESMTP\r\n"))
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(cmd, "EHLO"):
// Deliberately no STARTTLS in the extension list.
c.Write([]byte("250-plain.mx\r\n250 SIZE 10240000\r\n"))
case cmd == "DATA":
c.Write([]byte("354 End data with <CR><LF>.<CR><LF>\r\n"))
for {
body, err := r.ReadString('\n')
if err != nil {
return
}
if strings.TrimSpace(body) == "." {
break
}
}
c.Write([]byte("250 2.0.0 Ok: queued\r\n"))
case strings.HasPrefix(cmd, "QUIT"):
c.Write([]byte("221 Bye\r\n"))
return
default:
c.Write([]byte("250 Ok\r\n"))
}
}
}(conn)
}
}()
return ln.Addr().String()
}
type stubPolicyResolver struct {
policy entity.TLSPolicy
err error
}
func (s stubPolicyResolver) Resolve(_ context.Context, _ string, _ string) (entity.TLSPolicy, error) {
return s.policy, s.err
}
func runWorkerWithPolicy(t *testing.T, mxAddr string, resolver stubPolicyResolver) *entity.OutboundJob {
t.Helper()
repo := &fakeOutboundRepo{}
repo.Enqueue(context.Background(), newTestJob())
worker := NewOutboundWorkerUseCase(
repo,
&fakeMXResolver{hosts: []string{mxAddr}},
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
nil, nil, "mail.local",
)
worker.SetPolicyResolver(resolver)
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
return repo.jobs[0]
}
// PLAN.md section 6.2: when the destination's policy demands validated TLS
// and it cannot be had, the message is deferred - never delivered in the
// clear. This is the anti-downgrade guarantee.
func TestOutboundWorker_DefersRatherThanDowngradeUnderEnforcePolicy(t *testing.T) {
addr := startPlaintextMX(t)
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
policy: entity.NewMtaStsPolicy([]string{"*"}, true),
})
if got.Status != entity.OutboundJobStatusDeferred {
t.Errorf("status = %s, want DEFERRED", got.Status)
}
if got.LastSmtpCode != "451" {
t.Errorf("LastSmtpCode = %q, want 451", got.LastSmtpCode)
}
if !strings.Contains(got.LastError, "4.7.5") {
t.Errorf("LastError should carry the 4.7.5 status: %q", got.LastError)
}
if got.TlsPolicyUsed != "none" {
t.Errorf("TlsPolicyUsed = %q, want none", got.TlsPolicyUsed)
}
}
// The same MX, with no policy published, is delivered to over plaintext:
// refusing here would lose legitimate mail (RFC 7435).
func TestOutboundWorker_DeliversOpportunisticallyWithoutPolicy(t *testing.T) {
addr := startPlaintextMX(t)
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
policy: entity.NewTLSPolicy(false, false),
})
if got.Status != entity.OutboundJobStatusDelivered {
t.Errorf("status = %s, want DELIVERED (last error: %s)", got.Status, got.LastError)
}
if got.TlsPolicyUsed != entity.TLSModeOpportunistic {
t.Errorf("TlsPolicyUsed = %q, want opportunistic", got.TlsPolicyUsed)
}
}
// An MX outside the policy's mx set must not be used at all
// (RFC 8461 section 4.1).
func TestOutboundWorker_RefusesMxOutsidePolicy(t *testing.T) {
addr := startPlaintextMX(t)
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
policy: entity.NewMtaStsPolicy([]string{"only.allowed.test"}, true),
})
if got.Status != entity.OutboundJobStatusDeferred {
t.Errorf("status = %s, want DEFERRED", got.Status)
}
if !strings.Contains(got.LastError, "not listed in the MTA-STS policy") {
t.Errorf("LastError = %q, want it to name the policy mismatch", got.LastError)
}
}
// A policy that exists but cannot be evaluated is a reason to wait, not to
// downgrade.
func TestOutboundWorker_DefersWhenPolicyCannotBeResolved(t *testing.T) {
addr := startPlaintextMX(t)
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
err: fmt.Errorf("TLSA records are not DNSSEC-validated"),
})
if got.Status != entity.OutboundJobStatusDeferred {
t.Errorf("status = %s, want DEFERRED", got.Status)
}
if !strings.Contains(got.LastError, "DNSSEC") {
t.Errorf("LastError = %q, want the resolution failure to be recorded", got.LastError)
}
}
// A testing-mode policy reports but must not block delivery.
func TestOutboundWorker_TestingModePolicyStillDelivers(t *testing.T) {
addr := startPlaintextMX(t)
got := runWorkerWithPolicy(t, addr, stubPolicyResolver{
policy: entity.NewMtaStsPolicy([]string{"only.allowed.test"}, false),
})
if got.Status != entity.OutboundJobStatusDelivered {
t.Errorf("status = %s, want DELIVERED under mode: testing (last error: %s)", got.Status, got.LastError)
}
if got.TlsPolicyUsed != entity.TLSModeMtaSts {
t.Errorf("TlsPolicyUsed = %q, want mta-sts", got.TlsPolicyUsed)
}
}
@@ -4,9 +4,13 @@ import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/smtp"
"net/textproto"
"strconv"
"strings"
"time"
@@ -15,13 +19,35 @@ import (
"lambdamail/protocols/internal/domain/valueobject"
)
// ErrPolicyDowngradeRefused is returned when a destination's published TLS
// policy could not be satisfied. PLAN.md section 6.2 is explicit: the message
// is deferred with 4.7.5, never delivered over an unvalidated channel.
var ErrPolicyDowngradeRefused = errors.New("outbound: refusing to deliver without the TLS validation the destination requires")
type OutboundWorkerUseCase struct {
outboundRepo port.OutboundRepository
mxResolver port.MXResolver
blobReader port.BlobReader
inboundUC *ProcessInboundEmailUseCase
mailboxes port.MailboxRepository
localHost string
outboundRepo port.OutboundRepository
mxResolver port.MXResolver
blobReader port.BlobReader
inboundUC *ProcessInboundEmailUseCase
mailboxes port.MailboxRepository
policyResolver port.TLSPolicyResolver
relay RelayConfig
localHost string
}
// SetRelay routes delivery through a smarthost (PLAN.md section 10.4). This is
// the fallback for hosts whose provider blocks outbound port 25, and it
// replaces MX resolution entirely: every message goes to the relay.
func (w *OutboundWorkerUseCase) SetRelay(relay RelayConfig) {
w.relay = relay
}
// SetPolicyResolver enables DANE and MTA-STS on the delivery path. Without
// one the worker falls back to opportunistic TLS, which is correct for a
// destination that publishes no policy but must never be the choice for one
// that does.
func (w *OutboundWorkerUseCase) SetPolicyResolver(resolver port.TLSPolicyResolver) {
w.policyResolver = resolver
}
func NewOutboundWorkerUseCase(
@@ -72,6 +98,21 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit
return
}
// With a smarthost every message goes to the relay regardless of
// destination, and the relay owns transport security from there on.
if w.relay.Configured() {
if err := w.deliverViaRelay(job.EnvelopeFrom, job.EnvelopeTo, payload); err != nil {
w.finishFailed(ctx, job, err)
return
}
job.Status = entity.OutboundJobStatusDelivered
job.LastError = ""
job.LastSmtpCode = ""
job.TlsPolicyUsed = entity.TLSModeRelay
_ = w.outboundRepo.UpdateJob(ctx, job)
return
}
mxHosts, err := w.mxResolver.LookupMX(ctx, job.DestinationDomain)
if err != nil || len(mxHosts) == 0 {
mxHosts = []string{job.DestinationDomain}
@@ -79,10 +120,28 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit
var lastErr error
delivered := false
policyUsed := entity.TLSModeOpportunistic
for _, mxHost := range mxHosts {
if err := w.deliverToMX(ctx, mxHost, job.EnvelopeFrom, job.EnvelopeTo, payload); err == nil {
policy, err := w.resolvePolicy(ctx, job.DestinationDomain, mxHost)
if err != nil {
// A published policy we cannot evaluate is a reason to wait, not
// a reason to send in the clear.
lastErr = err
continue
}
// RFC 8461 section 4.1: a host outside the policy's mx set must not
// be used while that policy is in force.
if policy.RequiresValidation() && !policy.CoversHost(mxHost) {
lastErr = fmt.Errorf("%w: %s is not listed in the MTA-STS policy of %s",
ErrPolicyDowngradeRefused, mxHost, job.DestinationDomain)
continue
}
if err := w.deliverToMX(ctx, mxHost, job.EnvelopeFrom, job.EnvelopeTo, payload, policy); err == nil {
delivered = true
policyUsed = policy.Effective()
break
} else {
lastErr = err
@@ -92,18 +151,52 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit
if delivered {
job.Status = entity.OutboundJobStatusDelivered
job.LastError = ""
job.TlsPolicyUsed = "opportunistic"
job.LastSmtpCode = ""
job.TlsPolicyUsed = policyUsed
_ = w.outboundRepo.UpdateJob(ctx, job)
return
}
w.finishFailed(ctx, job, lastErr)
}
// finishFailed classifies a delivery failure and records the outcome.
func (w *OutboundWorkerUseCase) finishFailed(ctx context.Context, job *entity.OutboundJob, lastErr error) {
errStr := "delivery failed"
if lastErr != nil {
errStr = lastErr.Error()
}
isPermanent := strings.HasPrefix(errStr, "5")
w.handleFailure(ctx, job, errStr, isPermanent)
// The remote reply code decides the outcome (PLAN.md section 6.3): 5xx is
// a permanent failure that bounces now, anything else - including a dial
// or TLS error, which carries no reply code at all - is retried.
code := smtpReplyCode(lastErr)
if code > 0 {
job.LastSmtpCode = strconv.Itoa(code)
}
// A refused downgrade is always temporary: the destination may fix its
// certificate, and bouncing would hand the attacker the outcome they
// wanted (PLAN.md section 6.2).
if errors.Is(lastErr, ErrPolicyDowngradeRefused) {
job.LastSmtpCode = "451"
job.TlsPolicyUsed = "none"
w.handleFailure(ctx, job, "4.7.5 "+errStr, false)
return
}
w.handleFailure(ctx, job, errStr, code >= 500 && code < 600)
}
// smtpReplyCode extracts the reply code from an error returned by net/smtp,
// which wraps remote replies in *textproto.Error. It returns 0 when the
// failure happened before any reply was read.
func smtpReplyCode(err error) int {
var protoErr *textproto.Error
if errors.As(err, &protoErr) {
return protoErr.Code
}
return 0
}
func (w *OutboundWorkerUseCase) handleFailure(ctx context.Context, job *entity.OutboundJob, errStr string, isPermanent bool) {
@@ -141,20 +234,87 @@ func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.
return
}
if w.mailboxes != nil {
targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom)
if err == nil && len(targets) > 0 {
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
Sender: "postmaster@lambdamail.local",
Recipients: targets,
RecipientAddresses: []string{job.EnvelopeFrom},
Body: bytes.NewReader(dsnBytes),
})
}
if w.mailboxes == nil {
return
}
targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom)
if err != nil || len(targets) == 0 {
return
}
// Handle indexes the two slices in lockstep, so the envelope address is
// repeated once per resolved mailbox. An alias sender fans out to several
// mailboxes under the same address.
addresses := make([]string, len(targets))
for i := range targets {
addresses[i] = job.EnvelopeFrom
}
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
Sender: "postmaster@lambdamail.local",
Recipients: targets,
RecipientAddresses: addresses,
Body: bytes.NewReader(dsnBytes),
})
}
func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, from string, to string, payload []byte) error {
// resolvePolicy asks the resolver what transport security the destination
// requires. With no resolver configured every destination is opportunistic.
func (w *OutboundWorkerUseCase) resolvePolicy(ctx context.Context, destinationDomain, mxHost string) (entity.TLSPolicy, error) {
if w.policyResolver == nil {
return entity.NewTLSPolicy(false, false), nil
}
return w.policyResolver.Resolve(ctx, destinationDomain, mxHost)
}
// tlsConfigFor turns a policy into a TLS configuration.
//
// Opportunistic delivery deliberately skips verification: RFC 7435 prefers an
// unauthenticated encrypted channel to a cleartext one, and the alternative -
// refusing every destination with a self-signed certificate - would lose
// legitimate mail. When a policy is in force the verification is real, and a
// failure surfaces as a refusal to deliver.
func tlsConfigFor(mxHost string, policy entity.TLSPolicy) *tls.Config {
config := &tls.Config{
ServerName: strings.TrimSuffix(mxHost, "."),
MinVersion: tls.VersionTLS12,
}
if !policy.RequiresValidation() {
config.InsecureSkipVerify = true
return config
}
if policy.Effective() == entity.TLSModeDane {
// DANE authenticates the certificate directly against the
// DNSSEC-signed association, so the PKIX chain and the name are not
// what is being trusted (RFC 7672 section 3.1). Verification is done
// by hand below instead of by the standard chain builder.
config.InsecureSkipVerify = true
config.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
chain := make([]*x509.Certificate, 0, len(rawCerts))
for _, raw := range rawCerts {
cert, err := x509.ParseCertificate(raw)
if err != nil {
return fmt.Errorf("parse peer certificate: %w", err)
}
chain = append(chain, cert)
}
if !policy.MatchesCertificate(chain) {
return fmt.Errorf("%w: certificate of %s matches no TLSA record", ErrPolicyDowngradeRefused, mxHost)
}
return nil
}
return config
}
// MTA-STS in enforce mode requires a normal, publicly trusted PKIX
// validation against the MX name (RFC 8461 section 4.1).
return config
}
func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, from string, to string, payload []byte, policy entity.TLSPolicy) error {
addr := mxHost
if _, _, err := net.SplitHostPort(mxHost); err != nil {
addr = net.JoinHostPort(mxHost, "25")
@@ -177,11 +337,20 @@ func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string,
return fmt.Errorf("EHLO failed: %w", err)
}
if ok, _ := client.Extension("STARTTLS"); ok {
config := &tls.Config{InsecureSkipVerify: true, ServerName: mxHost}
if err := client.StartTLS(config); err != nil {
starttlsOffered, _ := client.Extension("STARTTLS")
if starttlsOffered {
if err := client.StartTLS(tlsConfigFor(mxHost, policy)); err != nil {
if policy.RequiresValidation() {
return fmt.Errorf("%w: STARTTLS to %s failed validation: %v", ErrPolicyDowngradeRefused, mxHost, err)
}
return fmt.Errorf("STARTTLS failed: %w", err)
}
} else if policy.RequiresValidation() {
// The destination's own published policy says it speaks TLS. A peer
// that does not offer STARTTLS is either broken or being stripped,
// and handing it the message either way is exactly the downgrade the
// policy exists to prevent.
return fmt.Errorf("%w: %s did not offer STARTTLS", ErrPolicyDowngradeRefused, mxHost)
}
if err := client.Mail(from); err != nil {
@@ -30,6 +30,10 @@ func (f *fakeOutboundRepo) FetchNextReady(_ context.Context, _ string, _ int) ([
return ready, nil
}
func (f *fakeOutboundRepo) CountRecipientsSince(_ context.Context, _ uuid.UUID, _ time.Time) (int, error) {
return 0, nil
}
func (f *fakeOutboundRepo) UpdateJob(_ context.Context, job *entity.OutboundJob) error {
for i, j := range f.jobs {
if j.ID == job.ID {
@@ -0,0 +1,404 @@
package usecase
import (
"context"
"fmt"
"net"
"sort"
"strings"
"time"
)
// Severity mirrors the table in PLAN.md section 15.
type Severity string
const (
SeverityCritical Severity = "CRITICAL"
SeverityHigh Severity = "HIGH"
SeverityMedium Severity = "MEDIUM"
)
// CheckStatus is the outcome of one preflight check.
type CheckStatus string
const (
CheckPassed CheckStatus = "PASS"
CheckFailed CheckStatus = "FAIL"
CheckSkipped CheckStatus = "SKIP"
)
// CheckResult is one line of the preflight report.
type CheckResult struct {
Name string
Status CheckStatus
Severity Severity
Detail string
// Remedy is the operator action that fixes it. Several of these checks
// cover steps PLAN.md section 7.4 lists as not automatable, so telling
// the operator exactly what to do is the point of the check.
Remedy string
}
// PreflightReport aggregates the checks.
type PreflightReport struct {
Results []CheckResult
}
// Blocking reports whether any critical check failed. PLAN.md section 15 uses
// this to keep a domain out of the VERIFIED state.
func (r PreflightReport) Blocking() bool {
for _, result := range r.Results {
if result.Status == CheckFailed && result.Severity == SeverityCritical {
return true
}
}
return false
}
func (r PreflightReport) String() string {
var sb strings.Builder
for _, result := range r.Results {
fmt.Fprintf(&sb, "[%-4s] %-10s %s", result.Status, result.Severity, result.Name)
if result.Detail != "" {
fmt.Fprintf(&sb, "\n %s", result.Detail)
}
if result.Status == CheckFailed && result.Remedy != "" {
fmt.Fprintf(&sb, "\n remedy: %s", result.Remedy)
}
sb.WriteString("\n")
}
return sb.String()
}
// PreflightDeps are the environment probes the checks need. They are injected
// so the whole preflight is testable without touching the network.
type PreflightDeps struct {
// DialPort25 reports whether outbound port 25 reaches a known MX.
DialPort25 func(ctx context.Context, address string) error
// LookupAddr resolves the PTR of an IP.
LookupAddr func(ctx context.Context, ip string) ([]string, error)
// LookupHost resolves a name to addresses.
LookupHost func(ctx context.Context, host string) ([]string, error)
// LookupTXT resolves TXT records, used for the DNSBL checks.
LookupTXT func(ctx context.Context, name string) ([]string, error)
// HasCertificateFor reports whether a certificate is loaded for a host.
HasCertificateFor func(host string) bool
// FetchMtaSts fetches the served MTA-STS policy over HTTPS.
FetchMtaSts func(ctx context.Context, domain string) error
// FreeDiskPercent reports the free space on the mail spool.
FreeDiskPercent func() (float64, error)
// Now is the local clock; drift against NTP breaks DKIM and TOTP.
Now func() time.Time
// NetworkTime returns a trusted time to compare the local clock against.
NetworkTime func(ctx context.Context) (time.Time, error)
}
// PreflightInput describes the deployment being checked.
type PreflightInput struct {
PrimaryMailHost string
MailDomain string
PublicIPv4 string
PublicIPv6 string
DaneEnabled bool
TLSMode string
SmarthostSet bool
}
// PreflightUseCase implements PLAN.md section 15.
type PreflightUseCase struct {
deps PreflightDeps
}
func NewPreflightUseCase(deps PreflightDeps) *PreflightUseCase {
return &PreflightUseCase{deps: deps}
}
// wellKnownMX is the probe target of PLAN.md section 15. Google is used
// because it is the destination most likely to matter and least likely to be
// down.
const wellKnownMX = "gmail-smtp-in.l.google.com:25"
// dnsblZones are the reputation lists PLAN.md section 5.2 names.
var dnsblZones = map[string]string{
"zen.spamhaus.org": "Spamhaus ZEN",
"b.barracudacentral.org": "Barracuda",
"dnsbl.sorbs.net": "SORBS",
}
func (uc *PreflightUseCase) Execute(ctx context.Context, input PreflightInput) PreflightReport {
report := PreflightReport{}
add := func(r CheckResult) { report.Results = append(report.Results, r) }
add(uc.checkPort25(ctx, input))
add(uc.checkReverseDNS(ctx, input))
add(uc.checkForwardConfirmedReverseDNS(ctx, input))
add(uc.checkBlocklists(ctx, input))
add(uc.checkCertificate(input))
add(uc.checkDaneRequiresAcmeMode(input))
add(uc.checkMtaStsEndpoint(ctx, input))
add(uc.checkClockDrift(ctx))
add(uc.checkDiskSpace())
add(uc.checkIPv6Coherence(ctx, input))
return report
}
func (uc *PreflightUseCase) checkPort25(ctx context.Context, input PreflightInput) CheckResult {
const name = "Outbound port 25 reaches a public MX"
if input.SmarthostSet {
return CheckResult{
Name: name, Status: CheckSkipped, Severity: SeverityCritical,
Detail: "a smarthost is configured, so direct delivery on port 25 is not used",
}
}
if uc.deps.DialPort25 == nil {
return skipped(name, SeverityCritical)
}
if err := uc.deps.DialPort25(ctx, wellKnownMX); err != nil {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("could not reach %s: %v", wellKnownMX, err),
Remedy: "most VPS providers block egress on port 25 by default; ask the provider to unblock it, or configure RELAY_HOST to deliver through a smarthost",
}
}
return passed(name, SeverityCritical, "")
}
func (uc *PreflightUseCase) checkReverseDNS(ctx context.Context, input PreflightInput) CheckResult {
const name = "PTR of the public IP resolves to the mail host"
if uc.deps.LookupAddr == nil || input.PublicIPv4 == "" {
return skipped(name, SeverityCritical)
}
names, err := uc.deps.LookupAddr(ctx, input.PublicIPv4)
if err != nil || len(names) == 0 {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("no PTR record for %s", input.PublicIPv4),
Remedy: "set the PTR in the hosting provider's panel; reverse delegation belongs to the owner of the IP block, not to the DNS provider",
}
}
for _, candidate := range names {
if strings.EqualFold(strings.TrimSuffix(candidate, "."), input.PrimaryMailHost) {
return passed(name, SeverityCritical, "")
}
}
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("PTR of %s is %v, want %s", input.PublicIPv4, names, input.PrimaryMailHost),
Remedy: "point the PTR at " + input.PrimaryMailHost + " in the hosting provider's panel",
}
}
func (uc *PreflightUseCase) checkForwardConfirmedReverseDNS(ctx context.Context, input PreflightInput) CheckResult {
const name = "FCrDNS: the mail host resolves back to the public IP"
if uc.deps.LookupHost == nil || input.PublicIPv4 == "" {
return skipped(name, SeverityCritical)
}
addresses, err := uc.deps.LookupHost(ctx, input.PrimaryMailHost)
if err != nil {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("cannot resolve %s: %v", input.PrimaryMailHost, err),
Remedy: "publish the A record for " + input.PrimaryMailHost,
}
}
for _, address := range addresses {
if address == input.PublicIPv4 {
return passed(name, SeverityCritical, "")
}
}
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("%s resolves to %v, not to %s", input.PrimaryMailHost, addresses, input.PublicIPv4),
Remedy: "make the A record and the PTR agree; Gmail and Outlook reject or spam-file mail without forward-confirmed reverse DNS",
}
}
func (uc *PreflightUseCase) checkBlocklists(ctx context.Context, input PreflightInput) CheckResult {
const name = "Public IP is not on a major blocklist"
if uc.deps.LookupTXT == nil || input.PublicIPv4 == "" {
return skipped(name, SeverityCritical)
}
reversed := reverseIPv4(input.PublicIPv4)
if reversed == "" {
return skipped(name, SeverityCritical)
}
var listed []string
// Sorted so the report is stable between runs.
zones := make([]string, 0, len(dnsblZones))
for zone := range dnsblZones {
zones = append(zones, zone)
}
sort.Strings(zones)
for _, zone := range zones {
if records, err := uc.deps.LookupTXT(ctx, reversed+"."+zone); err == nil && len(records) > 0 {
listed = append(listed, dnsblZones[zone])
}
}
if len(listed) > 0 {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("%s is listed on: %s", input.PublicIPv4, strings.Join(listed, ", ")),
Remedy: "request delisting with each provider before sending, or move to an IP with clean reputation",
}
}
return passed(name, SeverityCritical, "")
}
func (uc *PreflightUseCase) checkCertificate(input PreflightInput) CheckResult {
const name = "A TLS certificate is loaded for the mail host"
if uc.deps.HasCertificateFor == nil {
return skipped(name, SeverityCritical)
}
if uc.deps.HasCertificateFor(input.PrimaryMailHost) {
return passed(name, SeverityCritical, "")
}
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: fmt.Sprintf("no certificate for %s", input.PrimaryMailHost),
Remedy: "Traefik only requests certificates for hosts it routes; declare an HTTP router with Host(`" + input.PrimaryMailHost + "`) so ACME issues one",
}
}
func (uc *PreflightUseCase) checkDaneRequiresAcmeMode(input PreflightInput) CheckResult {
const name = "DANE is only enabled in self-managed ACME mode"
if !input.DaneEnabled {
return CheckResult{
Name: name, Status: CheckSkipped, Severity: SeverityCritical,
Detail: "DANE is disabled",
}
}
if strings.EqualFold(input.TLSMode, "acme") {
return passed(name, SeverityCritical, "")
}
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityCritical,
Detail: "DANE is enabled while certificates come from Traefik",
Remedy: "Traefik cannot sign a CSR with a pre-generated key, so the TLSA record would stop matching at the next renewal and every validating MTA would reject mail; set TLS_MODE=acme or disable DANE",
}
}
func (uc *PreflightUseCase) checkMtaStsEndpoint(ctx context.Context, input PreflightInput) CheckResult {
const name = "MTA-STS policy is served over HTTPS with a valid certificate"
if uc.deps.FetchMtaSts == nil || input.MailDomain == "" {
return skipped(name, SeverityHigh)
}
if err := uc.deps.FetchMtaSts(ctx, input.MailDomain); err != nil {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityHigh,
Detail: fmt.Sprintf("cannot fetch the policy for %s: %v", input.MailDomain, err),
Remedy: "publish the mta-sts host and route it through the proxy with a valid certificate; senders that cannot fetch the policy fall back to unprotected delivery",
}
}
return passed(name, SeverityHigh, "")
}
func (uc *PreflightUseCase) checkClockDrift(ctx context.Context) CheckResult {
const name = "System clock is synchronised"
const maxDrift = 5 * time.Second
if uc.deps.NetworkTime == nil || uc.deps.Now == nil {
return skipped(name, SeverityHigh)
}
networkTime, err := uc.deps.NetworkTime(ctx)
if err != nil {
return skipped(name, SeverityHigh)
}
drift := uc.deps.Now().Sub(networkTime)
if drift < 0 {
drift = -drift
}
if drift > maxDrift {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityHigh,
Detail: fmt.Sprintf("local clock is off by %v", drift.Round(time.Millisecond)),
Remedy: "enable NTP on the host; DKIM signature timestamps and TOTP codes both depend on an accurate clock",
}
}
return passed(name, SeverityHigh, fmt.Sprintf("drift %v", drift.Round(time.Millisecond)))
}
func (uc *PreflightUseCase) checkDiskSpace() CheckResult {
const name = "Mail spool has more than 20% free space"
if uc.deps.FreeDiskPercent == nil {
return skipped(name, SeverityMedium)
}
free, err := uc.deps.FreeDiskPercent()
if err != nil {
return skipped(name, SeverityMedium)
}
if free < 20 {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityMedium,
Detail: fmt.Sprintf("%.1f%% free", free),
Remedy: "free space or grow the volume; a full spool means accepted mail cannot be written and has to be refused",
}
}
return passed(name, SeverityMedium, fmt.Sprintf("%.1f%% free", free))
}
func (uc *PreflightUseCase) checkIPv6Coherence(ctx context.Context, input PreflightInput) CheckResult {
const name = "IPv6 is either fully configured or disabled"
if input.PublicIPv6 == "" {
return passed(name, SeverityMedium, "IPv6 is disabled")
}
if uc.deps.LookupAddr == nil {
return skipped(name, SeverityMedium)
}
names, err := uc.deps.LookupAddr(ctx, input.PublicIPv6)
if err != nil || len(names) == 0 {
return CheckResult{
Name: name, Status: CheckFailed, Severity: SeverityMedium,
Detail: fmt.Sprintf("no PTR for %s", input.PublicIPv6),
Remedy: "publish a PTR for the IPv6 address or unset PUBLIC_IPV6; half-configured IPv6 is a common cause of rejection",
}
}
return passed(name, SeverityMedium, "")
}
func passed(name string, severity Severity, detail string) CheckResult {
return CheckResult{Name: name, Status: CheckPassed, Severity: severity, Detail: detail}
}
func skipped(name string, severity Severity) CheckResult {
return CheckResult{
Name: name, Status: CheckSkipped, Severity: severity,
Detail: "not enough configuration to run this check",
}
}
// reverseIPv4 turns "192.0.2.10" into "10.2.0.192", the form DNSBL queries
// take. It returns an empty string for anything that is not an IPv4 address.
func reverseIPv4(address string) string {
ip := net.ParseIP(address).To4()
if ip == nil {
return ""
}
return fmt.Sprintf("%d.%d.%d.%d", ip[3], ip[2], ip[1], ip[0])
}
@@ -0,0 +1,266 @@
package usecase
import (
"context"
"errors"
"strings"
"testing"
"time"
)
// healthyDeps is a deployment where every probe succeeds; individual tests
// break exactly one thing so the failure being asserted is unambiguous.
func healthyDeps() PreflightDeps {
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
return PreflightDeps{
DialPort25: func(context.Context, string) error { return nil },
LookupAddr: func(_ context.Context, _ string) ([]string, error) { return []string{"mail.example.test."}, nil },
LookupHost: func(_ context.Context, _ string) ([]string, error) { return []string{"192.0.2.10"}, nil },
LookupTXT: func(_ context.Context, _ string) ([]string, error) { return nil, errors.New("NXDOMAIN") },
HasCertificateFor: func(string) bool { return true },
FetchMtaSts: func(context.Context, string) error { return nil },
FreeDiskPercent: func() (float64, error) { return 65, nil },
Now: func() time.Time { return now },
NetworkTime: func(context.Context) (time.Time, error) { return now, nil },
}
}
func healthyInput() PreflightInput {
return PreflightInput{
PrimaryMailHost: "mail.example.test",
MailDomain: "example.test",
PublicIPv4: "192.0.2.10",
TLSMode: "traefik",
}
}
func findCheck(t *testing.T, report PreflightReport, substring string) CheckResult {
t.Helper()
for _, result := range report.Results {
if strings.Contains(result.Name, substring) {
return result
}
}
t.Fatalf("no check matching %q in report:\n%s", substring, report)
return CheckResult{}
}
func TestPreflight_HealthyDeploymentDoesNotBlock(t *testing.T) {
report := NewPreflightUseCase(healthyDeps()).Execute(context.Background(), healthyInput())
if report.Blocking() {
t.Errorf("a healthy deployment was reported as blocking:\n%s", report)
}
}
// PLAN.md premise 1: without outbound port 25 there is no direct delivery.
func TestPreflight_BlockedPort25IsCriticalAndSuggestsSmarthost(t *testing.T) {
deps := healthyDeps()
deps.DialPort25 = func(context.Context, string) error { return errors.New("connection timed out") }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "port 25")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !strings.Contains(check.Remedy, "smarthost") {
t.Errorf("remedy should point at the smarthost fallback: %q", check.Remedy)
}
if !report.Blocking() {
t.Error("a blocked port 25 must block")
}
}
// With a smarthost configured, direct port 25 delivery is not used, so the
// check must not block the deployment.
func TestPreflight_SmarthostSkipsPort25Check(t *testing.T) {
deps := healthyDeps()
deps.DialPort25 = func(context.Context, string) error { return errors.New("blocked") }
input := healthyInput()
input.SmarthostSet = true
report := NewPreflightUseCase(deps).Execute(context.Background(), input)
if findCheck(t, report, "port 25").Status != CheckSkipped {
t.Error("the port 25 check should be skipped when a smarthost is configured")
}
if report.Blocking() {
t.Errorf("a smarthost deployment was blocked:\n%s", report)
}
}
// PLAN.md premise 2: PTR delegation belongs to the hosting provider, so the
// remedy has to say so.
func TestPreflight_MissingPtrIsCriticalAndNamesTheProvider(t *testing.T) {
deps := healthyDeps()
deps.LookupAddr = func(context.Context, string) ([]string, error) { return nil, errors.New("NXDOMAIN") }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "PTR")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !strings.Contains(check.Remedy, "hosting provider") {
t.Errorf("remedy should name the hosting provider: %q", check.Remedy)
}
}
func TestPreflight_ForwardConfirmedReverseDnsMismatchIsCritical(t *testing.T) {
deps := healthyDeps()
deps.LookupHost = func(context.Context, string) ([]string, error) { return []string{"198.51.100.1"}, nil }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
if check := findCheck(t, report, "FCrDNS"); check.Status != CheckFailed {
t.Errorf("check = %+v, want a failure when the A record and the PTR disagree", check)
}
}
func TestPreflight_ListedIpIsCriticalAndNamesTheList(t *testing.T) {
deps := healthyDeps()
deps.LookupTXT = func(_ context.Context, name string) ([]string, error) {
if strings.Contains(name, "zen.spamhaus.org") {
return []string{"https://www.spamhaus.org/query/ip/192.0.2.10"}, nil
}
return nil, errors.New("NXDOMAIN")
}
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "blocklist")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !strings.Contains(check.Detail, "Spamhaus") {
t.Errorf("detail should name the list: %q", check.Detail)
}
}
// PLAN.md section 8.2 trap 2: Traefik only holds certificates for hosts it
// routes, so the remedy has to explain the missing router.
func TestPreflight_MissingCertificateExplainsTheTraefikRouter(t *testing.T) {
deps := healthyDeps()
deps.HasCertificateFor = func(string) bool { return false }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "certificate is loaded")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !strings.Contains(check.Remedy, "router") {
t.Errorf("remedy should explain the missing router: %q", check.Remedy)
}
}
// PLAN.md section 5.1: enabling DANE under Traefik-managed certificates
// guarantees breakage at the next renewal, so the preflight must refuse it.
func TestPreflight_DaneUnderTraefikModeIsRefused(t *testing.T) {
input := healthyInput()
input.DaneEnabled = true
input.TLSMode = "traefik"
report := NewPreflightUseCase(healthyDeps()).Execute(context.Background(), input)
check := findCheck(t, report, "DANE")
if check.Status != CheckFailed || check.Severity != SeverityCritical {
t.Errorf("check = %+v, want a critical failure", check)
}
if !report.Blocking() {
t.Error("DANE under Traefik mode must block the deployment")
}
}
func TestPreflight_DaneUnderAcmeModePasses(t *testing.T) {
input := healthyInput()
input.DaneEnabled = true
input.TLSMode = "acme"
report := NewPreflightUseCase(healthyDeps()).Execute(context.Background(), input)
if check := findCheck(t, report, "DANE"); check.Status != CheckPassed {
t.Errorf("check = %+v, want a pass under TLS_MODE=acme", check)
}
}
// DKIM signatures and TOTP codes both break on a drifting clock, but neither
// loses mail outright, so this is high rather than critical.
func TestPreflight_ClockDriftIsHighNotCritical(t *testing.T) {
deps := healthyDeps()
local := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
deps.Now = func() time.Time { return local.Add(90 * time.Second) }
deps.NetworkTime = func(context.Context) (time.Time, error) { return local, nil }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "clock")
if check.Status != CheckFailed || check.Severity != SeverityHigh {
t.Errorf("check = %+v, want a high-severity failure", check)
}
if report.Blocking() {
t.Error("clock drift alone must not block the deployment")
}
}
func TestPreflight_LowDiskIsMediumSeverity(t *testing.T) {
deps := healthyDeps()
deps.FreeDiskPercent = func() (float64, error) { return 4, nil }
report := NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
check := findCheck(t, report, "free space")
if check.Status != CheckFailed || check.Severity != SeverityMedium {
t.Errorf("check = %+v, want a medium-severity failure", check)
}
}
// PLAN.md premise 5: half-configured IPv6 is a common cause of rejection, and
// disabling it is a valid answer.
func TestPreflight_IPv6WithoutPtrFailsButDisabledIPv6Passes(t *testing.T) {
deps := healthyDeps()
deps.LookupAddr = func(_ context.Context, ip string) ([]string, error) {
if strings.Contains(ip, ":") {
return nil, errors.New("NXDOMAIN")
}
return []string{"mail.example.test."}, nil
}
withIPv6 := healthyInput()
withIPv6.PublicIPv6 = "2001:db8::1"
report := NewPreflightUseCase(deps).Execute(context.Background(), withIPv6)
if check := findCheck(t, report, "IPv6"); check.Status != CheckFailed {
t.Errorf("check = %+v, want a failure for IPv6 without a PTR", check)
}
report = NewPreflightUseCase(deps).Execute(context.Background(), healthyInput())
if check := findCheck(t, report, "IPv6"); check.Status != CheckPassed {
t.Errorf("check = %+v, want a pass when IPv6 is disabled", check)
}
}
// A probe that cannot run is reported as skipped, never silently as a pass.
func TestPreflight_MissingProbesAreSkippedNotPassed(t *testing.T) {
report := NewPreflightUseCase(PreflightDeps{}).Execute(context.Background(), healthyInput())
for _, result := range report.Results {
if result.Status == CheckPassed && result.Name != "IPv6 is either fully configured or disabled" {
t.Errorf("check %q passed with no probe configured", result.Name)
}
}
if report.Blocking() {
t.Error("skipped checks must not block")
}
}
func TestReverseIPv4(t *testing.T) {
if got := reverseIPv4("192.0.2.10"); got != "10.2.0.192" {
t.Errorf("reverseIPv4 = %q, want 10.2.0.192", got)
}
if got := reverseIPv4("2001:db8::1"); got != "" {
t.Errorf("reverseIPv4 of an IPv6 address = %q, want empty", got)
}
}
@@ -6,7 +6,11 @@ import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net"
"sort"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/valueobject"
@@ -31,6 +35,11 @@ type ProcessInboundEmailInput struct {
Recipients []port.MailboxRecord
RecipientAddresses []string
Body io.Reader
// ClientIP and HeloDomain identify the peer. SPF is defined over them
// (RFC 7208 section 2.3), so a delivery without them can only ever
// produce a "none" verdict.
ClientIP net.IP
HeloDomain string
}
// ProcessInboundEmailUseCase implements the inbound half of PLAN.md section
@@ -39,6 +48,8 @@ type ProcessInboundEmailInput struct {
// later sub-projects).
type ProcessInboundEmailUseCase struct {
scanner port.ContentScanner
authenticator port.MailAuthenticator
arcSealer port.ArcSealer
mailboxes port.MailboxRepository
blobs port.BlobStorage
messages port.InboundMessageRepository
@@ -54,11 +65,49 @@ func (uc *ProcessInboundEmailUseCase) SetScanner(scanner port.ContentScanner) {
uc.scanner = scanner
}
func (uc *ProcessInboundEmailUseCase) SetAuthenticator(authenticator port.MailAuthenticator) {
uc.authenticator = authenticator
}
// SetArcSealer enables ARC sealing of accepted messages. It is only worth
// enabling on a host that forwards mail onward (PLAN.md section 5).
func (uc *ProcessInboundEmailUseCase) SetArcSealer(sealer port.ArcSealer) {
uc.arcSealer = sealer
}
func (uc *ProcessInboundEmailUseCase) SetTrackerManager(tm *MailboxTrackerManager, folders port.ImapFolderRepository) {
uc.trackerManager = tm
uc.folders = folders
}
// prependHeaders inserts the scanner's verdict headers at the top of the
// message. A receiving MTA adds its trace headers above the existing ones
// (RFC 5321 section 4.4), which also keeps the original bytes - and therefore
// any DKIM signature over them - untouched.
func prependHeaders(payload []byte, headers map[string]string) []byte {
if len(headers) == 0 {
return payload
}
// Sorted so the delivered message is byte-for-byte reproducible instead of
// depending on Go's randomized map iteration order.
names := make([]string, 0, len(headers))
for name := range headers {
names = append(names, name)
}
sort.Strings(names)
var buf bytes.Buffer
for _, name := range names {
buf.WriteString(name)
buf.WriteString(": ")
buf.WriteString(headers[name])
buf.WriteString("\r\n")
}
buf.Write(payload)
return buf.Bytes()
}
// ResolveRecipient is called from the SMTP session's RCPT TO handler. It
// returns every mailbox address should be delivered to (more than one for
// an alias that fans out), or ErrRecipientNotFound / ErrMailboxQuotaExceeded.
@@ -84,12 +133,68 @@ func (uc *ProcessInboundEmailUseCase) ResolveRecipient(ctx context.Context, addr
// the same blob (PLAN.md section 9's dedup design: 1 email to 50
// recipients = 1 file).
func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessInboundEmailInput) error {
// The two slices are indexed in lockstep below: one envelope address per
// resolved mailbox, repeated when an alias fans out. Refusing a mismatch
// turns a caller's bug into an error instead of an out-of-range panic that
// would take down the calling worker.
if len(input.Recipients) != len(input.RecipientAddresses) {
return fmt.Errorf(
"process inbound email: %d recipients but %d envelope addresses",
len(input.Recipients), len(input.RecipientAddresses),
)
}
payload, err := io.ReadAll(input.Body)
if err != nil {
return err
}
targetFolder := "INBOX"
authResult := port.InboundAuthResult{
SPF: port.AuthResultNone,
DKIM: port.AuthResultNone,
DMARC: port.AuthResultNone,
}
// Authentication runs before the content scanners so that the spam filter
// sees our Authentication-Results header and can score on it
// (PLAN.md section 6.1).
if uc.authenticator != nil {
authResult = uc.authenticator.Authenticate(ctx, port.InboundAuthInput{
ClientIP: input.ClientIP,
HeloDomain: input.HeloDomain,
EnvelopeFrom: input.Sender,
Message: payload,
})
// Honouring a published "p=reject" is the whole point of the sender
// having published it. Quarantine is left to the spam filter, which
// files rather than refuses.
if authResult.DMARC == port.AuthResultFail && authResult.DmarcPolicy == "reject" {
return errors.New("550 5.7.1 DMARC policy rejects this message")
}
if authResult.AuthenticationResults != "" {
payload = prependHeaders(payload, map[string]string{
"Authentication-Results": authResult.AuthenticationResults,
})
}
// The ARC set has to be built over the message as it arrived, with
// this hop's verdict already known, so it is sealed here rather than
// after the content scanners rewrite headers.
if uc.arcSealer != nil {
sealed, err := uc.arcSealer.Seal(ctx, payload, authResult)
if err != nil {
// A sealing failure must not lose the message: it is delivered
// without the ARC set, which is exactly the state it would
// have been in had sealing not been enabled.
log.Printf("arc: could not seal message from %s: %v", input.Sender, err)
} else {
payload = sealed
}
}
}
if uc.scanner != nil {
recipientAddr := ""
@@ -115,6 +220,7 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
case valueobject.ScanVerdictSpamJunk:
targetFolder = "Junk"
}
payload = prependHeaders(payload, scanRes.HeadersToAdd)
}
}
@@ -130,9 +236,9 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
SenderAddress: input.Sender,
RecipientAddress: input.RecipientAddresses[i],
TargetFolderName: targetFolder,
SPFResult: "none",
DKIMResult: "none",
DMARCResult: "none",
SPFResult: authResult.SPF,
DKIMResult: authResult.DKIM,
DMARCResult: authResult.DMARC,
})
if err != nil {
return err
@@ -0,0 +1,179 @@
package usecase
import (
"context"
"strings"
"testing"
"lambdamail/protocols/internal/domain/entity"
)
func conflictTestInput() SyncDnsRecordsInput {
return SyncDnsRecordsInput{
DomainName: "example.test",
MailHost: "mail.example.test",
ServerIPv4: "192.0.2.1",
RsaDkimPubKey: "rsaKey",
EdDkimPubKey: "edKey",
}
}
// PLAN.md section 7.5: pre-existing records that are not ours are never
// touched, only reported as conflicts. A third-party verification TXT sits at
// the zone apex under the same type and name as our SPF record, so a reconciler
// keyed only on type+name would overwrite it and break the other service.
func TestSyncDnsRecords_ForeignApexTxtIsPreserved(t *testing.T) {
dns := newFakeDnsProvider()
dns.seed(entity.DnsRecord{
Type: "TXT",
Name: "example.test",
Value: "google-site-verification=abc123",
})
uc := NewSyncDnsRecordsUseCase(dns, nil)
out, err := uc.Execute(context.Background(), conflictTestInput())
if err != nil {
t.Fatalf("Execute: %v", err)
}
apexTxt := dns.find("TXT", "example.test")
if len(apexTxt) != 2 {
t.Fatalf("expected the foreign TXT and our SPF to coexist, got %d records", len(apexTxt))
}
var foundForeign, foundSPF bool
for _, r := range apexTxt {
if r.Value == "google-site-verification=abc123" {
foundForeign = true
}
if strings.HasPrefix(r.Value, "v=spf1") {
foundSPF = true
}
}
if !foundForeign {
t.Error("the foreign verification TXT was destroyed")
}
if !foundSPF {
t.Error("the SPF record was not created alongside the foreign apex TXT")
}
if out.ConflictCount != 0 {
t.Errorf("a coexisting foreign TXT is not a conflict, got ConflictCount=%d", out.ConflictCount)
}
}
// A name that admits only one value (the mail host A record) already pointing
// somewhere else is a genuine conflict: silently repointing it could take down
// an unrelated service.
func TestSyncDnsRecords_ForeignARecordIsReportedAsConflict(t *testing.T) {
dns := newFakeDnsProvider()
dns.seed(entity.DnsRecord{
Type: "A",
Name: "mail.example.test",
Value: "198.51.100.9",
Comment: "managed by something else",
})
uc := NewSyncDnsRecordsUseCase(dns, nil)
out, err := uc.Execute(context.Background(), conflictTestInput())
if err != nil {
t.Fatalf("Execute: %v", err)
}
records := dns.find("A", "mail.example.test")
if len(records) != 1 || records[0].Value != "198.51.100.9" {
t.Errorf("foreign A record was not left untouched: %+v", records)
}
if out.ConflictCount != 1 {
t.Fatalf("ConflictCount = %d, want 1", out.ConflictCount)
}
if out.Status != SyncStatusPartial {
t.Errorf("Status = %s, want %s", out.Status, SyncStatusPartial)
}
if !strings.Contains(out.Conflicts[0].String(), "198.51.100.9") {
t.Errorf("conflict report does not name the blocking value: %s", out.Conflicts[0])
}
}
// Our own records must still be updated in place when they drift.
func TestSyncDnsRecords_OwnRecordDriftIsCorrected(t *testing.T) {
dns := newFakeDnsProvider()
uc := NewSyncDnsRecordsUseCase(dns, nil)
ctx := context.Background()
if _, err := uc.Execute(ctx, conflictTestInput()); err != nil {
t.Fatalf("initial Execute: %v", err)
}
for i, r := range dns.records {
if r.Type == "A" && r.Name == "mail.example.test" {
dns.records[i].Value = "203.0.113.7"
}
}
out, err := uc.Execute(ctx, conflictTestInput())
if err != nil {
t.Fatalf("second Execute: %v", err)
}
if out.UpdatedCount != 1 || out.ConflictCount != 0 {
t.Errorf("updated=%d conflicts=%d, want updated=1 conflicts=0", out.UpdatedCount, out.ConflictCount)
}
if records := dns.find("A", "mail.example.test"); records[0].Value != "192.0.2.1" {
t.Errorf("drifted A record was not corrected: got %q", records[0].Value)
}
if out.Status != SyncStatusVerified {
t.Errorf("Status = %s, want %s", out.Status, SyncStatusVerified)
}
}
type stubDnsVerifier struct {
invisible map[string]bool
}
func (s stubDnsVerifier) VerifyRecord(_ context.Context, record entity.DnsRecord) (bool, string) {
if s.invisible[record.Name] {
return false, "no answer"
}
return true, ""
}
// PLAN.md section 7.5 verifies through public resolvers after writing. A
// record the provider accepted but nobody can resolve is drift, not success.
func TestSyncDnsRecords_UnresolvableRecordIsReportedAsDrift(t *testing.T) {
dns := newFakeDnsProvider()
uc := NewSyncDnsRecordsUseCase(dns, nil)
uc.SetVerifier(stubDnsVerifier{invisible: map[string]bool{"mail.example.test": true}})
out, err := uc.Execute(context.Background(), conflictTestInput())
if err != nil {
t.Fatalf("Execute: %v", err)
}
if out.Status != SyncStatusDrift {
t.Errorf("Status = %s, want DRIFT", out.Status)
}
if len(out.Unverified) == 0 {
t.Fatal("the unresolvable record was not reported")
}
if !strings.Contains(out.Unverified[0], "mail.example.test") {
t.Errorf("Unverified = %v, want it to name the record", out.Unverified)
}
}
func TestSyncDnsRecords_AllRecordsVisibleIsVerified(t *testing.T) {
dns := newFakeDnsProvider()
uc := NewSyncDnsRecordsUseCase(dns, nil)
uc.SetVerifier(stubDnsVerifier{})
out, err := uc.Execute(context.Background(), conflictTestInput())
if err != nil {
t.Fatalf("Execute: %v", err)
}
if out.Status != SyncStatusVerified {
t.Errorf("Status = %s, want VERIFIED", out.Status)
}
if len(out.Unverified) != 0 {
t.Errorf("Unverified = %v, want empty", out.Unverified)
}
}
@@ -9,28 +9,68 @@ import (
"lambdamail/protocols/internal/domain/entity"
)
// SyncStatus is the outcome reported to the admin console (PLAN.md section 7.5).
type SyncStatus string
const (
SyncStatusVerified SyncStatus = "VERIFIED"
SyncStatusPartial SyncStatus = "PARTIAL"
SyncStatusDrift SyncStatus = "DRIFT"
)
// ownedRecordCommentPrefix marks the records this reconciler manages. Anything
// in the zone without it that would collide with a desired record belongs to
// somebody else and is reported instead of overwritten.
const ownedRecordCommentPrefix = "LambdaMail"
type SyncDnsRecordsInput struct {
DomainName string
MailHost string
ServerIPv4 string
ServerIPv6 string
RsaDkimPubKey string
EdDkimPubKey string
DomainName string
MailHost string
ServerIPv4 string
ServerIPv6 string
RsaDkimPubKey string
EdDkimPubKey string
// TlsaHash publishes a single DANE association. TlsaHashes supersedes it
// and is what a rollover uses, since it can carry the current and the
// next key at once.
TlsaHash string
TlsaHashes []string
DaneEnabled bool
RelaySpfInclude string
AdminEmailAddress string
}
// SyncConflict describes a record that could not be reconciled because a
// foreign record already occupies its name.
type SyncConflict struct {
Type string
Name string
Existing string
Desired string
}
func (c SyncConflict) String() string {
return fmt.Sprintf("%s %s: existing %q blocks desired %q", c.Type, c.Name, c.Existing, c.Desired)
}
type SyncDnsRecordsOutput struct {
ZoneID string
Status SyncStatus
CreatedCount int
UpdatedCount int
UnchangedCount int
ConflictCount int
Conflicts []SyncConflict
Errors []string
// Unverified lists records that were written but could not be read back
// through the public resolvers.
Unverified []string
}
type SyncDnsRecordsUseCase struct {
dnsProvider port.DnsProvider
aliasRepo port.SystemAliasRepository
verifier port.DnsVerifier
}
func NewSyncDnsRecordsUseCase(dnsProvider port.DnsProvider, aliasRepo port.SystemAliasRepository) *SyncDnsRecordsUseCase {
@@ -40,21 +80,35 @@ func NewSyncDnsRecordsUseCase(dnsProvider port.DnsProvider, aliasRepo port.Syste
}
}
// SetVerifier enables the independent check of PLAN.md section 7.5: after
// writing, the records are read back through public resolvers. The provider
// accepting a write only proves the API accepted it, not that the record
// resolves for anyone else.
func (uc *SyncDnsRecordsUseCase) SetVerifier(verifier port.DnsVerifier) {
uc.verifier = verifier
}
func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecordsInput) (*SyncDnsRecordsOutput, error) {
if input.DomainName == "" {
return nil, fmt.Errorf("domain name is required")
}
desiredSpecs := entity.Build13DnsRecordSpecs(
input.DomainName,
input.MailHost,
input.ServerIPv4,
input.ServerIPv6,
input.RsaDkimPubKey,
input.EdDkimPubKey,
input.TlsaHash,
input.DaneEnabled,
)
tlsaHashes := input.TlsaHashes
if len(tlsaHashes) == 0 && input.TlsaHash != "" {
tlsaHashes = []string{input.TlsaHash}
}
desiredSpecs := entity.BuildDnsRecordSpecs(entity.DnsRecordSpec{
DomainName: input.DomainName,
MailHost: input.MailHost,
ServerIPv4: input.ServerIPv4,
ServerIPv6: input.ServerIPv6,
RsaDkimPubKey: input.RsaDkimPubKey,
EdDkimPubKey: input.EdDkimPubKey,
TlsaHashes: tlsaHashes,
DaneEnabled: input.DaneEnabled,
RelaySpfInclude: input.RelaySpfInclude,
})
zoneID, err := uc.dnsProvider.GetZoneID(ctx, input.DomainName)
if err != nil {
@@ -66,31 +120,48 @@ func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecor
return nil, fmt.Errorf("list dns records for zone %s: %w", zoneID, err)
}
existingMap := make(map[string]entity.DnsRecord)
// A single type+name can legitimately hold several records (multiple TXT at
// the zone apex is the common case), so the index maps to every candidate.
existingByKey := make(map[string][]entity.DnsRecord)
for _, rec := range existingRecords {
key := fmt.Sprintf("%s:%s", strings.ToUpper(rec.Type), strings.ToLower(rec.Name))
existingMap[key] = rec
key := recordKey(rec.Type, rec.Name)
existingByKey[key] = append(existingByKey[key], rec)
}
output := &SyncDnsRecordsOutput{ZoneID: zoneID}
for _, spec := range desiredSpecs {
key := fmt.Sprintf("%s:%s", strings.ToUpper(spec.Type), strings.ToLower(spec.Name))
existing, found := existingMap[key]
candidates := existingByKey[recordKey(spec.Type, spec.Name)]
match, foreign := classifyCandidates(spec, candidates)
if !found {
if err := uc.dnsProvider.CreateRecord(ctx, zoneID, spec); err != nil {
return nil, fmt.Errorf("create dns record %s %s: %w", spec.Type, spec.Name, err)
switch {
case match != nil:
if spec.EqualsNormalized(*match) {
output.UnchangedCount++
continue
}
output.CreatedCount++
} else if !spec.EqualsNormalized(existing) {
spec.ID = existing.ID
spec.ID = match.ID
if err := uc.dnsProvider.UpdateRecord(ctx, zoneID, spec); err != nil {
return nil, fmt.Errorf("update dns record %s %s: %w", spec.Type, spec.Name, err)
output.Errors = append(output.Errors, fmt.Sprintf("update %s %s: %v", spec.Type, spec.Name, err))
continue
}
output.UpdatedCount++
} else {
output.UnchangedCount++
case foreign != nil:
output.ConflictCount++
output.Conflicts = append(output.Conflicts, SyncConflict{
Type: spec.Type,
Name: spec.Name,
Existing: foreign.Value,
Desired: spec.Value,
})
default:
if err := uc.dnsProvider.CreateRecord(ctx, zoneID, spec); err != nil {
output.Errors = append(output.Errors, fmt.Sprintf("create %s %s: %v", spec.Type, spec.Name, err))
continue
}
output.CreatedCount++
}
}
@@ -100,5 +171,104 @@ func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecor
}
}
output.Unverified = uc.verifyPublished(ctx, desiredSpecs)
output.Status = SyncStatusVerified
switch {
case output.ConflictCount > 0 || len(output.Errors) > 0:
output.Status = SyncStatusPartial
case len(output.Unverified) > 0:
// The records were written but the world cannot see them yet. That is
// drift, not success: reporting VERIFIED here is what would let a
// broken zone look healthy.
output.Status = SyncStatusDrift
}
return output, nil
}
// verifyPublished re-reads every desired record through public resolvers and
// returns the ones that did not check out.
func (uc *SyncDnsRecordsUseCase) verifyPublished(ctx context.Context, specs []entity.DnsRecord) []string {
if uc.verifier == nil {
return nil
}
var unverified []string
for _, spec := range specs {
visible, detail := uc.verifier.VerifyRecord(ctx, spec)
if visible {
continue
}
entry := fmt.Sprintf("%s %s", spec.Type, spec.Name)
if detail != "" {
entry += ": " + detail
}
unverified = append(unverified, entry)
}
return unverified
}
func recordKey(recType, name string) string {
return strings.ToUpper(recType) + ":" + strings.ToLower(strings.TrimSuffix(name, "."))
}
// classifyCandidates picks, among the records already published under a
// desired record's type and name, the one this reconciler owns. It returns
// either that match, or - when no record is ours and the name admits only a
// single value - the foreign record blocking it.
func classifyCandidates(spec entity.DnsRecord, candidates []entity.DnsRecord) (match *entity.DnsRecord, blocking *entity.DnsRecord) {
for i := range candidates {
if isOurRecord(spec, candidates[i]) {
return &candidates[i], nil
}
}
if len(candidates) > 0 && !allowsCoexistence(spec.Type) {
return nil, &candidates[0]
}
return nil, nil
}
// isOurRecord decides ownership. The record comment is the primary marker, but
// it is not preserved by every provider or migration path, so TXT records fall
// back to their protocol tag: only one "v=spf1" record may exist per name, so a
// record carrying our tag is unambiguously the one we manage.
func isOurRecord(spec, existing entity.DnsRecord) bool {
if strings.HasPrefix(existing.Comment, ownedRecordCommentPrefix) {
return true
}
if strings.EqualFold(spec.Type, "TXT") {
tag := txtTag(spec.Value)
return tag != "" && strings.EqualFold(txtTag(existing.Value), tag)
}
// For single-valued names an identical value means the record is already
// what we want, whoever created it.
return strings.TrimSpace(spec.Value) == strings.TrimSpace(existing.Value)
}
// txtTag extracts the leading "v=<protocol>" tag that identifies which mail
// protocol a TXT record belongs to (RFC 7208 SPF, RFC 6376 DKIM, RFC 7489
// DMARC, RFC 8461 MTA-STS, RFC 8460 TLS-RPT).
func txtTag(value string) string {
trimmed := strings.TrimSpace(strings.Trim(value, `"`))
if !strings.HasPrefix(strings.ToLower(trimmed), "v=") {
return ""
}
rest := trimmed[2:]
if idx := strings.IndexAny(rest, "; \t"); idx >= 0 {
rest = rest[:idx]
}
return strings.ToLower(rest)
}
// allowsCoexistence reports whether several records may share a type and name.
// TXT is the mail-relevant case: verification tokens from unrelated services
// live alongside SPF at the zone apex and must not be disturbed.
func allowsCoexistence(recType string) bool {
switch strings.ToUpper(recType) {
case "TXT", "MX", "TLSA":
return true
default:
return false
}
}
@@ -9,12 +9,35 @@ import (
"lambdamail/protocols/internal/domain/entity"
)
// fakeDnsProvider models a real zone: several records may share a type and
// name (multiple TXT at the apex), so records are held in a list keyed by id.
type fakeDnsProvider struct {
records map[string]entity.DnsRecord // key -> record
records []entity.DnsRecord
nextID int
}
func newFakeDnsProvider() *fakeDnsProvider {
return &fakeDnsProvider{records: make(map[string]entity.DnsRecord)}
return &fakeDnsProvider{}
}
// seed publishes a pre-existing record that this reconciler did not create.
func (f *fakeDnsProvider) seed(record entity.DnsRecord) {
if record.ID == "" {
f.nextID++
record.ID = fmt.Sprintf("seed_%d", f.nextID)
}
f.records = append(f.records, record)
}
// find returns the records published under a type and name.
func (f *fakeDnsProvider) find(recType, name string) []entity.DnsRecord {
var out []entity.DnsRecord
for _, r := range f.records {
if strings.EqualFold(r.Type, recType) && strings.EqualFold(r.Name, name) {
out = append(out, r)
}
}
return out
}
func (f *fakeDnsProvider) GetZoneID(_ context.Context, domainName string) (string, error) {
@@ -22,31 +45,31 @@ func (f *fakeDnsProvider) GetZoneID(_ context.Context, domainName string) (strin
}
func (f *fakeDnsProvider) ListRecords(_ context.Context, _ string) ([]entity.DnsRecord, error) {
var out []entity.DnsRecord
for _, r := range f.records {
out = append(out, r)
}
return out, nil
return append([]entity.DnsRecord(nil), f.records...), nil
}
func (f *fakeDnsProvider) CreateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name))
record.ID = fmt.Sprintf("id_%s", key)
f.records[key] = record
f.nextID++
record.ID = fmt.Sprintf("id_%d", f.nextID)
f.records = append(f.records, record)
return nil
}
func (f *fakeDnsProvider) UpdateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name))
f.records[key] = record
return nil
for i, r := range f.records {
if r.ID == record.ID {
f.records[i] = record
return nil
}
}
return fmt.Errorf("record %s not found", record.ID)
}
func (f *fakeDnsProvider) DeleteRecord(_ context.Context, _ string, recordID string) error {
for k, r := range f.records {
for i, r := range f.records {
if r.ID == recordID {
delete(f.records, k)
break
f.records = append(f.records[:i], f.records[i+1:]...)
return nil
}
}
return nil
@@ -61,7 +84,11 @@ func (f *fakeSystemAliasRepository) EnsureSystemAliases(_ context.Context, _ str
return nil
}
func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) {
// The full desired state is the 13 numbered records of PLAN.md section 7.1
// plus the three client-autoconfiguration records of section 7.2.
const expectedRecordCount = 16
func TestSyncDnsRecordsUseCase_CreatesFullRecordSetAndAliases(t *testing.T) {
dns := newFakeDnsProvider()
alias := &fakeSystemAliasRepository{}
uc := NewSyncDnsRecordsUseCase(dns, alias)
@@ -85,8 +112,8 @@ func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) {
t.Fatalf("Execute failed: %v", err)
}
if out.CreatedCount != 13 {
t.Errorf("CreatedCount = %d, want 13", out.CreatedCount)
if out.CreatedCount != expectedRecordCount {
t.Errorf("CreatedCount = %d, want %d", out.CreatedCount, expectedRecordCount)
}
if !alias.ensured {
t.Error("expected system aliases to be ensured")
@@ -98,7 +125,7 @@ func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) {
t.Fatalf("Execute 2 failed: %v", err)
}
if out2.UnchangedCount != 13 || out2.CreatedCount != 0 || out2.UpdatedCount != 0 {
t.Errorf("expected 13 unchanged, got created=%d, updated=%d, unchanged=%d", out2.CreatedCount, out2.UpdatedCount, out2.UnchangedCount)
if out2.UnchangedCount != expectedRecordCount || out2.CreatedCount != 0 || out2.UpdatedCount != 0 {
t.Errorf("expected %d unchanged, got created=%d, updated=%d, unchanged=%d", expectedRecordCount, out2.CreatedCount, out2.UpdatedCount, out2.UnchangedCount)
}
}
@@ -10,12 +10,12 @@ import (
)
type DmarcRecord struct {
SourceIP string
Count int
Disposition string
DKIMResult string
SPFResult string
HeaderFrom string
SourceIP string
Count int
Disposition string
DKIMResult string
SPFResult string
HeaderFrom string
}
type DmarcReport struct {
@@ -41,8 +41,8 @@ type dmarcXmlReport struct {
} `xml:"policy_published"`
Record []struct {
Row struct {
SourceIP string `xml:"source_ip"`
Count int `xml:"count"`
SourceIP string `xml:"source_ip"`
Count int `xml:"count"`
PolicyEvaluated struct {
Disposition string `xml:"disposition"`
DKIM string `xml:"dkim"`
@@ -1,9 +1,10 @@
package entity
import (
"crypto/sha256"
"encoding/binary"
"fmt"
"strings"
"time"
)
type DnsRecord struct {
@@ -17,8 +18,34 @@ type DnsRecord struct {
Comment string
}
// Build13DnsRecordSpecs generates the 13 mandatory DNS record specifications per PLAN.md section 7.
func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkimPubKey, edDkimPubKey, tlsaHash string, daneEnabled bool) []DnsRecord {
// DnsRecordSpec describes the zone the reconciler should converge on.
type DnsRecordSpec struct {
DomainName string
MailHost string
ServerIPv4 string
ServerIPv6 string
RsaDkimPubKey string
EdDkimPubKey string
// TlsaHashes are the DANE associations to publish. Two are normally
// present during a rollover: the current key and the next one
// (RFC 7671 section 8.1).
TlsaHashes []string
DaneEnabled bool
// RelaySpfInclude is the mechanism authorising a smarthost to send for
// this domain. Without it every message the relay forwards fails SPF
// (PLAN.md section 10.4).
RelaySpfInclude string
}
// BuildDnsRecordSpecs generates the DNS records of PLAN.md section 7: the 13
// numbered ones of section 7.1 plus the client-autoconfiguration records of
// section 7.2.
func BuildDnsRecordSpecs(spec DnsRecordSpec) []DnsRecord {
domainName := spec.DomainName
mailHost := spec.MailHost
serverIPv4, serverIPv6 := spec.ServerIPv4, spec.ServerIPv6
rsaDkimPubKey, edDkimPubKey := spec.RsaDkimPubKey, spec.EdDkimPubKey
if mailHost == "" {
mailHost = fmt.Sprintf("mail.%s", domainName)
}
@@ -46,7 +73,7 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim
{
Type: "TXT",
Name: domainName,
Value: fmt.Sprintf("v=spf1 mx a:%s -all", mailHost),
Value: buildSpfRecord(mailHost, spec.RelaySpfInclude),
TTL: 1,
Comment: "LambdaMail SPF Record",
},
@@ -78,7 +105,7 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim
{
Type: "TXT",
Name: fmt.Sprintf("_mta-sts.%s", domainName),
Value: fmt.Sprintf("v=STSv1; id=%d", time.Now().Unix()),
Value: fmt.Sprintf("v=STSv1; id=%s", mtaStsPolicyID(domainName, mailHost)),
TTL: 1,
Comment: "LambdaMail MTA-STS Policy Version",
},
@@ -117,6 +144,33 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim
Priority: intPtr(0),
Comment: "LambdaMail Submissions Autoconfig",
},
// The remaining client-autoconfiguration records of PLAN.md section
// 7.2. They are unnumbered there because they are conveniences, but
// the HTTPS endpoints of section 7.3 are unreachable without them.
{
Type: "SRV",
Name: fmt.Sprintf("_pop3s._tcp.%s", domainName),
Value: fmt.Sprintf("0 0 995 %s", mailHost),
TTL: 1,
Priority: intPtr(0),
Comment: "LambdaMail POP3S Autoconfig",
},
{
Type: "CNAME",
Name: fmt.Sprintf("autoconfig.%s", domainName),
Value: mailHost,
TTL: 1,
Proxied: true,
Comment: "LambdaMail Thunderbird Autoconfig Endpoint",
},
{
Type: "SRV",
Name: fmt.Sprintf("_autodiscover._tcp.%s", domainName),
Value: fmt.Sprintf("0 0 443 %s", mailHost),
TTL: 1,
Priority: intPtr(0),
Comment: "LambdaMail Outlook Autodiscover",
},
}
// 2. Conditional AAAA record if IPv6 is supplied
@@ -131,24 +185,53 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim
})
}
// 11. Conditional TLSA record if DANE is enabled
if daneEnabled && tlsaHash != "" {
records = append(records, DnsRecord{
Type: "TLSA",
Name: fmt.Sprintf("_25._tcp.%s", mailHost),
Value: fmt.Sprintf("3 1 1 %s", tlsaHash),
TTL: 1,
Comment: "LambdaMail DANE TLSA Record",
})
// 11. Conditional TLSA records if DANE is enabled. More than one is normal
// during a rollover: the current association and the next one are both
// published so the certificate can change without a gap (RFC 7671 8.1).
if spec.DaneEnabled {
for _, hash := range spec.TlsaHashes {
if hash == "" {
continue
}
records = append(records, DnsRecord{
Type: "TLSA",
Name: fmt.Sprintf("_25._tcp.%s", mailHost),
Value: fmt.Sprintf("3 1 1 %s", hash),
TTL: 1,
Comment: "LambdaMail DANE TLSA Record",
})
}
}
return records
}
// buildSpfRecord assembles the sender policy. PLAN.md section 5 uses a hard
// fail, which is only safe once every legitimate sending path is listed - so
// the relay, when there is one, has to be included here.
func buildSpfRecord(mailHost, relayInclude string) string {
mechanisms := []string{"v=spf1", "mx", "a:" + mailHost}
if relayInclude != "" {
mechanisms = append(mechanisms, relayInclude)
}
return strings.Join(append(mechanisms, "-all"), " ")
}
func intPtr(v int) *int {
return &v
}
// mtaStsPolicyID derives the MTA-STS policy id from the policy's own content
// rather than from the wall clock. RFC 8461 section 3.1 requires the id to
// change whenever the served policy changes; PLAN.md section 7.5 requires the
// reconciler to be fully idempotent. A timestamp satisfies the first and
// violates the second - every sync would rewrite the record and force every
// sender to re-fetch. Hashing the inputs that define the policy satisfies both.
func mtaStsPolicyID(domainName, mailHost string) string {
sum := sha256.Sum256([]byte(domainName + "\x00" + mailHost))
return fmt.Sprintf("%016x", binary.BigEndian.Uint64(sum[:8]))
}
// EqualsNormalized compares two DNS records ignoring subtle formatting whitespace/case differences.
func (r DnsRecord) EqualsNormalized(other DnsRecord) bool {
if r.Type != other.Type {
@@ -163,5 +246,13 @@ func (r DnsRecord) EqualsNormalized(other DnsRecord) bool {
if r.Proxied != other.Proxied {
return false
}
// MX and SRV priority drift routes mail to the wrong host, so it counts as
// a difference even though the record value itself matches.
if (r.Priority == nil) != (other.Priority == nil) {
return false
}
if r.Priority != nil && *r.Priority != *other.Priority {
return false
}
return true
}
@@ -4,21 +4,43 @@ import (
"testing"
)
func TestBuild13DnsRecordSpecs_GeneratesMandatoryRecords(t *testing.T) {
specs := Build13DnsRecordSpecs(
"example.test",
"mail.example.test",
"192.0.2.1",
"2001:db8::1",
"rsaPubKeyBase64",
"edPubKeyBase64",
"hash123",
true,
)
func TestBuildDnsRecordSpecs_GeneratesMandatoryRecords(t *testing.T) {
specs := BuildDnsRecordSpecs(DnsRecordSpec{
DomainName: "example.test",
MailHost: "mail.example.test",
ServerIPv4: "192.0.2.1",
ServerIPv6: "2001:db8::1",
RsaDkimPubKey: "rsaPubKeyBase64",
EdDkimPubKey: "edPubKeyBase64",
TlsaHashes: []string{"hash123"},
DaneEnabled: true,
})
// Should contain 13 records (11 mandatory + 1 AAAA + 1 TLSA)
if len(specs) != 13 {
t.Fatalf("expected 13 records, got %d", len(specs))
// PLAN.md section 7.1 numbers 13 records (11 always-on, plus AAAA when
// IPv6 is configured and TLSA when DANE is on). Section 7.2 adds three
// unnumbered client-autoconfiguration records: the _pop3s SRV, the
// autoconfig CNAME and the _autodiscover SRV.
const numberedRecords, convenienceRecords = 13, 3
if len(specs) != numberedRecords+convenienceRecords {
t.Fatalf("expected %d records, got %d", numberedRecords+convenienceRecords, len(specs))
}
// The HTTPS endpoints of section 7.3 are unreachable without the names
// that point at them.
for _, required := range []struct{ recType, name string }{
{"CNAME", "autoconfig.example.test"},
{"SRV", "_pop3s._tcp.example.test"},
{"SRV", "_autodiscover._tcp.example.test"},
} {
found := false
for _, r := range specs {
if r.Type == required.recType && r.Name == required.name {
found = true
}
}
if !found {
t.Errorf("%s %s not found in specs", required.recType, required.name)
}
}
// Verify MX
@@ -24,7 +24,7 @@ func NewMailbox(domainID uuid.UUID, address valueobject.EmailAddress, hash value
func (m *Mailbox) Address() valueobject.EmailAddress { return m.address }
func (m *Mailbox) UsedBytes() int64 { return m.usedBytes }
func (m *Mailbox) Quota() valueobject.QuotaLimit { return m.quota }
func (m *Mailbox) Quota() valueobject.QuotaLimit { return m.quota }
// RecordUsage sets used_bytes, enforcing "used_bytes <= quota_bytes" (PLAN.md section 3).
func (m *Mailbox) RecordUsage(bytes int64) error {
@@ -36,15 +36,23 @@ type OutboundJob struct {
CreatedAt time.Time
}
// CalculateNextBackoff computes the next retry time and DSN requirements based on attempt count.
func CalculateNextBackoff(now time.Time, attempt int, expiresAt time.Time) (nextAttemptAt time.Time, sendDelayDsn bool, permanentFailure bool) {
// CalculateNextBackoff computes the next retry time and DSN requirements from
// the attempt that has just failed. The delays follow PLAN.md section 6.3,
// which tabulates the wait *before* each attempt: attempt 2 runs 5 min after
// attempt 1 failed, attempt 4 an hour later and also emits the delay DSN, and
// from attempt 5 on the wait carries +/-20% jitter to avoid a thundering herd.
func CalculateNextBackoff(now time.Time, failedAttempt int, expiresAt time.Time) (nextAttemptAt time.Time, sendDelayDsn bool, permanentFailure bool) {
if !expiresAt.IsZero() && (now.After(expiresAt) || now.Equal(expiresAt)) {
return now, false, true
}
// The delay to apply is the one the retry table associates with the
// attempt about to be scheduled, not with the one that just failed.
nextAttempt := failedAttempt + 1
var baseDelay time.Duration
switch attempt {
switch nextAttempt {
case 1:
baseDelay = 0
case 2:
@@ -64,8 +72,8 @@ func CalculateNextBackoff(now time.Time, attempt int, expiresAt time.Time) (next
baseDelay = 12 * time.Hour
}
if baseDelay > 0 && attempt >= 5 {
// Apply ±20% jitter
if baseDelay > 0 && nextAttempt >= 5 {
// Apply +/-20% jitter
jitterFactor := 0.8 + rand.Float64()*0.4
baseDelay = time.Duration(float64(baseDelay) * jitterFactor)
}
@@ -5,26 +5,50 @@ import (
"time"
)
func TestCalculateNextBackoff_SchedulesCorrectDelays(t *testing.T) {
// PLAN.md section 6.3 defines the wait *before the next* attempt: after
// attempt 1 fails the next try is +5 min, after attempt 3 it is +1 h together
// with the delay DSN, and from the fifth try on the delay carries +/-20%
// jitter. A zero delay after the first failure would hammer the destination in
// a tight loop.
func TestCalculateNextBackoff_FollowsRetryTable(t *testing.T) {
now := time.Now()
expiresAt := now.Add(5 * 24 * time.Hour)
// Attempt 2 -> +5 min
next, delayDsn, perm := CalculateNextBackoff(now, 2, expiresAt)
if perm || delayDsn || next.Sub(now) < 4*time.Minute {
t.Errorf("attempt 2 backoff mismatch: next=%v, delayDsn=%v, perm=%v", next, delayDsn, perm)
cases := []struct {
failedAttempt int
wantMin time.Duration
wantMax time.Duration
wantDelayDsn bool
}{
{1, 5 * time.Minute, 5 * time.Minute, false},
{2, 15 * time.Minute, 15 * time.Minute, false},
{3, time.Hour, time.Hour, true},
{4, 96 * time.Minute, 144 * time.Minute, false}, // 2 h +/- 20%
{5, 192 * time.Minute, 288 * time.Minute, false}, // 4 h +/- 20%
{6, 384 * time.Minute, 576 * time.Minute, false}, // 8 h +/- 20%
{7, 576 * time.Minute, 864 * time.Minute, false}, // 12 h +/- 20%
{20, 576 * time.Minute, 864 * time.Minute, false}, // steady 12 h
}
// Attempt 4 -> +1 hr + delayDsn=true
next, delayDsn, perm = CalculateNextBackoff(now, 4, expiresAt)
if perm || !delayDsn || next.Sub(now) < 55*time.Minute {
t.Errorf("attempt 4 backoff mismatch: next=%v, delayDsn=%v, perm=%v", next, delayDsn, perm)
for _, tc := range cases {
next, delayDsn, perm := CalculateNextBackoff(now, tc.failedAttempt, expiresAt)
if perm {
t.Errorf("attempt %d: unexpected permanent failure", tc.failedAttempt)
continue
}
got := next.Sub(now)
if got < tc.wantMin || got > tc.wantMax {
t.Errorf("attempt %d: delay = %v, want between %v and %v", tc.failedAttempt, got, tc.wantMin, tc.wantMax)
}
if delayDsn != tc.wantDelayDsn {
t.Errorf("attempt %d: delayDsn = %v, want %v", tc.failedAttempt, delayDsn, tc.wantDelayDsn)
}
}
}
// Expired -> permanent failure
expiredTime := now.Add(-1 * time.Hour)
_, _, perm = CalculateNextBackoff(now, 5, expiredTime)
if !perm {
t.Errorf("expected permanent failure for expired job")
func TestCalculateNextBackoff_ExpiredJobIsPermanent(t *testing.T) {
now := time.Now()
if _, _, perm := CalculateNextBackoff(now, 5, now.Add(-time.Hour)); !perm {
t.Error("expected permanent failure for an expired job")
}
}
@@ -7,10 +7,10 @@ import (
)
var (
ErrEmptyScript = errors.New("sieve script cannot be empty")
ErrUnmatchedBrace = errors.New("unmatched curly brace in sieve script")
ErrUnmatchedBracket = errors.New("unmatched square bracket in sieve script")
ErrUnterminatedQuote = errors.New("unterminated string literal in sieve script")
ErrEmptyScript = errors.New("sieve script cannot be empty")
ErrUnmatchedBrace = errors.New("unmatched curly brace in sieve script")
ErrUnmatchedBracket = errors.New("unmatched square bracket in sieve script")
ErrUnterminatedQuote = errors.New("unterminated string literal in sieve script")
ErrUnterminatedComment = errors.New("unterminated comment block in sieve script")
)
@@ -32,8 +32,8 @@ if size :over 1M {
func TestValidateSieveScript_InvalidScripts(t *testing.T) {
tests := []struct {
name string
script string
name string
script string
wantErr error
}{
{"empty", "", ErrEmptyScript},
@@ -1,25 +1,155 @@
package entity
// TLSPolicy is the effective transport security policy for an outbound destination
// (PLAN.md section 3: DANE has precedence over MTA-STS, RFC 8461 section 2).
import (
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"strings"
"lambdamail/protocols/internal/domain/valueobject"
)
// Transport security modes, in the precedence order of RFC 8461 section 2:
// DANE wins when TLSA records exist, MTA-STS applies otherwise, and a
// destination with neither gets opportunistic TLS (RFC 7435).
const (
TLSModeDane = "dane"
TLSModeMtaSts = "mta-sts"
TLSModeOpportunistic = "opportunistic"
// TLSModeRelay records that the message left through a smarthost, which
// owns transport security from that point on (PLAN.md section 10.4).
TLSModeRelay = "relay"
)
// TLSPolicy is the effective transport security policy for one outbound
// destination (PLAN.md sections 5 and 6.2).
type TLSPolicy struct {
daneAvailable bool
mtaSTSAvailable bool
// enforce is true when a validation failure must defer the message
// instead of falling back to an unvalidated connection. It is false for
// MTA-STS "testing", whose whole purpose is to report without breaking.
enforce bool
// mxPatterns are the "mx:" entries of the MTA-STS policy. A host that
// matches none of them is not covered by the policy.
mxPatterns []string
// tlsaRecords are the DANE-EE associations published for the target host.
tlsaRecords []valueobject.TLSARecord
}
// NewTLSPolicy builds an opportunistic-or-simple policy. It is kept for
// callers that only need the precedence decision.
func NewTLSPolicy(daneAvailable, mtaSTSAvailable bool) TLSPolicy {
return TLSPolicy{daneAvailable: daneAvailable, mtaSTSAvailable: mtaSTSAvailable}
}
// Effective returns "dane", "mta-sts" or "opportunistic" following the precedence
// rule in PLAN.md section 5 / RFC 8461 section 2.
// NewDanePolicy builds a policy backed by TLSA records. DANE is always
// enforcing: the records are DNSSEC-signed, so a mismatch is an attack or a
// misconfiguration, never an acceptable downgrade (RFC 7672 section 2.2).
func NewDanePolicy(records []valueobject.TLSARecord) TLSPolicy {
return TLSPolicy{daneAvailable: true, enforce: true, tlsaRecords: records}
}
// NewMtaStsPolicy builds a policy from a fetched MTA-STS policy file.
func NewMtaStsPolicy(mxPatterns []string, enforce bool) TLSPolicy {
return TLSPolicy{mtaSTSAvailable: true, enforce: enforce, mxPatterns: mxPatterns}
}
// Effective returns "dane", "mta-sts" or "opportunistic" following the
// precedence rule in PLAN.md section 5 / RFC 8461 section 2.
func (p TLSPolicy) Effective() string {
switch {
case p.daneAvailable:
return "dane"
return TLSModeDane
case p.mtaSTSAvailable:
return "mta-sts"
return TLSModeMtaSts
default:
return "opportunistic"
return TLSModeOpportunistic
}
}
// RequiresValidation reports whether the connection's certificate has to be
// checked before any mail is handed over.
func (p TLSPolicy) RequiresValidation() bool {
return p.enforce && (p.daneAvailable || p.mtaSTSAvailable)
}
// CoversHost reports whether a candidate MX is inside the policy's scope. An
// MTA-STS policy that does not list the host means the host must not be used
// under that policy (RFC 8461 section 4.1).
func (p TLSPolicy) CoversHost(host string) bool {
if !p.mtaSTSAvailable {
return true
}
for _, pattern := range p.mxPatterns {
if matchMxPattern(pattern, host) {
return true
}
}
return false
}
// matchMxPattern implements the single-label wildcard of RFC 8461 section 4.1:
// "*.example.com" matches "mx1.example.com" but not "a.mx1.example.com".
func matchMxPattern(pattern, host string) bool {
pattern = strings.ToLower(strings.TrimSuffix(pattern, "."))
host = strings.ToLower(strings.TrimSuffix(host, "."))
if !strings.HasPrefix(pattern, "*.") {
return pattern == host
}
suffix := pattern[1:] // ".example.com"
if !strings.HasSuffix(host, suffix) {
return false
}
label := strings.TrimSuffix(host, suffix)
return label != "" && !strings.Contains(label, ".")
}
// MatchesCertificate checks a presented certificate chain against the DANE
// associations. Only usage 3 (DANE-EE) is supported by explicit decision
// (PLAN.md section 5.1), so the end-entity certificate is what gets matched
// and the PKIX chain is irrelevant.
func (p TLSPolicy) MatchesCertificate(chain []*x509.Certificate) bool {
if len(p.tlsaRecords) == 0 || len(chain) == 0 {
return false
}
leaf := chain[0]
for _, record := range p.tlsaRecords {
if digestFor(leaf, record) == strings.ToLower(record.Data()) {
return true
}
}
return false
}
// digestFor renders the certificate the way a TLSA record's selector and
// matching type describe it (RFC 6698 section 2.1.2).
func digestFor(cert *x509.Certificate, record valueobject.TLSARecord) string {
var material []byte
switch record.Selector() {
case 0: // full certificate
material = cert.Raw
case 1: // SubjectPublicKeyInfo
material = cert.RawSubjectPublicKeyInfo
default:
return ""
}
switch record.MatchingType() {
case 0: // exact match on the material itself
return strings.ToLower(hex.EncodeToString(material))
case 1: // SHA-256
sum := sha256.Sum256(material)
return hex.EncodeToString(sum[:])
default:
// SHA-512 (type 2) is deliberately unsupported: the reconciler only
// ever publishes "3 1 1" records (PLAN.md section 7.1).
return ""
}
}
@@ -13,75 +13,75 @@ type DomainEvent interface {
type EmailReceived struct{ MessageAggregateID uuid.UUID }
func (e EmailReceived) Type() string { return "EmailReceived" }
func (e EmailReceived) Type() string { return "EmailReceived" }
func (e EmailReceived) AggregateID() uuid.UUID { return e.MessageAggregateID }
type EmailAccepted struct{ MessageAggregateID uuid.UUID }
func (e EmailAccepted) Type() string { return "EmailAccepted" }
func (e EmailAccepted) Type() string { return "EmailAccepted" }
func (e EmailAccepted) AggregateID() uuid.UUID { return e.MessageAggregateID }
type EmailDelivered struct{ JobAggregateID uuid.UUID }
func (e EmailDelivered) Type() string { return "EmailDelivered" }
func (e EmailDelivered) Type() string { return "EmailDelivered" }
func (e EmailDelivered) AggregateID() uuid.UUID { return e.JobAggregateID }
type EmailDeferred struct{ JobAggregateID uuid.UUID }
func (e EmailDeferred) Type() string { return "EmailDeferred" }
func (e EmailDeferred) Type() string { return "EmailDeferred" }
func (e EmailDeferred) AggregateID() uuid.UUID { return e.JobAggregateID }
type EmailBounced struct{ JobAggregateID uuid.UUID }
func (e EmailBounced) Type() string { return "EmailBounced" }
func (e EmailBounced) Type() string { return "EmailBounced" }
func (e EmailBounced) AggregateID() uuid.UUID { return e.JobAggregateID }
type MailboxQuotaWarning struct{ MailboxAggregateID uuid.UUID }
func (e MailboxQuotaWarning) Type() string { return "MailboxQuotaWarning" }
func (e MailboxQuotaWarning) Type() string { return "MailboxQuotaWarning" }
func (e MailboxQuotaWarning) AggregateID() uuid.UUID { return e.MailboxAggregateID }
type MailboxQuotaExceeded struct{ MailboxAggregateID uuid.UUID }
func (e MailboxQuotaExceeded) Type() string { return "MailboxQuotaExceeded" }
func (e MailboxQuotaExceeded) Type() string { return "MailboxQuotaExceeded" }
func (e MailboxQuotaExceeded) AggregateID() uuid.UUID { return e.MailboxAggregateID }
type SpamDetected struct{ MessageAggregateID uuid.UUID }
func (e SpamDetected) Type() string { return "SpamDetected" }
func (e SpamDetected) Type() string { return "SpamDetected" }
func (e SpamDetected) AggregateID() uuid.UUID { return e.MessageAggregateID }
type VirusDetected struct{ MessageAggregateID uuid.UUID }
func (e VirusDetected) Type() string { return "VirusDetected" }
func (e VirusDetected) Type() string { return "VirusDetected" }
func (e VirusDetected) AggregateID() uuid.UUID { return e.MessageAggregateID }
type DkimRotationStarted struct{ DomainAggregateID uuid.UUID }
func (e DkimRotationStarted) Type() string { return "DkimRotationStarted" }
func (e DkimRotationStarted) Type() string { return "DkimRotationStarted" }
func (e DkimRotationStarted) AggregateID() uuid.UUID { return e.DomainAggregateID }
type DkimRotationCompleted struct{ DomainAggregateID uuid.UUID }
func (e DkimRotationCompleted) Type() string { return "DkimRotationCompleted" }
func (e DkimRotationCompleted) Type() string { return "DkimRotationCompleted" }
func (e DkimRotationCompleted) AggregateID() uuid.UUID { return e.DomainAggregateID }
type DnsRecordDrifted struct{ DomainAggregateID uuid.UUID }
func (e DnsRecordDrifted) Type() string { return "DnsRecordDrifted" }
func (e DnsRecordDrifted) Type() string { return "DnsRecordDrifted" }
func (e DnsRecordDrifted) AggregateID() uuid.UUID { return e.DomainAggregateID }
type CertificateRotated struct{ HostAggregateID uuid.UUID }
func (e CertificateRotated) Type() string { return "CertificateRotated" }
func (e CertificateRotated) Type() string { return "CertificateRotated" }
func (e CertificateRotated) AggregateID() uuid.UUID { return e.HostAggregateID }
type AuthenticationFailed struct{ MailboxAggregateID uuid.UUID }
func (e AuthenticationFailed) Type() string { return "AuthenticationFailed" }
func (e AuthenticationFailed) Type() string { return "AuthenticationFailed" }
func (e AuthenticationFailed) AggregateID() uuid.UUID { return e.MailboxAggregateID }
type RateLimitTriggered struct{ SubjectAggregateID uuid.UUID }
func (e RateLimitTriggered) Type() string { return "RateLimitTriggered" }
func (e RateLimitTriggered) Type() string { return "RateLimitTriggered" }
func (e RateLimitTriggered) AggregateID() uuid.UUID { return e.SubjectAggregateID }
@@ -11,9 +11,9 @@ type DomainName struct {
}
var (
ErrDomainNameEmpty = errors.New("domain name: must not be empty")
ErrDomainNameNotFQDN = errors.New("domain name: must contain at least one dot (not a FQDN)")
ErrDomainNameInvalidLabel = errors.New("domain name: label must not start or end with a hyphen")
ErrDomainNameEmpty = errors.New("domain name: must not be empty")
ErrDomainNameNotFQDN = errors.New("domain name: must contain at least one dot (not a FQDN)")
ErrDomainNameInvalidLabel = errors.New("domain name: label must not start or end with a hyphen")
)
// NewDomainName validates and normalizes raw into a DomainName.
@@ -14,9 +14,9 @@ type EmailAddress struct {
}
var (
ErrEmailMissingAtSign = errors.New("email address: missing '@' separator")
ErrEmailEmptyLocalPart = errors.New("email address: local part must not be empty")
ErrEmailEmptyDomain = errors.New("email address: domain must not be empty")
ErrEmailMissingAtSign = errors.New("email address: missing '@' separator")
ErrEmailEmptyLocalPart = errors.New("email address: local part must not be empty")
ErrEmailEmptyDomain = errors.New("email address: domain must not be empty")
ErrEmailLocalPartTooLong = errors.New("email address: local part exceeds 64 octets (RFC 5321 4.5.3.1.1)")
)
@@ -11,7 +11,7 @@ type MessageID struct {
}
var (
ErrMessageIDEmpty = errors.New("message id: must not be empty")
ErrMessageIDEmpty = errors.New("message id: must not be empty")
ErrMessageIDNotBracketed = errors.New("message id: must be wrapped in angle brackets")
)
@@ -12,7 +12,7 @@ func TestSpamVerdictFromScore_MatchesThresholds(t *testing.T) {
{3.9, SpamHam},
{4, SpamProbable},
{8.9, SpamProbable},
{9, SpamReject}, // section 10.1: 9-15 greylists/defers, modeled here as reject-eligible
{9, SpamReject}, // section 10.1: 9-15 greylists/defers, modeled here as reject-eligible
{15.1, SpamReject},
}
for _, c := range cases {
@@ -6,14 +6,12 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
gosmtp "github.com/emersion/go-smtp"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/google/uuid"
appusecase "lambdamail/protocols/internal/application/usecase"
@@ -47,22 +45,7 @@ func TestAntispamAntivirusEndToEnd(t *testing.T) {
}
defer pool.Close()
root := repoRoot(t)
migrations := []string{
"0001_init_schema.up.sql",
"0002_add_is_system_to_aliases.up.sql",
"0003_create_report_tables.up.sql",
}
for _, m := range migrations {
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
if err != nil {
t.Fatalf("read migration %s: %v", m, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", m, err)
}
}
applyMigrations(t, ctx, pool)
// Seed domain, mailbox, INBOX and Junk folders
domainID := uuid.New()
@@ -2,13 +2,11 @@ package e2e
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/google/uuid"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/domain/entity"
@@ -38,21 +36,7 @@ func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) {
}
defer pool.Close()
root := repoRoot(t)
migrations := []string{
"0001_init_schema.up.sql",
"0002_add_is_system_to_aliases.up.sql",
"0003_create_report_tables.up.sql",
}
for _, m := range migrations {
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
if err != nil {
t.Fatalf("read migration %s: %v", m, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", m, err)
}
}
applyMigrations(t, ctx, pool)
// Seed domain & admin mailbox
domainID := uuid.New()
@@ -86,8 +70,11 @@ func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) {
t.Fatalf("SyncDnsRecords execute: %v", err)
}
if out.CreatedCount != 13 {
t.Errorf("CreatedCount = %d, want 13", out.CreatedCount)
// The 13 numbered records of PLAN.md section 7.1 plus the three
// client-autoconfiguration records of section 7.2.
const expectedRecords = 16
if out.CreatedCount != expectedRecords {
t.Errorf("CreatedCount = %d, want %d", out.CreatedCount, expectedRecords)
}
// Verify 4 system aliases in PostgreSQL (postmaster@, abuse@, dmarc@, tlsrpt@)
@@ -7,8 +7,6 @@ import (
"encoding/base64"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -52,21 +50,7 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) {
}
defer pool.Close()
root := repoRoot(t)
migrations := []string{
"0001_init_schema.up.sql",
"0002_add_is_system_to_aliases.up.sql",
"0003_create_report_tables.up.sql",
}
for _, m := range migrations {
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
if err != nil {
t.Fatalf("read migration %s: %v", m, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", m, err)
}
}
applyMigrations(t, ctx, pool)
// 2. Seed domain, mailbox, INBOX, Archive
password := "correct horse battery staple"
@@ -443,7 +427,7 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) {
t.Fatalf("SETACTIVE = %q", activeResp)
}
// Verify RFC 5804 §2.7: DELETESCRIPT on active script MUST fail
// Verify RFC 5804 section 2.7: DELETESCRIPT on active script MUST fail
sendCmdSieve("DELETESCRIPT \"auto_rules\"")
if delActiveResp := readLineSieve(); !strings.HasPrefix(delActiveResp, "NO") {
t.Fatalf("expected NO for deleting active script, got %q", delActiveResp)
@@ -8,8 +8,6 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -19,12 +17,14 @@ import (
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/domain/entity"
"lambdamail/protocols/internal/domain/valueobject"
"lambdamail/protocols/internal/infrastructure/clamav"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres"
"lambdamail/protocols/internal/infrastructure/rspamd"
httppresentation "lambdamail/protocols/internal/presentation/http"
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
gosmtp "github.com/emersion/go-smtp"
)
@@ -96,22 +96,7 @@ func TestPhase3CompleteEndToEnd(t *testing.T) {
defer pool.Close()
// Apply Database Migrations
root := repoRoot(t)
migrations := []string{
"0001_init_schema.up.sql",
"0002_add_is_system_to_aliases.up.sql",
"0003_create_report_tables.up.sql",
}
for _, m := range migrations {
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
if err != nil {
t.Fatalf("read migration %s: %v", m, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", m, err)
}
}
applyMigrations(t, ctx, pool)
// -------------------------------------------------------------------
// 1. Sub-project 1: Cloudflare DNS Automation Engine & System Aliases
@@ -156,8 +141,14 @@ func TestPhase3CompleteEndToEnd(t *testing.T) {
if err != nil {
t.Fatalf("sync dns records: %v", err)
}
if dnsOut.CreatedCount != 13 {
t.Fatalf("expected 13 created DNS records, got %d", dnsOut.CreatedCount)
// The 13 numbered records of PLAN.md section 7.1 plus the three
// client-autoconfiguration records of section 7.2.
const expectedDnsRecords = 16
if dnsOut.CreatedCount != expectedDnsRecords {
t.Fatalf("expected %d created DNS records, got %d", expectedDnsRecords, dnsOut.CreatedCount)
}
if dnsOut.Status != appusecase.SyncStatusVerified {
t.Errorf("sync status = %s, want VERIFIED", dnsOut.Status)
}
// Verify system aliases created
@@ -174,14 +165,26 @@ func TestPhase3CompleteEndToEnd(t *testing.T) {
reportUC := appusecase.NewIngestReportsUseCase(reportRepo)
httpRouter := httppresentation.NewRouter(reportUC, func() error { return pool.Ping(ctx) })
// Test MTA-STS endpoint
// Test MTA-STS endpoint. The default is the safe end of the ramp
// (PLAN.md section 5): senders honour the policy but do not hard-fail on
// it until the operator promotes the domain to enforce.
reqSts := httptest.NewRequest("GET", "/.well-known/mta-sts.txt", nil)
reqSts.Host = "mta-sts.f3e2e.test"
recSts := httptest.NewRecorder()
httpRouter.ServeHTTP(recSts, reqSts)
if recSts.Code != http.StatusOK || !strings.Contains(recSts.Body.String(), "mode: enforce") {
if recSts.Code != http.StatusOK || !strings.Contains(recSts.Body.String(), "mode: testing") {
t.Errorf("MTA-STS endpoint error: code=%d body=%s", recSts.Code, recSts.Body.String())
}
if !strings.Contains(recSts.Body.String(), "mx: mail.f3e2e.test") {
t.Errorf("MTA-STS policy does not advertise the mail host: %s", recSts.Body.String())
}
httpRouter.SetMtaStsMode(valueobject.MtaStsModeEnforce)
recEnforced := httptest.NewRecorder()
httpRouter.ServeHTTP(recEnforced, httptest.NewRequest("GET", "/.well-known/mta-sts.txt", nil))
if !strings.Contains(recEnforced.Body.String(), "mode: enforce") {
t.Errorf("promoting the domain to enforce had no effect: %s", recEnforced.Body.String())
}
// Test Autoconfig endpoint
reqAuto := httptest.NewRequest("GET", "/.well-known/autoconfig/mail/config-v1.1.xml", nil)
@@ -6,8 +6,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -42,22 +40,7 @@ func TestHttpServicesEndToEnd(t *testing.T) {
}
defer pool.Close()
root := repoRoot(t)
migrations := []string{
"0001_init_schema.up.sql",
"0002_add_is_system_to_aliases.up.sql",
"0003_create_report_tables.up.sql",
}
for _, m := range migrations {
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
if err != nil {
t.Fatalf("read migration %s: %v", m, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", m, err)
}
}
applyMigrations(t, ctx, pool)
reportRepo := postgres.NewReportRepository(pool)
reportUseCase := appusecase.NewIngestReportsUseCase(reportRepo)
@@ -10,13 +10,13 @@ import (
"time"
"github.com/alexedwards/argon2id"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-imap/v2/imapserver"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/application/port"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres"
imappresentation "lambdamail/protocols/internal/presentation/imap"
@@ -10,13 +10,13 @@ import (
"time"
"github.com/alexedwards/argon2id"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-imap/v2/imapserver"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/application/port"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres"
imappresentation "lambdamail/protocols/internal/presentation/imap"
@@ -0,0 +1,79 @@
package e2e
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// applyMigrations brings a fresh database up to the current schema by running
// every up-migration in order. It reads the directory instead of taking a
// hand-written list so that adding a migration cannot leave a test silently
// running against a stale schema.
func applyMigrations(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
t.Helper()
dir := filepath.Join(repoRoot(t), "migrations")
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read migrations dir: %v", err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
names = append(names, e.Name())
}
}
if len(names) == 0 {
t.Fatalf("no up-migrations found in %s", dir)
}
sort.Strings(names)
for _, name := range names {
sql, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("read migration %s: %v", name, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", name, err)
}
}
}
// selfSignedTLSCert issues an in-memory certificate for the STARTTLS handshake
// in tests. Certificate validation is not what these tests exercise.
func selfSignedTLSCert(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "mail.test"},
DNSNames: []string{"mail.test", "localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
@@ -4,8 +4,6 @@ import (
"bufio"
"context"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -50,22 +48,7 @@ func TestOutboundQueueEndToEnd(t *testing.T) {
}
defer pool.Close()
root := repoRoot(t)
migrations := []string{
"0001_init_schema.up.sql",
"0002_add_is_system_to_aliases.up.sql",
"0003_create_report_tables.up.sql",
}
for _, m := range migrations {
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
if err != nil {
t.Fatalf("read migration %s: %v", m, err)
}
if _, err := pool.Exec(ctx, string(sql)); err != nil {
t.Fatalf("apply migration %s: %v", m, err)
}
}
applyMigrations(t, ctx, pool)
// Seed domain, sender mailbox, INBOX
domainID := uuid.New()
@@ -14,8 +14,8 @@ import (
"github.com/alexedwards/argon2id"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/application/port"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres"
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
@@ -19,8 +19,8 @@ import (
"testing"
"time"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
gosmtp "github.com/emersion/go-smtp"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
@@ -0,0 +1,360 @@
package e2e
import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"net"
"strings"
"testing"
"time"
"github.com/alexedwards/argon2id"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/google/uuid"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/dkim"
"lambdamail/protocols/internal/infrastructure/postgres"
"lambdamail/protocols/internal/infrastructure/vault"
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
gosmtp "github.com/emersion/go-smtp"
)
// TestSubmissionEndToEnd covers the outbound half of PLAN.md section 6.2:
// an authenticated client submits on the submission port, the message is
// signed with both DKIM keys, queued, and then delivered by the worker to a
// remote MX.
func TestSubmissionEndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("e2e test skipped in -short mode")
}
ctx := context.Background()
runtimeDir := t.TempDir()
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
Port(54344).
RuntimePath(runtimeDir).
StartTimeout(120 * time.Second))
if err := pg.Start(); err != nil {
t.Fatalf("start embedded postgres: %v", err)
}
defer pg.Stop()
dbURL := "postgres://postgres:postgres@localhost:54344/postgres?sslmode=disable"
pool, err := postgres.NewPool(ctx, dbURL)
if err != nil {
t.Fatalf("connect pool: %v", err)
}
defer pool.Close()
applyMigrations(t, ctx, pool)
// ---------------------------------------------------------------- seed
const (
senderAddress = "sender@submission.test"
password = "correct horse battery staple"
)
domainID, mailboxID, inboxID := uuid.New(), uuid.New(), uuid.New()
hash, err := argon2id.CreateHash(password, argon2id.DefaultParams)
if err != nil {
t.Fatalf("hash password: %v", err)
}
pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'submission.test', 'submission.test')`, domainID)
pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, max_recipients_per_hour)
VALUES ($1, $2, 'sender', $3, $4, 5)`, mailboxID, domainID, senderAddress, hash)
pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`, inboxID, mailboxID)
// ----------------------------------------------------------- provision
secretVault, err := vault.New("e2e-master-key")
if err != nil {
t.Fatalf("vault: %v", err)
}
dkimRepo := postgres.NewDkimRepository(pool, secretVault)
provisioner := appusecase.NewProvisionDkimKeysUseCase(dkimRepo, func(algorithm string) ([]byte, string, error) {
generated, err := dkim.Generate(algorithm)
if err != nil {
return nil, "", err
}
return generated.PrivateKeyPEM, generated.PublicKeyBase64, nil
})
keys, err := provisioner.Execute(ctx, "submission.test")
if err != nil {
t.Fatalf("provision DKIM keys: %v", err)
}
if keys.RsaPublicKey == "" || keys.Ed25519PublicKey == "" {
t.Fatal("provisioning produced no public keys for the DNS records")
}
// The private halves must not be readable from the table without the
// master key (PLAN.md section 9).
var storedCiphertext []byte
pool.QueryRow(ctx, `SELECT private_key_enc FROM dkim_keys WHERE algorithm = 'rsa2048'`).Scan(&storedCiphertext)
if strings.Contains(string(storedCiphertext), "PRIVATE KEY") {
t.Error("the DKIM private key was stored in the clear")
}
// ------------------------------------------------------------- wiring
blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir())
blobReader := diskstorage.NewLocalDiskBlobReader(pool)
authRepo := postgres.NewAuthRepository(pool)
outboundRepo := postgres.NewOutboundRepository(pool)
submissionUC := appusecase.NewProcessOutboundEmailUseCase(authRepo, outboundRepo, blobs, dkim.NewSigner(dkimRepo))
certificate := selfSignedTLSCert(t)
server := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC))
server.Domain = "mail.submission.test"
server.MaxMessageBytes = 10 << 20
server.MaxRecipients = 10
server.AllowInsecureAuth = false
server.TLSConfig = &tls.Config{Certificates: []tls.Certificate{certificate}}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go server.Serve(ln)
// -------------------------------------------------- submit over STARTTLS
submitMessage(t, ln.Addr().String(), senderAddress, password, "rcpt@remote.test")
// The job must be queued against the sending mailbox.
var queued int
var destinationDomain string
pool.QueryRow(ctx, `SELECT COUNT(*), COALESCE(MAX(destination_domain), '') FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).
Scan(&queued, &destinationDomain)
if queued != 1 {
t.Fatalf("queued %d jobs, want 1", queued)
}
if destinationDomain != "remote.test" {
t.Errorf("destination domain = %q, want remote.test", destinationDomain)
}
// The stored message must carry both signatures.
var blobID uuid.UUID
pool.QueryRow(ctx, `SELECT blob_id FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).Scan(&blobID)
stored, err := blobReader.ReadByID(ctx, blobID)
if err != nil {
t.Fatalf("read stored message: %v", err)
}
if count := strings.Count(string(stored), "DKIM-Signature:"); count != 2 {
t.Errorf("stored message carries %d DKIM signatures, want 2 (RSA and Ed25519)", count)
}
if !strings.Contains(string(stored), "d=submission.test") {
t.Errorf("signatures do not claim the sending domain:\n%s", stored)
}
// --------------------------------------------------------- delivery
received := make(chan string, 1)
mxAddr := startCapturingMX(t, received)
worker := appusecase.NewOutboundWorkerUseCase(
outboundRepo, &staticMXResolver{addr: mxAddr}, blobReader, nil, nil, "mail.submission.test",
)
if _, err := worker.ProcessBatch(ctx, "worker1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
var status, tlsPolicy string
pool.QueryRow(ctx, `SELECT status, COALESCE(tls_policy_used, '') FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).
Scan(&status, &tlsPolicy)
if status != "DELIVERED" {
var lastError string
pool.QueryRow(ctx, `SELECT COALESCE(last_error, '') FROM outbound_jobs WHERE mailbox_id = $1`, mailboxID).Scan(&lastError)
t.Fatalf("status = %s, want DELIVERED (last error: %s)", status, lastError)
}
if tlsPolicy != "opportunistic" {
t.Errorf("tls_policy_used = %q, want opportunistic for a destination with no published policy", tlsPolicy)
}
select {
case delivered := <-received:
if !strings.Contains(delivered, "DKIM-Signature:") {
t.Error("the delivered message lost its signatures")
}
case <-time.After(5 * time.Second):
t.Fatal("the remote MX never received the message")
}
// ------------------------------------------------ per-mailbox send limit
// The mailbox allows five recipients an hour and has used one.
assertSubmissionRefused(t, ln.Addr().String(), senderAddress, password,
[]string{"a@remote.test", "b@remote.test", "c@remote.test", "d@remote.test", "e@remote.test"})
// An account may not send as somebody else.
assertSenderRefused(t, ln.Addr().String(), senderAddress, password, "ceo@submission.test")
}
// submitMessage performs a full EHLO/STARTTLS/AUTH/MAIL/RCPT/DATA exchange.
func submitMessage(t *testing.T, addr, username, password, recipient string) {
t.Helper()
client, err := gosmtp.DialStartTLS(addr, &tls.Config{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("dial submission over STARTTLS: %v", err)
}
defer client.Close()
if err := client.Auth(plainAuth(username, password)); err != nil {
t.Fatalf("AUTH: %v", err)
}
if err := client.Mail(username, nil); err != nil {
t.Fatalf("MAIL FROM: %v", err)
}
if err := client.Rcpt(recipient, nil); err != nil {
t.Fatalf("RCPT TO: %v", err)
}
w, err := client.Data()
if err != nil {
t.Fatalf("DATA: %v", err)
}
message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: e2e\r\nDate: Mon, 20 Jul 2026 10:00:00 +0000\r\nMessage-ID: <e2e@submission.test>\r\n\r\nhello\r\n", username, recipient)
if _, err := w.Write([]byte(message)); err != nil {
t.Fatalf("write DATA: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close DATA: %v", err)
}
client.Quit()
}
// assertSubmissionRefused checks that going past the hourly recipient budget
// is refused with a temporary error rather than silently accepted.
func assertSubmissionRefused(t *testing.T, addr, username, password string, recipients []string) {
t.Helper()
client, err := gosmtp.DialStartTLS(addr, &tls.Config{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer client.Close()
client.Auth(plainAuth(username, password))
client.Mail(username, nil)
for _, r := range recipients {
client.Rcpt(r, nil)
}
w, err := client.Data()
if err != nil {
// Some servers refuse at DATA issue time; either point is acceptable.
return
}
w.Write([]byte("From: x\r\n\r\nbody\r\n"))
if err := w.Close(); err == nil {
t.Error("submission past the hourly recipient limit was accepted")
} else if !strings.Contains(err.Error(), "451") {
t.Errorf("error = %v, want a 451 temporary refusal", err)
}
}
func assertSenderRefused(t *testing.T, addr, username, password, forgedSender string) {
t.Helper()
client, err := gosmtp.DialStartTLS(addr, &tls.Config{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer client.Close()
client.Auth(plainAuth(username, password))
if err := client.Mail(forgedSender, nil); err == nil {
t.Errorf("MAIL FROM %s was accepted for an account that does not own it", forgedSender)
}
}
// startCapturingMX accepts one message and hands its body back on a channel.
func startCapturingMX(t *testing.T, received chan<- string) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
c.SetDeadline(time.Now().Add(10 * time.Second))
r := bufio.NewReader(c)
c.Write([]byte("220 capture.mx ESMTP\r\n"))
var body strings.Builder
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
switch cmd := strings.ToUpper(strings.TrimSpace(line)); {
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
c.Write([]byte("250 capture.mx\r\n"))
case cmd == "DATA":
c.Write([]byte("354 Send it\r\n"))
for {
dataLine, err := r.ReadString('\n')
if err != nil {
return
}
if strings.TrimSpace(dataLine) == "." {
break
}
body.WriteString(dataLine)
}
c.Write([]byte("250 2.0.0 Queued\r\n"))
select {
case received <- body.String():
default:
}
case cmd == "QUIT":
c.Write([]byte("221 Bye\r\n"))
return
default:
c.Write([]byte("250 Ok\r\n"))
}
}
}(conn)
}
}()
return ln.Addr().String()
}
// plainAuth builds a SASL PLAIN client without pulling in another dependency.
type plainAuthClient struct {
username, password string
done bool
}
func plainAuth(username, password string) *plainAuthClient {
return &plainAuthClient{username: username, password: password}
}
func (a *plainAuthClient) Start() (mech string, ir []byte, err error) {
return "PLAIN", []byte("\x00" + a.username + "\x00" + a.password), nil
}
func (a *plainAuthClient) Next(challenge []byte) ([]byte, error) {
if a.done {
return nil, fmt.Errorf("unexpected challenge %q", base64.StdEncoding.EncodeToString(challenge))
}
a.done = true
return nil, nil
}
@@ -5,240 +5,392 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
)
const (
// rateLimitMaxAttempts bounds the 429 retry loop so a throttled zone
// delays the reconcile instead of blocking it forever.
rateLimitMaxAttempts = 4
rateLimitMaxBackoff = 30 * time.Second
recordsPerPage = 100
)
type CloudflareAdapter struct {
apiToken string
baseURL string
apiToken string
baseURL string
httpClient *http.Client
}
func NewCloudflareAdapter(apiToken string) *CloudflareAdapter {
return &CloudflareAdapter{
apiToken: apiToken,
baseURL: "https://api.cloudflare.com/client/v4",
httpClient: &http.Client{},
apiToken: apiToken,
baseURL: "https://api.cloudflare.com/client/v4",
// Without a timeout a stalled API call would hold the reconcile - and
// on the periodic drift job, the goroutine - open indefinitely.
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
type cfError struct {
Message string `json:"message"`
}
type cfResultInfo struct {
Page int `json:"page"`
TotalPages int `json:"total_pages"`
}
type cfZoneResponse struct {
Success bool `json:"success"`
Result []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"result"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
Errors []cfError `json:"errors"`
}
type cfRecord struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Priority *int `json:"priority,omitempty"`
Proxied bool `json:"proxied,omitempty"`
Comment string `json:"comment,omitempty"`
ID string `json:"id,omitempty"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content,omitempty"`
TTL int `json:"ttl"`
// Pointers so that "absent" and "explicitly false" stay distinguishable:
// Cloudflare applies its own default for an omitted proxied flag, and a
// proxied mail host silently breaks SMTP (PLAN.md section 7.1).
Priority *int `json:"priority,omitempty"`
Proxied *bool `json:"proxied,omitempty"`
// Data carries the structured form Cloudflare requires for SRV and TLSA;
// those types reject a flat content string.
Data map[string]any `json:"data,omitempty"`
Comment string `json:"comment,omitempty"`
}
type cfRecordResponse struct {
Success bool `json:"success"`
Result []cfRecord `json:"result"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
Success bool `json:"success"`
Result []cfRecord `json:"result"`
ResultInfo cfResultInfo `json:"result_info"`
Errors []cfError `json:"errors"`
}
type cfSingleRecordResponse struct {
Success bool `json:"success"`
Result cfRecord `json:"result"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
Success bool `json:"success"`
Result cfRecord `json:"result"`
Errors []cfError `json:"errors"`
}
func (a *CloudflareAdapter) GetZoneID(ctx context.Context, domainName string) (string, error) {
reqURL := fmt.Sprintf("%s/zones?name=%s", a.baseURL, url.QueryEscape(domainName))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return "", err
}
a.setHeaders(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var data cfZoneResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return "", fmt.Errorf("decode json: %w", err)
if err := a.doJSON(ctx, http.MethodGet, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
return "", err
}
if !data.Success || len(data.Result) == 0 {
errMsg := "zone not found"
if len(data.Errors) > 0 {
errMsg = data.Errors[0].Message
}
return "", fmt.Errorf("cloudflare API error: %s", errMsg)
return "", fmt.Errorf("cloudflare: zone %q not found or token lacks Zone:Read on it", domainName)
}
return data.Result[0].ID, nil
}
func (a *CloudflareAdapter) ListRecords(ctx context.Context, zoneID string) ([]entity.DnsRecord, error) {
reqURL := fmt.Sprintf("%s/zones/%s/dns_records?per_page=100", a.baseURL, zoneID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
a.setHeaders(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var data cfRecordResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("decode json: %w", err)
}
if !data.Success {
return nil, fmt.Errorf("cloudflare API error listing records")
}
var out []entity.DnsRecord
for _, r := range data.Result {
out = append(out, entity.DnsRecord{
ID: r.ID,
Type: r.Type,
Name: r.Name,
Value: r.Content,
TTL: r.TTL,
Priority: r.Priority,
Proxied: r.Proxied,
Comment: r.Comment,
})
for page := 1; ; page++ {
reqURL := fmt.Sprintf("%s/zones/%s/dns_records?per_page=%d&page=%d", a.baseURL, zoneID, recordsPerPage, page)
var data cfRecordResponse
if err := a.doJSON(ctx, http.MethodGet, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
return nil, err
}
if !data.Success {
return nil, fmt.Errorf("cloudflare: listing records for zone %s failed", zoneID)
}
for _, r := range data.Result {
rec := entity.DnsRecord{
ID: r.ID,
Type: r.Type,
Name: r.Name,
Value: r.Content,
TTL: r.TTL,
Priority: r.Priority,
Comment: r.Comment,
}
if r.Proxied != nil {
rec.Proxied = *r.Proxied
}
// Cloudflare returns SRV and TLSA with content already rendered,
// but older zones and some responses only populate data; fall back
// to it so a structured record is not seen as an empty value and
// rewritten on every reconcile.
if rec.Value == "" && len(r.Data) > 0 {
rec.Value = renderStructuredValue(r.Type, r.Data)
}
out = append(out, rec)
}
// Zones larger than one page would otherwise look like they are
// missing records, and the reconciler would try to re-create them.
if data.ResultInfo.TotalPages <= page || len(data.Result) == 0 {
break
}
}
return out, nil
}
func (a *CloudflareAdapter) CreateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
reqURL := fmt.Sprintf("%s/zones/%s/dns_records", a.baseURL, zoneID)
cfBody := cfRecord{
Type: record.Type,
Name: record.Name,
Content: record.Value,
TTL: record.TTL,
Priority: record.Priority,
Proxied: record.Proxied,
Comment: record.Comment,
}
payload, err := json.Marshal(cfBody)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(payload))
if err != nil {
return err
}
a.setHeaders(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var data cfSingleRecordResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return err
if err := a.doJSON(ctx, http.MethodPost, reqURL, toCfRecord(record), &data, func() []cfError { return data.Errors }); err != nil {
return fmt.Errorf("create %s %s: %w", record.Type, record.Name, err)
}
if !data.Success {
errMsg := "create record failed"
if len(data.Errors) > 0 {
errMsg = data.Errors[0].Message
}
return fmt.Errorf("cloudflare API error: %s", errMsg)
return fmt.Errorf("cloudflare: create %s %s failed", record.Type, record.Name)
}
return nil
}
func (a *CloudflareAdapter) UpdateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, record.ID)
cfBody := cfRecord{
Type: record.Type,
Name: record.Name,
Content: record.Value,
TTL: record.TTL,
Priority: record.Priority,
Proxied: record.Proxied,
Comment: record.Comment,
}
payload, err := json.Marshal(cfBody)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewBuffer(payload))
if err != nil {
return err
}
a.setHeaders(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var data cfSingleRecordResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return err
if err := a.doJSON(ctx, http.MethodPut, reqURL, toCfRecord(record), &data, func() []cfError { return data.Errors }); err != nil {
return fmt.Errorf("update %s %s: %w", record.Type, record.Name, err)
}
if !data.Success {
return fmt.Errorf("cloudflare API error updating record")
return fmt.Errorf("cloudflare: update %s %s failed", record.Type, record.Name)
}
return nil
}
func (a *CloudflareAdapter) DeleteRecord(ctx context.Context, zoneID string, recordID string) error {
reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, recordID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil)
if err != nil {
return err
}
a.setHeaders(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return err
var data cfSingleRecordResponse
if err := a.doJSON(ctx, http.MethodDelete, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
return fmt.Errorf("delete record %s: %w", recordID, err)
}
if !data.Success {
return fmt.Errorf("cloudflare: delete record %s failed", recordID)
}
return nil
}
// toCfRecord maps a desired record onto the API payload. The proxied flag is
// only meaningful for the record types Cloudflare can proxy; sending it for a
// TXT or MX record is rejected.
func toCfRecord(record entity.DnsRecord) cfRecord {
body := cfRecord{
ID: record.ID,
Type: record.Type,
Name: record.Name,
Content: record.Value,
TTL: record.TTL,
Priority: record.Priority,
Comment: record.Comment,
}
switch strings.ToUpper(record.Type) {
case "A", "AAAA", "CNAME":
proxied := record.Proxied
body.Proxied = &proxied
case "SRV", "TLSA":
// These types are rejected with a flat content string; the API wants
// the components broken out.
if data := structuredData(record); data != nil {
body.Data = data
body.Content = ""
body.Priority = nil
}
}
return body
}
// structuredData splits an SRV or TLSA record value into the field map the
// Cloudflare API expects. It returns nil when the value is not in the
// canonical presentation form, letting the caller fall back to content.
func structuredData(record entity.DnsRecord) map[string]any {
fields := strings.Fields(record.Value)
switch strings.ToUpper(record.Type) {
case "SRV":
// Presentation form: "<priority> <weight> <port> <target>".
if len(fields) != 4 {
return nil
}
priority, err1 := strconv.Atoi(fields[0])
weight, err2 := strconv.Atoi(fields[1])
port, err3 := strconv.Atoi(fields[2])
if err1 != nil || err2 != nil || err3 != nil {
return nil
}
return map[string]any{
"priority": priority,
"weight": weight,
"port": port,
"target": strings.TrimSuffix(fields[3], "."),
}
case "TLSA":
// Presentation form: "<usage> <selector> <matching type> <data>".
if len(fields) != 4 {
return nil
}
usage, err1 := strconv.Atoi(fields[0])
selector, err2 := strconv.Atoi(fields[1])
matchingType, err3 := strconv.Atoi(fields[2])
if err1 != nil || err2 != nil || err3 != nil {
return nil
}
return map[string]any{
"usage": usage,
"selector": selector,
"matching_type": matchingType,
"certificate": fields[3],
}
}
defer resp.Body.Close()
return nil
}
func (a *CloudflareAdapter) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+a.apiToken)
req.Header.Set("Content-Type", "application/json")
// renderStructuredValue is the inverse of structuredData: it rebuilds the
// presentation form so the reconciler can compare a published record against
// the desired one.
func renderStructuredValue(recordType string, data map[string]any) string {
number := func(key string) string {
switch v := data[key].(type) {
case float64: // JSON numbers decode as float64
return strconv.Itoa(int(v))
case string:
return v
default:
return ""
}
}
text := func(key string) string {
if v, ok := data[key].(string); ok {
return strings.TrimSuffix(v, ".")
}
return ""
}
switch strings.ToUpper(recordType) {
case "SRV":
return strings.Join([]string{number("priority"), number("weight"), number("port"), text("target")}, " ")
case "TLSA":
return strings.Join([]string{number("usage"), number("selector"), number("matching_type"), text("certificate")}, " ")
default:
return ""
}
}
// doJSON performs one API call, retrying while Cloudflare reports 429 and
// honouring Retry-After (PLAN.md section 7.5). It fails on any non-2xx status
// rather than handing a decoder an error page.
func (a *CloudflareAdapter) doJSON(ctx context.Context, method, reqURL string, body any, out any, errsOf func() []cfError) error {
var payload []byte
if body != nil {
var err error
if payload, err = json.Marshal(body); err != nil {
return fmt.Errorf("encode request body: %w", err)
}
}
backoff := time.Second
for attempt := 1; ; attempt++ {
var reader io.Reader
if payload != nil {
reader = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, reader)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+a.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := a.httpClient.Do(req)
if err != nil {
return err
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < rateLimitMaxAttempts {
wait := retryAfter(resp.Header.Get("Retry-After"), backoff)
resp.Body.Close()
backoff = min(backoff*2, rateLimitMaxBackoff)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
continue
}
respBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return fmt.Errorf("read response body: %w", readErr)
}
// Decode first so an error envelope can be quoted back to the operator,
// then judge the status. Cloudflare returns its envelope on 4xx too.
decodeErr := json.Unmarshal(respBody, out)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("cloudflare API returned HTTP %d: %s", resp.StatusCode, describeErrors(errsOf(), respBody))
}
if decodeErr != nil {
return fmt.Errorf("decode json: %w", decodeErr)
}
return nil
}
}
func describeErrors(errs []cfError, raw []byte) string {
if len(errs) > 0 {
messages := make([]string, 0, len(errs))
for _, e := range errs {
messages = append(messages, e.Message)
}
return strings.Join(messages, "; ")
}
const maxRaw = 200
if len(raw) > maxRaw {
raw = raw[:maxRaw]
}
return strings.TrimSpace(string(raw))
}
// retryAfter reads the header as delta-seconds (RFC 9110 section 10.2.3),
// falling back to the caller's exponential backoff when it is absent or
// unparseable.
func retryAfter(header string, fallback time.Duration) time.Duration {
if header == "" {
return fallback
}
seconds, err := strconv.Atoi(strings.TrimSpace(header))
if err != nil || seconds < 0 {
return fallback
}
return time.Duration(seconds) * time.Second
}
// Ensure port interface compliance
@@ -0,0 +1,237 @@
package cloudflare
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"lambdamail/protocols/internal/domain/entity"
)
// A proxied A record for the mail host makes Cloudflare answer with its own
// anycast addresses, which do not speak SMTP - the domain stops receiving mail.
// PLAN.md section 7.1 requires proxied=false, so the flag must be sent
// explicitly rather than left to the zone default.
func TestCloudflareAdapter_CreateRecord_SendsProxiedFalseExplicitly(t *testing.T) {
var body map[string]interface{}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&body)
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": map[string]interface{}{"id": "rec_1"}})
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
err := adapter.CreateRecord(context.Background(), "zone1", entity.DnsRecord{
Type: "A", Name: "mail.example.test", Value: "192.0.2.1", TTL: 1, Proxied: false,
})
if err != nil {
t.Fatalf("CreateRecord: %v", err)
}
proxied, present := body["proxied"]
if !present {
t.Fatal("proxied flag was omitted from the request; Cloudflare would fall back to the zone default")
}
if proxied != false {
t.Errorf("proxied = %v, want false", proxied)
}
}
// An HTTP-level failure carries no Cloudflare envelope, so a decoder-only
// check reports a confusing JSON error - or worse, silently succeeds.
func TestCloudflareAdapter_ReportsHttpErrorStatus(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"success":false,"errors":[{"message":"Insufficient permissions"}]}`))
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
_, err := adapter.GetZoneID(context.Background(), "example.test")
if err == nil {
t.Fatal("expected an error for HTTP 403")
}
if !strings.Contains(err.Error(), "Insufficient permissions") {
t.Errorf("error should surface the API message, got: %v", err)
}
}
// PLAN.md section 7.5 lists the record listing as paginated. Stopping at the
// first page makes the reconciler believe records are missing and try to
// re-create them, which the API then rejects.
func TestCloudflareAdapter_ListRecords_FollowsPagination(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := r.URL.Query().Get("page")
if page == "" || page == "1" {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"result": []map[string]interface{}{
{"id": "rec_1", "type": "A", "name": "a.example.test", "content": "192.0.2.1"},
},
"result_info": map[string]interface{}{"page": 1, "total_pages": 2},
})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"result": []map[string]interface{}{
{"id": "rec_2", "type": "A", "name": "b.example.test", "content": "192.0.2.2"},
},
"result_info": map[string]interface{}{"page": 2, "total_pages": 2},
})
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
records, err := adapter.ListRecords(context.Background(), "zone1")
if err != nil {
t.Fatalf("ListRecords: %v", err)
}
if len(records) != 2 {
t.Fatalf("got %d records, want 2 across both pages", len(records))
}
}
// PLAN.md section 7.5 requires retrying 429 while honouring Retry-After.
func TestCloudflareAdapter_RetriesOnRateLimit(t *testing.T) {
var calls int
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"result": []map[string]interface{}{{"id": "zone_1", "name": "example.test"}},
})
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
zoneID, err := adapter.GetZoneID(context.Background(), "example.test")
if err != nil {
t.Fatalf("GetZoneID: %v", err)
}
if zoneID != "zone_1" {
t.Errorf("zoneID = %q, want zone_1", zoneID)
}
if calls != 2 {
t.Errorf("made %d calls, want 2 (one rate limited, one retried)", calls)
}
}
// A failed delete that reports success would silently leave stale records in
// the zone.
func TestCloudflareAdapter_DeleteRecord_ReportsFailure(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"success":false,"errors":[{"message":"Record not found"}]}`))
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
if err := adapter.DeleteRecord(context.Background(), "zone1", "rec_missing"); err == nil {
t.Fatal("expected an error when the delete fails")
}
}
// Cloudflare rejects SRV and TLSA records submitted as a flat content string;
// they must be broken into the structured data object.
func TestCloudflareAdapter_SendsStructuredDataForSrvAndTlsa(t *testing.T) {
cases := []struct {
name string
record entity.DnsRecord
expected map[string]interface{}
}{
{
name: "SRV",
record: entity.DnsRecord{Type: "SRV", Name: "_imaps._tcp.example.test", Value: "0 1 993 mail.example.test", TTL: 1},
expected: map[string]interface{}{
"priority": float64(0), "weight": float64(1), "port": float64(993), "target": "mail.example.test",
},
},
{
name: "TLSA",
record: entity.DnsRecord{Type: "TLSA", Name: "_25._tcp.mail.example.test", Value: "3 1 1 " + strings.Repeat("ab", 32), TTL: 1},
expected: map[string]interface{}{
"usage": float64(3), "selector": float64(1), "matching_type": float64(1), "certificate": strings.Repeat("ab", 32),
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var body map[string]interface{}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&body)
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": map[string]interface{}{"id": "rec_1"}})
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
if err := adapter.CreateRecord(context.Background(), "zone1", tc.record); err != nil {
t.Fatalf("CreateRecord: %v", err)
}
data, ok := body["data"].(map[string]interface{})
if !ok {
t.Fatalf("no structured data object sent; body = %v", body)
}
for key, want := range tc.expected {
if data[key] != want {
t.Errorf("data[%q] = %v, want %v", key, data[key], want)
}
}
if content, present := body["content"]; present && content != "" {
t.Errorf("content was sent alongside data: %v", content)
}
})
}
}
// A record returned with only a data object must round-trip back to the
// presentation form, or the reconciler sees an empty value and rewrites it on
// every run.
func TestCloudflareAdapter_ListRecords_RendersStructuredValues(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"result": []map[string]interface{}{{
"id": "rec_1", "type": "SRV", "name": "_imaps._tcp.example.test",
"data": map[string]interface{}{"priority": 0, "weight": 1, "port": 993, "target": "mail.example.test"},
}},
"result_info": map[string]interface{}{"page": 1, "total_pages": 1},
})
}))
defer ts.Close()
adapter := NewCloudflareAdapter("token")
adapter.baseURL = ts.URL
records, err := adapter.ListRecords(context.Background(), "zone1")
if err != nil {
t.Fatalf("ListRecords: %v", err)
}
if len(records) != 1 || records[0].Value != "0 1 993 mail.example.test" {
t.Errorf("value = %q, want the rendered presentation form", records[0].Value)
}
}
@@ -21,7 +21,7 @@ func NewAuthRepository(pool *pgxpool.Pool) *AuthRepository {
func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*port.MailboxAuth, error) {
row := r.pool.QueryRow(ctx, `
SELECT m.id, m.password_hash
SELECT m.id, m.password_hash, m.email_address, d.name, m.max_recipients_per_hour
FROM mailboxes m
JOIN domains d ON d.id = m.domain_id
WHERE m.email_address = $1
@@ -31,7 +31,7 @@ func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*po
var rec port.MailboxAuth
var id uuid.UUID
if err := row.Scan(&id, &rec.PasswordHash); err != nil {
if err := row.Scan(&id, &rec.PasswordHash, &rec.EmailAddress, &rec.DomainName, &rec.MaxRecipientsPerHour); err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
@@ -3,6 +3,9 @@ package postgres
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
@@ -37,8 +40,13 @@ func (r *OutboundRepository) FetchNextReady(ctx context.Context, workerID string
rows, err := r.pool.Query(ctx, `
WITH ready_jobs AS (
SELECT id FROM outbound_jobs
WHERE status IN ('QUEUED', 'DEFERRED')
AND next_attempt_at <= NOW()
WHERE (
(status IN ('QUEUED', 'DEFERRED') AND next_attempt_at <= NOW())
-- Reclaim jobs whose worker died mid-delivery. Without this
-- they stay SENDING forever and the message is silently
-- lost, which contradicts ADR-002's durability guarantee.
OR (status = 'SENDING' AND locked_until IS NOT NULL AND locked_until <= NOW())
)
ORDER BY next_attempt_at ASC
LIMIT $2
FOR UPDATE SKIP LOCKED
@@ -90,6 +98,22 @@ func (r *OutboundRepository) FetchNextReady(ctx context.Context, workerID string
return jobs, nil
}
// CountRecipientsSince counts queued recipients, not messages: one message to
// fifty addresses is fifty recipients against the limit, which is the number
// that matters for abuse.
func (r *OutboundRepository) CountRecipientsSince(ctx context.Context, mailboxID uuid.UUID, since time.Time) (int, error) {
var count int
err := r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM outbound_jobs
WHERE mailbox_id = $1 AND created_at >= $2
`, mailboxID, since).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count recent recipients for mailbox %s: %w", mailboxID, err)
}
return count, nil
}
func (r *OutboundRepository) UpdateJob(ctx context.Context, job *entity.OutboundJob) error {
_, err := r.pool.Exec(ctx, `
UPDATE outbound_jobs
@@ -7,15 +7,16 @@ import (
"fmt"
"net/http"
"strings"
"time"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/valueobject"
)
type RspamdResponse struct {
Action string `json:"action"`
Score float64 `json:"score"`
RequiredScore float64 `json:"required_score"`
Action string `json:"action"`
Score float64 `json:"score"`
RequiredScore float64 `json:"required_score"`
Symbols map[string]map[string]interface{} `json:"symbols"`
}
@@ -29,8 +30,11 @@ func NewRspamdAdapter(baseURL string) *RspamdAdapter {
baseURL = "http://localhost:11333"
}
return &RspamdAdapter{
baseURL: strings.TrimSuffix(baseURL, "/"),
httpClient: &http.Client{},
baseURL: strings.TrimSuffix(baseURL, "/"),
// A scan sits in the middle of an open SMTP transaction, so an
// unbounded request would pin the session (and the sender's socket)
// until the peer gives up.
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
@@ -86,7 +90,9 @@ func (a *RspamdAdapter) Scan(ctx context.Context, input port.ScanInput) (*valueo
switch parsed.Action {
case "reject":
res.Verdict = valueobject.ScanVerdictSpamReject
case "greylist":
// "soft reject" is Rspamd asking the sender to retry later (rate limit or
// transient policy); like a greylist it maps to a 4xx, never to acceptance.
case "greylist", "soft reject":
res.Verdict = valueobject.ScanVerdictGreylist
case "add header", "rewrite subject":
res.Verdict = valueobject.ScanVerdictSpamJunk
@@ -18,7 +18,12 @@ func TestRspamdAdapter_Scan_MapsActions(t *testing.T) {
}{
{"no action", valueobject.ScanVerdictClean},
{"add header", valueobject.ScanVerdictSpamJunk},
{"rewrite subject", valueobject.ScanVerdictSpamJunk},
{"greylist", valueobject.ScanVerdictGreylist},
// Rspamd's "soft reject" asks the sender to come back later (rate
// limit, transient policy). Treating it as clean would accept mail
// the filter explicitly refused to accept yet.
{"soft reject", valueobject.ScanVerdictGreylist},
{"reject", valueobject.ScanVerdictSpamReject},
}
@@ -3,6 +3,7 @@ package httppresentation
import (
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
@@ -11,10 +12,16 @@ import (
"lambdamail/protocols/internal/domain/valueobject"
)
// mtaStsMaxAge is the policy lifetime advertised to senders. RFC 8461
// recommends a value in the order of weeks so that an attacker cannot strip
// the policy by making the endpoint unreachable.
const mtaStsMaxAge = 604800 // 7 days
type Router struct {
mux *http.ServeMux
reportUseCase *appusecase.IngestReportsUseCase
pingFunc func() error
mtaStsMode valueobject.MtaStsMode
}
func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() error) *Router {
@@ -22,11 +29,24 @@ func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() e
mux: http.NewServeMux(),
reportUseCase: reportUseCase,
pingFunc: pingFunc,
// PLAN.md section 5 ramps MTA-STS from testing to enforce. Starting at
// enforce means any TLS misconfiguration silently blackholes inbound
// mail from every sender that honours the policy, with no way to tell
// from this side - so the safe mode is the default and the operator
// promotes it once TLS-RPT reports come back clean.
mtaStsMode: valueobject.MtaStsModeTesting,
}
r.registerRoutes()
return r
}
// SetMtaStsMode selects the mode advertised in the served policy. Callers
// should promote to enforce only after TLS-RPT confirms senders can negotiate
// TLS against the published MX.
func (r *Router) SetMtaStsMode(mode valueobject.MtaStsMode) {
r.mtaStsMode = mode
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mux.ServeHTTP(w, req)
}
@@ -57,19 +77,23 @@ func (r *Router) handleMtaSts(w http.ResponseWriter, req *http.Request) {
host := extractHost(req.Host)
domainName := strings.TrimPrefix(host, "mta-sts.")
policy := valueobject.NewMtaStsPolicy(valueobject.MtaStsModeEnforce, []string{fmt.Sprintf("mail.%s", domainName)}, 604800)
policy := valueobject.NewMtaStsPolicy(r.mtaStsMode, []string{fmt.Sprintf("mail.%s", domainName)}, mtaStsMaxAge)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
// Kept in step with max_age so an HTTP cache cannot outlive the policy it
// is caching.
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", mtaStsMaxAge))
w.WriteHeader(http.StatusOK)
w.Write([]byte(policy.Format()))
}
func (r *Router) handleSecurityTxt(w http.ResponseWriter, req *http.Request) {
host := extractHost(req.Host)
// The contact must be a real mailbox on the mail domain, not on whichever
// service hostname the request happened to arrive at.
domainName := baseDomain(extractHost(req.Host))
expires := time.Now().AddDate(1, 0, 0).Format(time.RFC3339)
content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", host, expires)
content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", domainName, expires)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
@@ -144,12 +168,27 @@ func (r *Router) handleTlsRptIngest(w http.ResponseWriter, req *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
}
// extractHost strips the optional port from a Host header, handling the
// bracketed IPv6 literal form as well.
func extractHost(hostHeader string) string {
if h, _, err := strings.Cut(hostHeader, ":"); err && h != "" {
return h
}
if hostHeader == "" {
return "example.test"
}
return hostHeader
if host, _, err := net.SplitHostPort(hostHeader); err == nil && host != "" {
return host
}
return strings.Trim(hostHeader, "[]")
}
// serviceHostPrefixes are the per-service names published under a mail domain
// (PLAN.md section 7.3); stripping one yields the mail domain itself.
var serviceHostPrefixes = []string{"mta-sts.", "autoconfig.", "autodiscover.", "mail."}
func baseDomain(host string) string {
for _, prefix := range serviceHostPrefixes {
if strings.HasPrefix(host, prefix) {
return strings.TrimPrefix(host, prefix)
}
}
return host
}
@@ -24,4 +24,3 @@ func (s *session) Unsubscribe(_ string) error { return errN
func (s *session) Append(_ string, _ imap.LiteralReader, _ *imap.AppendOptions) (*imap.AppendData, error) {
return nil, errNotYetImplemented
}
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"io"
"net"
"strings"
gosmtp "github.com/emersion/go-smtp"
@@ -24,19 +25,44 @@ func NewBackend(uc *usecase.ProcessInboundEmailUseCase) *Backend {
return &Backend{useCase: uc}
}
func (b *Backend) NewSession(_ *gosmtp.Conn) (gosmtp.Session, error) {
return &session{useCase: b.useCase}, nil
func (b *Backend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) {
return &session{useCase: b.useCase, conn: c}, nil
}
// session holds one SMTP transaction's accumulated state (RFC 5321 section
// 4.1.1: MAIL, then one or more RCPT, then DATA, then reset).
type session struct {
useCase *usecase.ProcessInboundEmailUseCase
conn *gosmtp.Conn
from string
recipients []port.MailboxRecord
recipientAddresses []string
}
// clientIP is the peer address SPF is evaluated against. It returns nil when
// the connection is not available, which downgrades SPF to "none" rather than
// producing a wrong verdict.
func (s *session) clientIP() net.IP {
if s.conn == nil || s.conn.Conn() == nil {
return nil
}
if addr, ok := s.conn.Conn().RemoteAddr().(*net.TCPAddr); ok {
return addr.IP
}
host, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
if err != nil {
return nil
}
return net.ParseIP(host)
}
func (s *session) heloDomain() string {
if s.conn == nil {
return ""
}
return s.conn.Hostname()
}
func (s *session) Reset() {
s.from = ""
s.recipients = nil
@@ -107,6 +133,8 @@ func (s *session) Data(r io.Reader) error {
Recipients: s.recipients,
RecipientAddresses: s.recipientAddresses,
Body: r,
ClientIP: s.clientIP(),
HeloDomain: s.heloDomain(),
})
if err != nil {
var smtpErr *gosmtp.SMTPError
@@ -114,6 +142,13 @@ func (s *session) Data(r io.Reader) error {
return smtpErr
}
errMsg := err.Error()
if strings.HasPrefix(errMsg, "550 ") {
return &gosmtp.SMTPError{
Code: 550,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 1},
Message: strings.TrimPrefix(errMsg, "550 5.7.1 "),
}
}
if strings.HasPrefix(errMsg, "554 ") {
return &gosmtp.SMTPError{
Code: 554,
@@ -0,0 +1,67 @@
package smtppresentation
import (
"errors"
"github.com/emersion/go-sasl"
)
// loginMechanism is the SASL mechanism name for the legacy LOGIN exchange.
// PLAN.md section 4 requires it alongside PLAIN because several desktop and
// mobile clients still offer nothing else. Neither go-sasl nor go-smtp ships a
// server side for it, so it is implemented here.
const loginMechanism = "LOGIN"
// LOGIN is not specified by an RFC; it is a de facto mechanism where the
// server prompts for the username, then for the password, each as a bare
// challenge string.
var (
loginUsernameChallenge = []byte("Username:")
loginPasswordChallenge = []byte("Password:")
)
// loginAuthenticator verifies a username and password pair.
type loginAuthenticator func(username, password string) error
// loginServer implements sasl.Server for the LOGIN mechanism.
type loginServer struct {
authenticate loginAuthenticator
username string
// state advances from awaiting the username, to awaiting the password,
// to finished.
state int
}
const (
loginStateAwaitingUsername = iota
loginStateAwaitingPassword
loginStateDone
)
func newLoginServer(authenticate loginAuthenticator) sasl.Server {
return &loginServer{authenticate: authenticate}
}
func (s *loginServer) Next(response []byte) (challenge []byte, done bool, err error) {
switch s.state {
case loginStateAwaitingUsername:
// A client may send the username as the initial response, in which
// case there is nothing to prompt for yet.
if response == nil {
return loginUsernameChallenge, false, nil
}
s.username = string(response)
s.state = loginStateAwaitingPassword
return loginPasswordChallenge, false, nil
case loginStateAwaitingPassword:
s.state = loginStateDone
if err := s.authenticate(s.username, string(response)); err != nil {
return nil, false, err
}
return nil, true, nil
default:
return nil, false, errors.New("sasl: LOGIN exchange already finished")
}
}
@@ -0,0 +1,67 @@
package smtppresentation
import (
"errors"
"testing"
)
// The LOGIN exchange prompts for the username, then the password, and only
// then authenticates.
func TestLoginServer_PromptsThenAuthenticates(t *testing.T) {
var gotUser, gotPass string
server := newLoginServer(func(username, password string) error {
gotUser, gotPass = username, password
return nil
})
challenge, done, err := server.Next(nil)
if err != nil || done || string(challenge) != "Username:" {
t.Fatalf("first step: challenge=%q done=%v err=%v", challenge, done, err)
}
challenge, done, err = server.Next([]byte("user@example.test"))
if err != nil || done || string(challenge) != "Password:" {
t.Fatalf("second step: challenge=%q done=%v err=%v", challenge, done, err)
}
_, done, err = server.Next([]byte("secret"))
if err != nil || !done {
t.Fatalf("third step: done=%v err=%v", done, err)
}
if gotUser != "user@example.test" || gotPass != "secret" {
t.Errorf("credentials = %q/%q", gotUser, gotPass)
}
}
// A client that sends the username as its initial response skips the first
// prompt.
func TestLoginServer_AcceptsInitialResponse(t *testing.T) {
server := newLoginServer(func(string, string) error { return nil })
challenge, done, err := server.Next([]byte("user@example.test"))
if err != nil || done || string(challenge) != "Password:" {
t.Fatalf("challenge=%q done=%v err=%v", challenge, done, err)
}
}
func TestLoginServer_PropagatesAuthFailure(t *testing.T) {
authErr := errors.New("bad credentials")
server := newLoginServer(func(string, string) error { return authErr })
server.Next(nil)
server.Next([]byte("user@example.test"))
if _, done, err := server.Next([]byte("wrong")); err == nil || done {
t.Fatalf("expected the failure to surface: done=%v err=%v", done, err)
}
}
func TestLoginServer_RefusesExtraSteps(t *testing.T) {
server := newLoginServer(func(string, string) error { return nil })
server.Next(nil)
server.Next([]byte("user@example.test"))
server.Next([]byte("secret"))
if _, _, err := server.Next([]byte("extra")); err == nil {
t.Fatal("expected an error after the exchange finished")
}
}
@@ -0,0 +1,159 @@
package smtppresentation
import (
"context"
"errors"
"io"
"github.com/emersion/go-sasl"
gosmtp "github.com/emersion/go-smtp"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
)
// SubmissionBackend serves the submission ports (587 and 465). It is a
// separate backend from the MX one on purpose: submission always requires
// authentication and always relays outward, while port 25 never authenticates
// and only accepts mail for local mailboxes (PLAN.md section 4).
type SubmissionBackend struct {
useCase *usecase.ProcessOutboundEmailUseCase
}
func NewSubmissionBackend(uc *usecase.ProcessOutboundEmailUseCase) *SubmissionBackend {
return &SubmissionBackend{useCase: uc}
}
func (b *SubmissionBackend) NewSession(_ *gosmtp.Conn) (gosmtp.Session, error) {
return &submissionSession{useCase: b.useCase}, nil
}
type submissionSession struct {
useCase *usecase.ProcessOutboundEmailUseCase
// authenticated is nil until AUTH succeeds; every other command checks it.
authenticated *port.MailboxAuth
from string
recipients []string
}
// AuthMechanisms advertises PLAIN and LOGIN. The server only offers them once
// the connection is encrypted - go-smtp withholds AUTH before TLS unless
// AllowInsecureAuth is set, which this deployment never sets (PLAN.md
// section 4: credentials never travel in the clear).
func (s *submissionSession) AuthMechanisms() []string {
return []string{sasl.Plain, loginMechanism}
}
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
switch mech {
case sasl.Plain:
return sasl.NewPlainServer(func(identity, username, password string) error {
return s.authenticate(username, password)
}), nil
case loginMechanism:
return newLoginServer(s.authenticate), nil
default:
return nil, gosmtp.ErrAuthUnsupported
}
}
func (s *submissionSession) authenticate(username, password string) error {
rec, err := s.useCase.Authenticate(context.Background(), username, password)
if err != nil {
return &gosmtp.SMTPError{
Code: 535,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 8},
Message: "Authentication credentials invalid",
}
}
s.authenticated = rec
return nil
}
func (s *submissionSession) Reset() {
s.from = ""
s.recipients = nil
}
func (s *submissionSession) Logout() error {
s.authenticated = nil
return nil
}
func (s *submissionSession) Mail(from string, _ *gosmtp.MailOptions) error {
if s.authenticated == nil {
return errAuthRequired()
}
if err := usecase.AuthorizeSender(s.authenticated, from); err != nil {
return &gosmtp.SMTPError{
Code: 550,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 1},
Message: "Sender address not owned by the authenticated account",
}
}
s.from = from
return nil
}
func (s *submissionSession) Rcpt(to string, _ *gosmtp.RcptOptions) error {
if s.authenticated == nil {
return errAuthRequired()
}
s.recipients = append(s.recipients, to)
return nil
}
func (s *submissionSession) Data(r io.Reader) error {
if s.authenticated == nil {
return errAuthRequired()
}
if len(s.recipients) == 0 {
return &gosmtp.SMTPError{
Code: 503,
EnhancedCode: gosmtp.EnhancedCode{5, 5, 1},
Message: "RCPT TO required before DATA",
}
}
err := s.useCase.Submit(context.Background(), usecase.ProcessOutboundEmailInput{
MailboxID: s.authenticated.ID,
SenderAddr: s.from,
DomainName: s.authenticated.DomainName,
Recipients: s.recipients,
Body: r,
MaxRecipientsPerHour: s.authenticated.MaxRecipientsPerHour,
})
if err == nil {
return nil
}
// A rate-limited sender gets a 4xx: the message is legitimate, the
// account has simply run out of budget for this hour and may retry.
if errors.Is(err, usecase.ErrSendLimitExceeded) {
return &gosmtp.SMTPError{
Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 7, 0},
Message: "Hourly recipient limit reached, try again later",
}
}
return &gosmtp.SMTPError{
Code: 451,
EnhancedCode: gosmtp.EnhancedCode{4, 3, 0},
Message: "Temporary error accepting message for delivery",
}
}
func errAuthRequired() error {
return &gosmtp.SMTPError{
Code: 530,
EnhancedCode: gosmtp.EnhancedCode{5, 7, 0},
Message: "Authentication required",
}
}
// Ensure the session satisfies both the base and the auth add-on interface.
var (
_ gosmtp.Session = (*submissionSession)(nil)
_ gosmtp.AuthSession = (*submissionSession)(nil)
)
@@ -0,0 +1,94 @@
package smtppresentation
import (
"strings"
"testing"
gosmtp "github.com/emersion/go-smtp"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/application/usecase"
)
func smtpCode(t *testing.T, err error) int {
t.Helper()
if err == nil {
return 0
}
var smtpErr *gosmtp.SMTPError
if !ok(err, &smtpErr) {
t.Fatalf("error is not an SMTPError: %v", err)
}
return smtpErr.Code
}
func ok(err error, target **gosmtp.SMTPError) bool {
e, is := err.(*gosmtp.SMTPError)
if is {
*target = e
}
return is
}
// Every command before AUTH must be refused: submission that accepted an
// unauthenticated MAIL FROM would be an open relay.
func TestSubmissionSession_RefusesCommandsBeforeAuth(t *testing.T) {
s := &submissionSession{}
if got := smtpCode(t, s.Mail("user@example.test", nil)); got != 530 {
t.Errorf("MAIL FROM before AUTH = %d, want 530", got)
}
if got := smtpCode(t, s.Rcpt("rcpt@remote.test", nil)); got != 530 {
t.Errorf("RCPT TO before AUTH = %d, want 530", got)
}
if got := smtpCode(t, s.Data(strings.NewReader("body"))); got != 530 {
t.Errorf("DATA before AUTH = %d, want 530", got)
}
}
// An authenticated account must not be able to send as anybody else.
func TestSubmissionSession_RefusesForeignSender(t *testing.T) {
s := &submissionSession{authenticated: &port.MailboxAuth{EmailAddress: "user@example.test"}}
if err := s.Mail("user@example.test", nil); err != nil {
t.Fatalf("sending as itself was refused: %v", err)
}
if got := smtpCode(t, s.Mail("ceo@example.test", nil)); got != 550 {
t.Errorf("MAIL FROM a foreign address = %d, want 550", got)
}
}
func TestSubmissionSession_AdvertisesPlainAndLogin(t *testing.T) {
mechs := (&submissionSession{}).AuthMechanisms()
joined := strings.Join(mechs, " ")
if !strings.Contains(joined, "PLAIN") || !strings.Contains(joined, "LOGIN") {
t.Errorf("AuthMechanisms = %v, want PLAIN and LOGIN", mechs)
}
}
func TestSubmissionSession_RejectsUnsupportedMechanism(t *testing.T) {
if _, err := (&submissionSession{}).Auth("SCRAM-SHA-256"); err == nil {
t.Fatal("expected an unsupported-mechanism error")
}
}
// Reset must clear the envelope but keep the session authenticated: RSET
// starts a new transaction, it does not log the user out.
func TestSubmissionSession_ResetKeepsAuthentication(t *testing.T) {
s := &submissionSession{authenticated: &port.MailboxAuth{EmailAddress: "user@example.test"}}
s.Mail("user@example.test", nil)
s.Rcpt("rcpt@remote.test", nil)
s.Reset()
if s.from != "" || len(s.recipients) != 0 {
t.Errorf("envelope survived RSET: from=%q recipients=%v", s.from, s.recipients)
}
if s.authenticated == nil {
t.Error("RSET logged the session out")
}
}
// Compile-time proof that the use case wiring matches what the session needs.
var _ = usecase.NewProcessOutboundEmailUseCase