#18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics
This commit is contained in:
1 parent
7249419efc
commit
d56f29d084
59 files changed
+5046
-542
No files matched your search
+46
-4
@@ -8,23 +8,65 @@ REDIS_PASSWORD=change-me-in-local-dev
|
|||||||
|
|
||||||
# --- Shared secrets (local dev values — never reuse in production) ---
|
# --- Shared secrets (local dev values — never reuse in production) ---
|
||||||
JWT_SECRET=local-dev-jwt-secret-change-me
|
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
|
LAMBDAMAIL_MASTER_KEY=local-dev-master-key-32-bytes-min
|
||||||
|
|
||||||
# --- Cloudflare (optional in F0 — DNS automation lands in F3) ---
|
|
||||||
CLOUDFLARE_API_TOKEN=
|
|
||||||
|
|
||||||
# --- Host identity ---
|
# --- Host identity ---
|
||||||
PRIMARY_MAIL_HOST=mail.localhost
|
PRIMARY_MAIL_HOST=mail.localhost
|
||||||
|
MAIL_DOMAIN=localhost
|
||||||
PUBLIC_IPV4=127.0.0.1
|
PUBLIC_IPV4=127.0.0.1
|
||||||
PUBLIC_IPV6=
|
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
|
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
|
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_CERT_PATH=/etc/lambdamail/certs/cert.pem
|
||||||
PROTOCOLS_KEY_PATH=/etc/lambdamail/certs/key.pem
|
PROTOCOLS_KEY_PATH=/etc/lambdamail/certs/key.pem
|
||||||
PROTOCOLS_SPOOL_DIR=/var/lambdamail/spool
|
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 ---
|
||||||
WEBMAIL_HOST=localhost:3000
|
WEBMAIL_HOST=localhost:3000
|
||||||
ADMIN_HOST=
|
ADMIN_HOST=
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
type = "console";
|
||||||
|
level = "info";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -2,42 +2,62 @@ x-logging: &default-logging
|
|||||||
driver: json-file
|
driver: json-file
|
||||||
options: { max-size: "10m", max-file: "3" }
|
options: { max-size: "10m", max-file: "3" }
|
||||||
|
|
||||||
|
x-restart: &restart unless-stopped
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# ---------------------------------------------------------------- DB
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: *restart
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:?required}
|
POSTGRES_DB: ${POSTGRES_DB:?required}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:?required}
|
POSTGRES_USER: ${POSTGRES_USER:?required}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required} # no default: fail fast
|
||||||
ports: ["5432:5432"]
|
POSTGRES_INITDB_ARGS: "--data-checksums"
|
||||||
|
command: >
|
||||||
|
postgres -c shared_buffers=256MB -c max_connections=200
|
||||||
|
-c wal_level=replica
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
interval: 5s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
mem_limit: 1g
|
||||||
|
cpus: 1.0
|
||||||
|
networks: [backend]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- REDIS
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
restart: unless-stopped
|
restart: *restart
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
command: >
|
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:
|
environment:
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
volumes: [redis_data:/data]
|
volumes: [redis_data:/data]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "redis-cli --no-auth-warning -a $${REDIS_PASSWORD} ping | grep -q PONG"]
|
test: ["CMD-SHELL", "redis-cli --no-auth-warning -a $${REDIS_PASSWORD} ping | grep -q PONG"]
|
||||||
interval: 5s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 5
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 0.5
|
||||||
|
networks: [backend]
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- MIGRATE
|
||||||
migrate:
|
migrate:
|
||||||
image: migrate/migrate:v4.17.1
|
image: migrate/migrate:v4.17.1
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
logging: *default-logging
|
||||||
volumes: ["./migrations:/migrations"]
|
volumes: ["./migrations:/migrations"]
|
||||||
depends_on:
|
depends_on:
|
||||||
db: { condition: service_healthy }
|
db: { condition: service_healthy }
|
||||||
@@ -47,91 +67,243 @@ services:
|
|||||||
- -database
|
- -database
|
||||||
- postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
|
- postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
|
||||||
- up
|
- 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:
|
auth:
|
||||||
build: { context: ./services/auth }
|
build: { context: ./services/auth }
|
||||||
restart: unless-stopped
|
restart: *restart
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
environment:
|
environment:
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
JWT_SECRET: ${JWT_SECRET:?required}
|
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:
|
depends_on:
|
||||||
db: { condition: service_healthy }
|
db: { condition: service_healthy }
|
||||||
redis: { condition: service_healthy }
|
redis: { condition: service_healthy }
|
||||||
migrate: { condition: service_completed_successfully }
|
migrate: { condition: service_completed_successfully }
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3001/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
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
|
timeout: 5s
|
||||||
retries: 5
|
retries: 3
|
||||||
start_period: 15s
|
start_period: 20s
|
||||||
|
mem_limit: 512m
|
||||||
|
networks: [backend, egress] # egress: Cloudflare API. Without it DNS sync
|
||||||
|
# fails silently, since backend is internal.
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- STORAGE
|
||||||
storage:
|
storage:
|
||||||
build: { context: ./services/storage }
|
build: { context: ./services/storage }
|
||||||
restart: unless-stopped
|
restart: *restart
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
environment:
|
environment:
|
||||||
PORT: 3002
|
PORT: 3002
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
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:
|
depends_on:
|
||||||
db: { condition: service_healthy }
|
db: { condition: service_healthy }
|
||||||
redis: { condition: service_healthy }
|
redis: { condition: service_healthy }
|
||||||
migrate: { condition: service_completed_successfully }
|
migrate: { condition: service_completed_successfully }
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
interval: 10s
|
interval: 15s
|
||||||
timeout: 5s
|
retries: 3
|
||||||
retries: 5
|
start_period: 20s
|
||||||
start_period: 15s
|
mem_limit: 1g
|
||||||
|
cpus: 1.0
|
||||||
|
networks: [backend, egress] # egress: external S3 when STORAGE_DRIVER=s3
|
||||||
|
|
||||||
|
# --------------------------------------------------------- PROTOCOLS
|
||||||
protocols:
|
protocols:
|
||||||
build: { context: ./services/protocols }
|
build: { context: ./services/protocols }
|
||||||
restart: unless-stopped
|
restart: *restart
|
||||||
logging: *default-logging
|
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:
|
environment:
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable
|
||||||
PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:-mail.localhost}
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
PROTOCOLS_CERT_PATH: /etc/lambdamail/certs/cert.pem
|
RSPAMD_URL: http://rspamd:11333
|
||||||
PROTOCOLS_KEY_PATH: /etc/lambdamail/certs/key.pem
|
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
|
PROTOCOLS_SPOOL_DIR: /var/lambdamail/spool
|
||||||
volumes:
|
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
|
- protocols_spool:/var/lambdamail/spool
|
||||||
depends_on:
|
depends_on:
|
||||||
db: { condition: service_healthy }
|
db: { condition: service_healthy }
|
||||||
|
redis: { condition: service_healthy }
|
||||||
migrate: { condition: service_completed_successfully }
|
migrate: { condition: service_completed_successfully }
|
||||||
|
rspamd: { condition: service_started }
|
||||||
|
clamav: { condition: service_started }
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health/ready || exit 1"]
|
test: ["CMD", "/app/lambdamail-protocols", "healthcheck"]
|
||||||
interval: 10s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 3
|
||||||
start_period: 15s
|
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:
|
web:
|
||||||
build: { context: ./services/webmail }
|
build: { context: ./services/webmail }
|
||||||
restart: unless-stopped
|
restart: *restart
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
ports: ["3000:3000"]
|
|
||||||
environment:
|
environment:
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
AUTH_SERVICE_URL: http://auth:3001
|
AUTH_SERVICE_URL: http://auth:3001
|
||||||
STORAGE_SERVICE_URL: http://storage:3002
|
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:
|
depends_on:
|
||||||
auth: { condition: service_healthy }
|
auth: { condition: service_healthy }
|
||||||
storage: { condition: service_healthy }
|
storage: { condition: service_healthy }
|
||||||
healthcheck:
|
labels:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health || exit 1"]
|
- traefik.enable=true
|
||||||
interval: 10s
|
# With the service on two networks Traefik may route via the wrong one
|
||||||
timeout: 5s
|
# and return 504. This pins it to the proxy network.
|
||||||
retries: 5
|
- traefik.docker.network=${COOLIFY_PROXY_NETWORK:-coolify}
|
||||||
start_period: 15s
|
# 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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
mail_storage:
|
||||||
|
rspamd_data:
|
||||||
|
clamav_data:
|
||||||
protocols_spool:
|
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.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/emersion/go-imap/v2"
|
"github.com/emersion/go-imap/v2"
|
||||||
"github.com/emersion/go-imap/v2/imapserver"
|
"github.com/emersion/go-imap/v2/imapserver"
|
||||||
gosmtp "github.com/emersion/go-smtp"
|
gosmtp "github.com/emersion/go-smtp"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
"lambdamail/protocols/internal/application/usecase"
|
"lambdamail/protocols/internal/application/usecase"
|
||||||
|
"lambdamail/protocols/internal/domain/valueobject"
|
||||||
|
"lambdamail/protocols/internal/infrastructure/arc"
|
||||||
"lambdamail/protocols/internal/infrastructure/clamav"
|
"lambdamail/protocols/internal/infrastructure/clamav"
|
||||||
|
"lambdamail/protocols/internal/infrastructure/cloudflare"
|
||||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||||
|
"lambdamail/protocols/internal/infrastructure/dkim"
|
||||||
|
"lambdamail/protocols/internal/infrastructure/mailauth"
|
||||||
"lambdamail/protocols/internal/infrastructure/netdns"
|
"lambdamail/protocols/internal/infrastructure/netdns"
|
||||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||||
"lambdamail/protocols/internal/infrastructure/rspamd"
|
"lambdamail/protocols/internal/infrastructure/rspamd"
|
||||||
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
|
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"
|
httppresentation "lambdamail/protocols/internal/presentation/http"
|
||||||
|
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
||||||
managesievepresentation "lambdamail/protocols/internal/presentation/managesieve"
|
managesievepresentation "lambdamail/protocols/internal/presentation/managesieve"
|
||||||
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
|
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
|
||||||
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("open database pool: %v", err)
|
log.Fatalf("open database pool: %v", err)
|
||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
spoolDir := envOrDefault("PROTOCOLS_SPOOL_DIR", "/var/lambdamail/spool")
|
if err := os.MkdirAll(cfg.SpoolDir, 0o755); err != nil {
|
||||||
if err := os.MkdirAll(spoolDir, 0o755); err != nil {
|
log.Fatalf("create spool dir %s: %v", cfg.SpoolDir, err)
|
||||||
log.Fatalf("create spool dir %s: %v", spoolDir, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
secretVault, vaultErr := vault.New(cfg.MasterKey)
|
||||||
|
certProvider := buildCertProvider(ctx, cfg, pool, secretVault, vaultErr)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- repos
|
||||||
mailboxes := postgres.NewMailboxRepository(pool)
|
mailboxes := postgres.NewMailboxRepository(pool)
|
||||||
blobs := diskstorage.NewLocalDiskBlobStorage(pool, spoolDir)
|
blobs := diskstorage.NewLocalDiskBlobStorage(pool, cfg.SpoolDir)
|
||||||
|
blobReader := diskstorage.NewLocalDiskBlobReader(pool)
|
||||||
messages := postgres.NewInboundMessageRepository(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")
|
// The vault is required for DKIM: without it the private keys could not
|
||||||
rspamdURL := os.Getenv("RSPAMD_URL")
|
// be stored, and unsigned outbound mail fails the DMARC alignment that
|
||||||
if clamavAddr != "" || rspamdURL != "" {
|
// PLAN.md section 5 depends on.
|
||||||
var scanners []port.ContentScanner
|
var dkimRepo *postgres.DkimRepository
|
||||||
if clamavAddr != "" {
|
if vaultErr != nil {
|
||||||
scanners = append(scanners, clamav.NewClamAVAdapter(clamavAddr))
|
log.Printf("WARNING: %v - DKIM signing is disabled, outbound mail will not be signed", vaultErr)
|
||||||
}
|
} else {
|
||||||
if rspamdURL != "" {
|
dkimRepo = postgres.NewDkimRepository(pool, secretVault)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
certProvider, err := tlsprovider.NewLocalFileCertProvider(
|
// ------------------------------------------------------------- inbound
|
||||||
os.Getenv("PROTOCOLS_CERT_PATH"),
|
inboundUC := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages)
|
||||||
os.Getenv("PROTOCOLS_KEY_PATH"),
|
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 {
|
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)
|
log.Fatalf("start self-managed ACME: %v", err)
|
||||||
certProvider, err = tlsprovider.NewEphemeralSelfSignedCertProvider()
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("generate fallback self-signed certificate: %v", err)
|
log.Fatalf("generate fallback self-signed certificate: %v", err)
|
||||||
}
|
}
|
||||||
|
return provider
|
||||||
}
|
}
|
||||||
|
|
||||||
backend := smtppresentation.NewBackend(useCase)
|
func relayConfig(cfg config) usecase.RelayConfig {
|
||||||
smtpServer := gosmtp.NewServer(backend)
|
return usecase.RelayConfig{
|
||||||
smtpServer.Addr = ":25"
|
Host: cfg.RelayHost,
|
||||||
smtpServer.Domain = envOrDefault("PRIMARY_MAIL_HOST", "mail.localhost")
|
Port: cfg.RelayPort,
|
||||||
smtpServer.MaxMessageBytes = 52428800 // 50 MB default; per-domain limit is a future iteration
|
Username: cfg.RelayUser,
|
||||||
smtpServer.MaxRecipients = 100
|
Password: cfg.RelayPass,
|
||||||
smtpServer.TLSConfig = &tls.Config{
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
GetCertificate: certProvider.GetCertificate,
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
CipherSuites: []uint16{
|
CipherSuites: []uint16{
|
||||||
@@ -93,99 +437,120 @@ func main() {
|
|||||||
},
|
},
|
||||||
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384},
|
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)
|
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
authRepo := postgres.NewAuthRepository(pool)
|
func runDeliveryWorker(ctx context.Context, worker *usecase.OutboundWorkerUseCase) {
|
||||||
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)
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
_, _ = outboundWorker.ProcessBatch(ctx, "protocols-worker-1", 10)
|
reconcile()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
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 {
|
// generateDkimKey adapts the key generator to the use case's signature,
|
||||||
if v := os.Getenv(key); v != "" {
|
// keeping the crypto in the infrastructure layer.
|
||||||
return v
|
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)
|
||||||
|
}
|
||||||
@@ -3,24 +3,33 @@ module lambdamail/protocols
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
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/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/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/miekg/dns v1.1.72
|
||||||
|
golang.org/x/crypto v0.54.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alexedwards/argon2id v1.0.0 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // 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/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/lib/pq v1.10.9 // indirect
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||||
golang.org/x/crypto v0.14.0 // indirect
|
golang.org/x/mod v0.37.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/sys v0.13.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/text v0.29.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
|
||||||
)
|
)
|
||||||
@@ -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 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
||||||
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
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.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.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
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/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 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
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 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
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 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
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 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
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 h1:ewv1e6bBlqOIYtgGgRcEnNDpfGlmfPxB8T3PO9tV68Q=
|
||||||
github.com/fergusstrange/embedded-postgres v1.30.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
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/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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
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.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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
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 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
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=
|
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-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.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.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.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.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-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-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.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.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.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-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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.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.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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
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.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
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-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.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.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.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=
|
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/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=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ import (
|
|||||||
"github.com/google/uuid"
|
"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 {
|
type MailboxAuth struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
PasswordHash string
|
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.
|
// AuthRepository resolves a login address to its stored credential.
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package port
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/domain/entity"
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
)
|
)
|
||||||
@@ -10,4 +13,9 @@ type OutboundRepository interface {
|
|||||||
Enqueue(ctx context.Context, job *entity.OutboundJob) error
|
Enqueue(ctx context.Context, job *entity.OutboundJob) error
|
||||||
FetchNextReady(ctx context.Context, workerID string, limit int) ([]*entity.OutboundJob, error)
|
FetchNextReady(ctx context.Context, workerID string, limit int) ([]*entity.OutboundJob, error)
|
||||||
UpdateJob(ctx context.Context, job *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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ func TestManageSieveSessionUseCase_Put_Get_List_SetActive_Delete(t *testing.T) {
|
|||||||
t.Fatalf("SetActive failed: %v", err)
|
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) {
|
if err := uc.DeleteScript(ctx, gotID, "main_rules"); !errors.Is(err, ErrActiveScriptDelete) {
|
||||||
t.Fatalf("expected ErrActiveScriptDelete, got %v", err)
|
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"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
|
"net/textproto"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,15 +19,37 @@ import (
|
|||||||
"lambdamail/protocols/internal/domain/valueobject"
|
"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 {
|
type OutboundWorkerUseCase struct {
|
||||||
outboundRepo port.OutboundRepository
|
outboundRepo port.OutboundRepository
|
||||||
mxResolver port.MXResolver
|
mxResolver port.MXResolver
|
||||||
blobReader port.BlobReader
|
blobReader port.BlobReader
|
||||||
inboundUC *ProcessInboundEmailUseCase
|
inboundUC *ProcessInboundEmailUseCase
|
||||||
mailboxes port.MailboxRepository
|
mailboxes port.MailboxRepository
|
||||||
|
policyResolver port.TLSPolicyResolver
|
||||||
|
relay RelayConfig
|
||||||
localHost string
|
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(
|
func NewOutboundWorkerUseCase(
|
||||||
outboundRepo port.OutboundRepository,
|
outboundRepo port.OutboundRepository,
|
||||||
mxResolver port.MXResolver,
|
mxResolver port.MXResolver,
|
||||||
@@ -72,6 +98,21 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit
|
|||||||
return
|
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)
|
mxHosts, err := w.mxResolver.LookupMX(ctx, job.DestinationDomain)
|
||||||
if err != nil || len(mxHosts) == 0 {
|
if err != nil || len(mxHosts) == 0 {
|
||||||
mxHosts = []string{job.DestinationDomain}
|
mxHosts = []string{job.DestinationDomain}
|
||||||
@@ -79,10 +120,28 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit
|
|||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
delivered := false
|
delivered := false
|
||||||
|
policyUsed := entity.TLSModeOpportunistic
|
||||||
|
|
||||||
for _, mxHost := range mxHosts {
|
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
|
delivered = true
|
||||||
|
policyUsed = policy.Effective()
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
@@ -92,18 +151,52 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit
|
|||||||
if delivered {
|
if delivered {
|
||||||
job.Status = entity.OutboundJobStatusDelivered
|
job.Status = entity.OutboundJobStatusDelivered
|
||||||
job.LastError = ""
|
job.LastError = ""
|
||||||
job.TlsPolicyUsed = "opportunistic"
|
job.LastSmtpCode = ""
|
||||||
|
job.TlsPolicyUsed = policyUsed
|
||||||
_ = w.outboundRepo.UpdateJob(ctx, job)
|
_ = w.outboundRepo.UpdateJob(ctx, job)
|
||||||
return
|
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"
|
errStr := "delivery failed"
|
||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
errStr = lastErr.Error()
|
errStr = lastErr.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
isPermanent := strings.HasPrefix(errStr, "5")
|
// The remote reply code decides the outcome (PLAN.md section 6.3): 5xx is
|
||||||
w.handleFailure(ctx, job, errStr, isPermanent)
|
// 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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.mailboxes != nil {
|
if w.mailboxes == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom)
|
targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom)
|
||||||
if err == nil && len(targets) > 0 {
|
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{
|
_ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{
|
||||||
Sender: "postmaster@lambdamail.local",
|
Sender: "postmaster@lambdamail.local",
|
||||||
Recipients: targets,
|
Recipients: targets,
|
||||||
RecipientAddresses: []string{job.EnvelopeFrom},
|
RecipientAddresses: addresses,
|
||||||
Body: bytes.NewReader(dsnBytes),
|
Body: bytes.NewReader(dsnBytes),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, from string, to string, payload []byte) error {
|
// 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
|
addr := mxHost
|
||||||
if _, _, err := net.SplitHostPort(mxHost); err != nil {
|
if _, _, err := net.SplitHostPort(mxHost); err != nil {
|
||||||
addr = net.JoinHostPort(mxHost, "25")
|
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)
|
return fmt.Errorf("EHLO failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
starttlsOffered, _ := client.Extension("STARTTLS")
|
||||||
config := &tls.Config{InsecureSkipVerify: true, ServerName: mxHost}
|
if starttlsOffered {
|
||||||
if err := client.StartTLS(config); err != nil {
|
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)
|
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 {
|
if err := client.Mail(from); err != nil {
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ func (f *fakeOutboundRepo) FetchNextReady(_ context.Context, _ string, _ int) ([
|
|||||||
return ready, nil
|
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 {
|
func (f *fakeOutboundRepo) UpdateJob(_ context.Context, job *entity.OutboundJob) error {
|
||||||
for i, j := range f.jobs {
|
for i, j := range f.jobs {
|
||||||
if j.ID == job.ID {
|
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"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
"lambdamail/protocols/internal/domain/valueobject"
|
"lambdamail/protocols/internal/domain/valueobject"
|
||||||
@@ -31,6 +35,11 @@ type ProcessInboundEmailInput struct {
|
|||||||
Recipients []port.MailboxRecord
|
Recipients []port.MailboxRecord
|
||||||
RecipientAddresses []string
|
RecipientAddresses []string
|
||||||
Body io.Reader
|
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
|
// ProcessInboundEmailUseCase implements the inbound half of PLAN.md section
|
||||||
@@ -39,6 +48,8 @@ type ProcessInboundEmailInput struct {
|
|||||||
// later sub-projects).
|
// later sub-projects).
|
||||||
type ProcessInboundEmailUseCase struct {
|
type ProcessInboundEmailUseCase struct {
|
||||||
scanner port.ContentScanner
|
scanner port.ContentScanner
|
||||||
|
authenticator port.MailAuthenticator
|
||||||
|
arcSealer port.ArcSealer
|
||||||
mailboxes port.MailboxRepository
|
mailboxes port.MailboxRepository
|
||||||
blobs port.BlobStorage
|
blobs port.BlobStorage
|
||||||
messages port.InboundMessageRepository
|
messages port.InboundMessageRepository
|
||||||
@@ -54,11 +65,49 @@ func (uc *ProcessInboundEmailUseCase) SetScanner(scanner port.ContentScanner) {
|
|||||||
uc.scanner = scanner
|
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) {
|
func (uc *ProcessInboundEmailUseCase) SetTrackerManager(tm *MailboxTrackerManager, folders port.ImapFolderRepository) {
|
||||||
uc.trackerManager = tm
|
uc.trackerManager = tm
|
||||||
uc.folders = folders
|
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
|
// ResolveRecipient is called from the SMTP session's RCPT TO handler. It
|
||||||
// returns every mailbox address should be delivered to (more than one for
|
// returns every mailbox address should be delivered to (more than one for
|
||||||
// an alias that fans out), or ErrRecipientNotFound / ErrMailboxQuotaExceeded.
|
// 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
|
// the same blob (PLAN.md section 9's dedup design: 1 email to 50
|
||||||
// recipients = 1 file).
|
// recipients = 1 file).
|
||||||
func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessInboundEmailInput) error {
|
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)
|
payload, err := io.ReadAll(input.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
targetFolder := "INBOX"
|
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 {
|
if uc.scanner != nil {
|
||||||
recipientAddr := ""
|
recipientAddr := ""
|
||||||
@@ -115,6 +220,7 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
|||||||
case valueobject.ScanVerdictSpamJunk:
|
case valueobject.ScanVerdictSpamJunk:
|
||||||
targetFolder = "Junk"
|
targetFolder = "Junk"
|
||||||
}
|
}
|
||||||
|
payload = prependHeaders(payload, scanRes.HeadersToAdd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +236,9 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
|||||||
SenderAddress: input.Sender,
|
SenderAddress: input.Sender,
|
||||||
RecipientAddress: input.RecipientAddresses[i],
|
RecipientAddress: input.RecipientAddresses[i],
|
||||||
TargetFolderName: targetFolder,
|
TargetFolderName: targetFolder,
|
||||||
SPFResult: "none",
|
SPFResult: authResult.SPF,
|
||||||
DKIMResult: "none",
|
DKIMResult: authResult.DKIM,
|
||||||
DMARCResult: "none",
|
DMARCResult: authResult.DMARC,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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,6 +9,20 @@ import (
|
|||||||
"lambdamail/protocols/internal/domain/entity"
|
"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 {
|
type SyncDnsRecordsInput struct {
|
||||||
DomainName string
|
DomainName string
|
||||||
MailHost string
|
MailHost string
|
||||||
@@ -16,21 +30,47 @@ type SyncDnsRecordsInput struct {
|
|||||||
ServerIPv6 string
|
ServerIPv6 string
|
||||||
RsaDkimPubKey string
|
RsaDkimPubKey string
|
||||||
EdDkimPubKey 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
|
TlsaHash string
|
||||||
|
TlsaHashes []string
|
||||||
DaneEnabled bool
|
DaneEnabled bool
|
||||||
|
RelaySpfInclude string
|
||||||
AdminEmailAddress 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 {
|
type SyncDnsRecordsOutput struct {
|
||||||
ZoneID string
|
ZoneID string
|
||||||
|
Status SyncStatus
|
||||||
CreatedCount int
|
CreatedCount int
|
||||||
UpdatedCount int
|
UpdatedCount int
|
||||||
UnchangedCount 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 {
|
type SyncDnsRecordsUseCase struct {
|
||||||
dnsProvider port.DnsProvider
|
dnsProvider port.DnsProvider
|
||||||
aliasRepo port.SystemAliasRepository
|
aliasRepo port.SystemAliasRepository
|
||||||
|
verifier port.DnsVerifier
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSyncDnsRecordsUseCase(dnsProvider port.DnsProvider, aliasRepo port.SystemAliasRepository) *SyncDnsRecordsUseCase {
|
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) {
|
func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecordsInput) (*SyncDnsRecordsOutput, error) {
|
||||||
if input.DomainName == "" {
|
if input.DomainName == "" {
|
||||||
return nil, fmt.Errorf("domain name is required")
|
return nil, fmt.Errorf("domain name is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
desiredSpecs := entity.Build13DnsRecordSpecs(
|
tlsaHashes := input.TlsaHashes
|
||||||
input.DomainName,
|
if len(tlsaHashes) == 0 && input.TlsaHash != "" {
|
||||||
input.MailHost,
|
tlsaHashes = []string{input.TlsaHash}
|
||||||
input.ServerIPv4,
|
}
|
||||||
input.ServerIPv6,
|
|
||||||
input.RsaDkimPubKey,
|
desiredSpecs := entity.BuildDnsRecordSpecs(entity.DnsRecordSpec{
|
||||||
input.EdDkimPubKey,
|
DomainName: input.DomainName,
|
||||||
input.TlsaHash,
|
MailHost: input.MailHost,
|
||||||
input.DaneEnabled,
|
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)
|
zoneID, err := uc.dnsProvider.GetZoneID(ctx, input.DomainName)
|
||||||
if err != nil {
|
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)
|
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 {
|
for _, rec := range existingRecords {
|
||||||
key := fmt.Sprintf("%s:%s", strings.ToUpper(rec.Type), strings.ToLower(rec.Name))
|
key := recordKey(rec.Type, rec.Name)
|
||||||
existingMap[key] = rec
|
existingByKey[key] = append(existingByKey[key], rec)
|
||||||
}
|
}
|
||||||
|
|
||||||
output := &SyncDnsRecordsOutput{ZoneID: zoneID}
|
output := &SyncDnsRecordsOutput{ZoneID: zoneID}
|
||||||
|
|
||||||
for _, spec := range desiredSpecs {
|
for _, spec := range desiredSpecs {
|
||||||
key := fmt.Sprintf("%s:%s", strings.ToUpper(spec.Type), strings.ToLower(spec.Name))
|
candidates := existingByKey[recordKey(spec.Type, spec.Name)]
|
||||||
existing, found := existingMap[key]
|
match, foreign := classifyCandidates(spec, candidates)
|
||||||
|
|
||||||
if !found {
|
switch {
|
||||||
if err := uc.dnsProvider.CreateRecord(ctx, zoneID, spec); err != nil {
|
case match != nil:
|
||||||
return nil, fmt.Errorf("create dns record %s %s: %w", spec.Type, spec.Name, err)
|
if spec.EqualsNormalized(*match) {
|
||||||
|
output.UnchangedCount++
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
output.CreatedCount++
|
spec.ID = match.ID
|
||||||
} else if !spec.EqualsNormalized(existing) {
|
|
||||||
spec.ID = existing.ID
|
|
||||||
if err := uc.dnsProvider.UpdateRecord(ctx, zoneID, spec); err != nil {
|
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++
|
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
|
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"
|
"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 {
|
type fakeDnsProvider struct {
|
||||||
records map[string]entity.DnsRecord // key -> record
|
records []entity.DnsRecord
|
||||||
|
nextID int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeDnsProvider() *fakeDnsProvider {
|
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) {
|
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) {
|
func (f *fakeDnsProvider) ListRecords(_ context.Context, _ string) ([]entity.DnsRecord, error) {
|
||||||
var out []entity.DnsRecord
|
return append([]entity.DnsRecord(nil), f.records...), nil
|
||||||
for _, r := range f.records {
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeDnsProvider) CreateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
|
func (f *fakeDnsProvider) CreateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
|
||||||
key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name))
|
f.nextID++
|
||||||
record.ID = fmt.Sprintf("id_%s", key)
|
record.ID = fmt.Sprintf("id_%d", f.nextID)
|
||||||
f.records[key] = record
|
f.records = append(f.records, record)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeDnsProvider) UpdateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
|
func (f *fakeDnsProvider) UpdateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
|
||||||
key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name))
|
for i, r := range f.records {
|
||||||
f.records[key] = record
|
if r.ID == record.ID {
|
||||||
|
f.records[i] = record
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("record %s not found", record.ID)
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeDnsProvider) DeleteRecord(_ context.Context, _ string, recordID string) error {
|
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 {
|
if r.ID == recordID {
|
||||||
delete(f.records, k)
|
f.records = append(f.records[:i], f.records[i+1:]...)
|
||||||
break
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -61,7 +84,11 @@ func (f *fakeSystemAliasRepository) EnsureSystemAliases(_ context.Context, _ str
|
|||||||
return nil
|
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()
|
dns := newFakeDnsProvider()
|
||||||
alias := &fakeSystemAliasRepository{}
|
alias := &fakeSystemAliasRepository{}
|
||||||
uc := NewSyncDnsRecordsUseCase(dns, alias)
|
uc := NewSyncDnsRecordsUseCase(dns, alias)
|
||||||
@@ -85,8 +112,8 @@ func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) {
|
|||||||
t.Fatalf("Execute failed: %v", err)
|
t.Fatalf("Execute failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if out.CreatedCount != 13 {
|
if out.CreatedCount != expectedRecordCount {
|
||||||
t.Errorf("CreatedCount = %d, want 13", out.CreatedCount)
|
t.Errorf("CreatedCount = %d, want %d", out.CreatedCount, expectedRecordCount)
|
||||||
}
|
}
|
||||||
if !alias.ensured {
|
if !alias.ensured {
|
||||||
t.Error("expected system aliases to be 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)
|
t.Fatalf("Execute 2 failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if out2.UnchangedCount != 13 || out2.CreatedCount != 0 || out2.UpdatedCount != 0 {
|
if out2.UnchangedCount != expectedRecordCount || out2.CreatedCount != 0 || out2.UpdatedCount != 0 {
|
||||||
t.Errorf("expected 13 unchanged, got created=%d, updated=%d, unchanged=%d", out2.CreatedCount, out2.UpdatedCount, out2.UnchangedCount)
|
t.Errorf("expected %d unchanged, got created=%d, updated=%d, unchanged=%d", expectedRecordCount, out2.CreatedCount, out2.UpdatedCount, out2.UnchangedCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package entity
|
package entity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type DnsRecord struct {
|
type DnsRecord struct {
|
||||||
@@ -17,8 +18,34 @@ type DnsRecord struct {
|
|||||||
Comment string
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build13DnsRecordSpecs generates the 13 mandatory DNS record specifications per PLAN.md section 7.
|
// DnsRecordSpec describes the zone the reconciler should converge on.
|
||||||
func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkimPubKey, edDkimPubKey, tlsaHash string, daneEnabled bool) []DnsRecord {
|
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 == "" {
|
if mailHost == "" {
|
||||||
mailHost = fmt.Sprintf("mail.%s", domainName)
|
mailHost = fmt.Sprintf("mail.%s", domainName)
|
||||||
}
|
}
|
||||||
@@ -46,7 +73,7 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim
|
|||||||
{
|
{
|
||||||
Type: "TXT",
|
Type: "TXT",
|
||||||
Name: domainName,
|
Name: domainName,
|
||||||
Value: fmt.Sprintf("v=spf1 mx a:%s -all", mailHost),
|
Value: buildSpfRecord(mailHost, spec.RelaySpfInclude),
|
||||||
TTL: 1,
|
TTL: 1,
|
||||||
Comment: "LambdaMail SPF Record",
|
Comment: "LambdaMail SPF Record",
|
||||||
},
|
},
|
||||||
@@ -78,7 +105,7 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim
|
|||||||
{
|
{
|
||||||
Type: "TXT",
|
Type: "TXT",
|
||||||
Name: fmt.Sprintf("_mta-sts.%s", domainName),
|
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,
|
TTL: 1,
|
||||||
Comment: "LambdaMail MTA-STS Policy Version",
|
Comment: "LambdaMail MTA-STS Policy Version",
|
||||||
},
|
},
|
||||||
@@ -117,6 +144,33 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim
|
|||||||
Priority: intPtr(0),
|
Priority: intPtr(0),
|
||||||
Comment: "LambdaMail Submissions Autoconfig",
|
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
|
// 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
|
// 11. Conditional TLSA records if DANE is enabled. More than one is normal
|
||||||
if daneEnabled && tlsaHash != "" {
|
// 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{
|
records = append(records, DnsRecord{
|
||||||
Type: "TLSA",
|
Type: "TLSA",
|
||||||
Name: fmt.Sprintf("_25._tcp.%s", mailHost),
|
Name: fmt.Sprintf("_25._tcp.%s", mailHost),
|
||||||
Value: fmt.Sprintf("3 1 1 %s", tlsaHash),
|
Value: fmt.Sprintf("3 1 1 %s", hash),
|
||||||
TTL: 1,
|
TTL: 1,
|
||||||
Comment: "LambdaMail DANE TLSA Record",
|
Comment: "LambdaMail DANE TLSA Record",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return records
|
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 {
|
func intPtr(v int) *int {
|
||||||
return &v
|
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.
|
// EqualsNormalized compares two DNS records ignoring subtle formatting whitespace/case differences.
|
||||||
func (r DnsRecord) EqualsNormalized(other DnsRecord) bool {
|
func (r DnsRecord) EqualsNormalized(other DnsRecord) bool {
|
||||||
if r.Type != other.Type {
|
if r.Type != other.Type {
|
||||||
@@ -163,5 +246,13 @@ func (r DnsRecord) EqualsNormalized(other DnsRecord) bool {
|
|||||||
if r.Proxied != other.Proxied {
|
if r.Proxied != other.Proxied {
|
||||||
return false
|
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
|
return true
|
||||||
}
|
}
|
||||||
@@ -4,21 +4,43 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuild13DnsRecordSpecs_GeneratesMandatoryRecords(t *testing.T) {
|
func TestBuildDnsRecordSpecs_GeneratesMandatoryRecords(t *testing.T) {
|
||||||
specs := Build13DnsRecordSpecs(
|
specs := BuildDnsRecordSpecs(DnsRecordSpec{
|
||||||
"example.test",
|
DomainName: "example.test",
|
||||||
"mail.example.test",
|
MailHost: "mail.example.test",
|
||||||
"192.0.2.1",
|
ServerIPv4: "192.0.2.1",
|
||||||
"2001:db8::1",
|
ServerIPv6: "2001:db8::1",
|
||||||
"rsaPubKeyBase64",
|
RsaDkimPubKey: "rsaPubKeyBase64",
|
||||||
"edPubKeyBase64",
|
EdDkimPubKey: "edPubKeyBase64",
|
||||||
"hash123",
|
TlsaHashes: []string{"hash123"},
|
||||||
true,
|
DaneEnabled: true,
|
||||||
)
|
})
|
||||||
|
|
||||||
// Should contain 13 records (11 mandatory + 1 AAAA + 1 TLSA)
|
// PLAN.md section 7.1 numbers 13 records (11 always-on, plus AAAA when
|
||||||
if len(specs) != 13 {
|
// IPv6 is configured and TLSA when DANE is on). Section 7.2 adds three
|
||||||
t.Fatalf("expected 13 records, got %d", len(specs))
|
// 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
|
// Verify MX
|
||||||
|
|||||||
@@ -36,15 +36,23 @@ type OutboundJob struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateNextBackoff computes the next retry time and DSN requirements based on attempt count.
|
// CalculateNextBackoff computes the next retry time and DSN requirements from
|
||||||
func CalculateNextBackoff(now time.Time, attempt int, expiresAt time.Time) (nextAttemptAt time.Time, sendDelayDsn bool, permanentFailure bool) {
|
// 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)) {
|
if !expiresAt.IsZero() && (now.After(expiresAt) || now.Equal(expiresAt)) {
|
||||||
return now, false, true
|
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
|
var baseDelay time.Duration
|
||||||
|
|
||||||
switch attempt {
|
switch nextAttempt {
|
||||||
case 1:
|
case 1:
|
||||||
baseDelay = 0
|
baseDelay = 0
|
||||||
case 2:
|
case 2:
|
||||||
@@ -64,8 +72,8 @@ func CalculateNextBackoff(now time.Time, attempt int, expiresAt time.Time) (next
|
|||||||
baseDelay = 12 * time.Hour
|
baseDelay = 12 * time.Hour
|
||||||
}
|
}
|
||||||
|
|
||||||
if baseDelay > 0 && attempt >= 5 {
|
if baseDelay > 0 && nextAttempt >= 5 {
|
||||||
// Apply ±20% jitter
|
// Apply +/-20% jitter
|
||||||
jitterFactor := 0.8 + rand.Float64()*0.4
|
jitterFactor := 0.8 + rand.Float64()*0.4
|
||||||
baseDelay = time.Duration(float64(baseDelay) * jitterFactor)
|
baseDelay = time.Duration(float64(baseDelay) * jitterFactor)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,50 @@ import (
|
|||||||
"time"
|
"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()
|
now := time.Now()
|
||||||
expiresAt := now.Add(5 * 24 * time.Hour)
|
expiresAt := now.Add(5 * 24 * time.Hour)
|
||||||
|
|
||||||
// Attempt 2 -> +5 min
|
cases := []struct {
|
||||||
next, delayDsn, perm := CalculateNextBackoff(now, 2, expiresAt)
|
failedAttempt int
|
||||||
if perm || delayDsn || next.Sub(now) < 4*time.Minute {
|
wantMin time.Duration
|
||||||
t.Errorf("attempt 2 backoff mismatch: next=%v, delayDsn=%v, perm=%v", next, delayDsn, perm)
|
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
|
for _, tc := range cases {
|
||||||
next, delayDsn, perm = CalculateNextBackoff(now, 4, expiresAt)
|
next, delayDsn, perm := CalculateNextBackoff(now, tc.failedAttempt, expiresAt)
|
||||||
if perm || !delayDsn || next.Sub(now) < 55*time.Minute {
|
if perm {
|
||||||
t.Errorf("attempt 4 backoff mismatch: next=%v, delayDsn=%v, perm=%v", next, delayDsn, 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
|
func TestCalculateNextBackoff_ExpiredJobIsPermanent(t *testing.T) {
|
||||||
expiredTime := now.Add(-1 * time.Hour)
|
now := time.Now()
|
||||||
_, _, perm = CalculateNextBackoff(now, 5, expiredTime)
|
if _, _, perm := CalculateNextBackoff(now, 5, now.Add(-time.Hour)); !perm {
|
||||||
if !perm {
|
t.Error("expected permanent failure for an expired job")
|
||||||
t.Errorf("expected permanent failure for expired job")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,155 @@
|
|||||||
package entity
|
package entity
|
||||||
|
|
||||||
// TLSPolicy is the effective transport security policy for an outbound destination
|
import (
|
||||||
// (PLAN.md section 3: DANE has precedence over MTA-STS, RFC 8461 section 2).
|
"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 {
|
type TLSPolicy struct {
|
||||||
daneAvailable bool
|
daneAvailable bool
|
||||||
mtaSTSAvailable 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 {
|
func NewTLSPolicy(daneAvailable, mtaSTSAvailable bool) TLSPolicy {
|
||||||
return TLSPolicy{daneAvailable: daneAvailable, mtaSTSAvailable: mtaSTSAvailable}
|
return TLSPolicy{daneAvailable: daneAvailable, mtaSTSAvailable: mtaSTSAvailable}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effective returns "dane", "mta-sts" or "opportunistic" following the precedence
|
// NewDanePolicy builds a policy backed by TLSA records. DANE is always
|
||||||
// rule in PLAN.md section 5 / RFC 8461 section 2.
|
// 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 {
|
func (p TLSPolicy) Effective() string {
|
||||||
switch {
|
switch {
|
||||||
case p.daneAvailable:
|
case p.daneAvailable:
|
||||||
return "dane"
|
return TLSModeDane
|
||||||
case p.mtaSTSAvailable:
|
case p.mtaSTSAvailable:
|
||||||
return "mta-sts"
|
return TLSModeMtaSts
|
||||||
default:
|
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 ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,14 +6,12 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
|
||||||
gosmtp "github.com/emersion/go-smtp"
|
gosmtp "github.com/emersion/go-smtp"
|
||||||
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
@@ -47,22 +45,7 @@ func TestAntispamAntivirusEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
root := repoRoot(t)
|
applyMigrations(t, ctx, pool)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed domain, mailbox, INBOX and Junk folders
|
// Seed domain, mailbox, INBOX and Junk folders
|
||||||
domainID := uuid.New()
|
domainID := uuid.New()
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ package e2e
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
"lambdamail/protocols/internal/domain/entity"
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
@@ -38,21 +36,7 @@ func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
root := repoRoot(t)
|
applyMigrations(t, ctx, pool)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed domain & admin mailbox
|
// Seed domain & admin mailbox
|
||||||
domainID := uuid.New()
|
domainID := uuid.New()
|
||||||
@@ -86,8 +70,11 @@ func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) {
|
|||||||
t.Fatalf("SyncDnsRecords execute: %v", err)
|
t.Fatalf("SyncDnsRecords execute: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if out.CreatedCount != 13 {
|
// The 13 numbered records of PLAN.md section 7.1 plus the three
|
||||||
t.Errorf("CreatedCount = %d, want 13", out.CreatedCount)
|
// 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@)
|
// Verify 4 system aliases in PostgreSQL (postmaster@, abuse@, dmarc@, tlsrpt@)
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -52,21 +50,7 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
root := repoRoot(t)
|
applyMigrations(t, ctx, pool)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Seed domain, mailbox, INBOX, Archive
|
// 2. Seed domain, mailbox, INBOX, Archive
|
||||||
password := "correct horse battery staple"
|
password := "correct horse battery staple"
|
||||||
@@ -443,7 +427,7 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) {
|
|||||||
t.Fatalf("SETACTIVE = %q", activeResp)
|
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\"")
|
sendCmdSieve("DELETESCRIPT \"auto_rules\"")
|
||||||
if delActiveResp := readLineSieve(); !strings.HasPrefix(delActiveResp, "NO") {
|
if delActiveResp := readLineSieve(); !strings.HasPrefix(delActiveResp, "NO") {
|
||||||
t.Fatalf("expected NO for deleting active script, got %q", delActiveResp)
|
t.Fatalf("expected NO for deleting active script, got %q", delActiveResp)
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,12 +17,14 @@ import (
|
|||||||
|
|
||||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
"lambdamail/protocols/internal/domain/entity"
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
|
"lambdamail/protocols/internal/domain/valueobject"
|
||||||
"lambdamail/protocols/internal/infrastructure/clamav"
|
"lambdamail/protocols/internal/infrastructure/clamav"
|
||||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||||
"lambdamail/protocols/internal/infrastructure/rspamd"
|
"lambdamail/protocols/internal/infrastructure/rspamd"
|
||||||
httppresentation "lambdamail/protocols/internal/presentation/http"
|
httppresentation "lambdamail/protocols/internal/presentation/http"
|
||||||
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
||||||
|
|
||||||
gosmtp "github.com/emersion/go-smtp"
|
gosmtp "github.com/emersion/go-smtp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,22 +96,7 @@ func TestPhase3CompleteEndToEnd(t *testing.T) {
|
|||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
// Apply Database Migrations
|
// Apply Database Migrations
|
||||||
root := repoRoot(t)
|
applyMigrations(t, ctx, pool)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// 1. Sub-project 1: Cloudflare DNS Automation Engine & System Aliases
|
// 1. Sub-project 1: Cloudflare DNS Automation Engine & System Aliases
|
||||||
@@ -156,8 +141,14 @@ func TestPhase3CompleteEndToEnd(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("sync dns records: %v", err)
|
t.Fatalf("sync dns records: %v", err)
|
||||||
}
|
}
|
||||||
if dnsOut.CreatedCount != 13 {
|
// The 13 numbered records of PLAN.md section 7.1 plus the three
|
||||||
t.Fatalf("expected 13 created DNS records, got %d", dnsOut.CreatedCount)
|
// 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
|
// Verify system aliases created
|
||||||
@@ -174,14 +165,26 @@ func TestPhase3CompleteEndToEnd(t *testing.T) {
|
|||||||
reportUC := appusecase.NewIngestReportsUseCase(reportRepo)
|
reportUC := appusecase.NewIngestReportsUseCase(reportRepo)
|
||||||
httpRouter := httppresentation.NewRouter(reportUC, func() error { return pool.Ping(ctx) })
|
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 := httptest.NewRequest("GET", "/.well-known/mta-sts.txt", nil)
|
||||||
reqSts.Host = "mta-sts.f3e2e.test"
|
reqSts.Host = "mta-sts.f3e2e.test"
|
||||||
recSts := httptest.NewRecorder()
|
recSts := httptest.NewRecorder()
|
||||||
httpRouter.ServeHTTP(recSts, reqSts)
|
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())
|
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
|
// Test Autoconfig endpoint
|
||||||
reqAuto := httptest.NewRequest("GET", "/.well-known/autoconfig/mail/config-v1.1.xml", nil)
|
reqAuto := httptest.NewRequest("GET", "/.well-known/autoconfig/mail/config-v1.1.xml", nil)
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -42,22 +40,7 @@ func TestHttpServicesEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
root := repoRoot(t)
|
applyMigrations(t, ctx, pool)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reportRepo := postgres.NewReportRepository(pool)
|
reportRepo := postgres.NewReportRepository(pool)
|
||||||
reportUseCase := appusecase.NewIngestReportsUseCase(reportRepo)
|
reportUseCase := appusecase.NewIngestReportsUseCase(reportRepo)
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/alexedwards/argon2id"
|
"github.com/alexedwards/argon2id"
|
||||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
|
||||||
"github.com/emersion/go-imap/v2"
|
"github.com/emersion/go-imap/v2"
|
||||||
"github.com/emersion/go-imap/v2/imapclient"
|
"github.com/emersion/go-imap/v2/imapclient"
|
||||||
"github.com/emersion/go-imap/v2/imapserver"
|
"github.com/emersion/go-imap/v2/imapserver"
|
||||||
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||||
|
|
||||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||||
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/alexedwards/argon2id"
|
"github.com/alexedwards/argon2id"
|
||||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
|
||||||
"github.com/emersion/go-imap/v2"
|
"github.com/emersion/go-imap/v2"
|
||||||
"github.com/emersion/go-imap/v2/imapclient"
|
"github.com/emersion/go-imap/v2/imapclient"
|
||||||
"github.com/emersion/go-imap/v2/imapserver"
|
"github.com/emersion/go-imap/v2/imapserver"
|
||||||
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||||
|
|
||||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||||
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
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"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -50,22 +48,7 @@ func TestOutboundQueueEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
root := repoRoot(t)
|
applyMigrations(t, ctx, pool)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed domain, sender mailbox, INBOX
|
// Seed domain, sender mailbox, INBOX
|
||||||
domainID := uuid.New()
|
domainID := uuid.New()
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"github.com/alexedwards/argon2id"
|
"github.com/alexedwards/argon2id"
|
||||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||||
|
|
||||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||||
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
|
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
|
||||||
gosmtp "github.com/emersion/go-smtp"
|
gosmtp "github.com/emersion/go-smtp"
|
||||||
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/usecase"
|
"lambdamail/protocols/internal/application/usecase"
|
||||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
"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,13 +5,25 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
"lambdamail/protocols/internal/domain/entity"
|
"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 {
|
type CloudflareAdapter struct {
|
||||||
apiToken string
|
apiToken string
|
||||||
baseURL string
|
baseURL string
|
||||||
@@ -22,223 +34,363 @@ func NewCloudflareAdapter(apiToken string) *CloudflareAdapter {
|
|||||||
return &CloudflareAdapter{
|
return &CloudflareAdapter{
|
||||||
apiToken: apiToken,
|
apiToken: apiToken,
|
||||||
baseURL: "https://api.cloudflare.com/client/v4",
|
baseURL: "https://api.cloudflare.com/client/v4",
|
||||||
httpClient: &http.Client{},
|
// 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 {
|
type cfZoneResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Result []struct {
|
Result []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"result"`
|
} `json:"result"`
|
||||||
Errors []struct {
|
Errors []cfError `json:"errors"`
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"errors"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type cfRecord struct {
|
type cfRecord struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content,omitempty"`
|
||||||
TTL int `json:"ttl"`
|
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"`
|
Priority *int `json:"priority,omitempty"`
|
||||||
Proxied bool `json:"proxied,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"`
|
Comment string `json:"comment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type cfRecordResponse struct {
|
type cfRecordResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Result []cfRecord `json:"result"`
|
Result []cfRecord `json:"result"`
|
||||||
Errors []struct {
|
ResultInfo cfResultInfo `json:"result_info"`
|
||||||
Message string `json:"message"`
|
Errors []cfError `json:"errors"`
|
||||||
} `json:"errors"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type cfSingleRecordResponse struct {
|
type cfSingleRecordResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Result cfRecord `json:"result"`
|
Result cfRecord `json:"result"`
|
||||||
Errors []struct {
|
Errors []cfError `json:"errors"`
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"errors"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *CloudflareAdapter) GetZoneID(ctx context.Context, domainName string) (string, error) {
|
func (a *CloudflareAdapter) GetZoneID(ctx context.Context, domainName string) (string, error) {
|
||||||
reqURL := fmt.Sprintf("%s/zones?name=%s", a.baseURL, url.QueryEscape(domainName))
|
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
|
var data cfZoneResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
if err := a.doJSON(ctx, http.MethodGet, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
|
||||||
return "", fmt.Errorf("decode json: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !data.Success || len(data.Result) == 0 {
|
if !data.Success || len(data.Result) == 0 {
|
||||||
errMsg := "zone not found"
|
return "", fmt.Errorf("cloudflare: zone %q not found or token lacks Zone:Read on it", domainName)
|
||||||
if len(data.Errors) > 0 {
|
|
||||||
errMsg = data.Errors[0].Message
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("cloudflare API error: %s", errMsg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.Result[0].ID, nil
|
return data.Result[0].ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *CloudflareAdapter) ListRecords(ctx context.Context, zoneID string) ([]entity.DnsRecord, error) {
|
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)
|
var out []entity.DnsRecord
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
a.setHeaders(req)
|
|
||||||
|
|
||||||
resp, err := a.httpClient.Do(req)
|
for page := 1; ; page++ {
|
||||||
if err != nil {
|
reqURL := fmt.Sprintf("%s/zones/%s/dns_records?per_page=%d&page=%d", a.baseURL, zoneID, recordsPerPage, page)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var data cfRecordResponse
|
var data cfRecordResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
if err := a.doJSON(ctx, http.MethodGet, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
|
||||||
return nil, fmt.Errorf("decode json: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !data.Success {
|
if !data.Success {
|
||||||
return nil, fmt.Errorf("cloudflare API error listing records")
|
return nil, fmt.Errorf("cloudflare: listing records for zone %s failed", zoneID)
|
||||||
}
|
}
|
||||||
|
|
||||||
var out []entity.DnsRecord
|
|
||||||
for _, r := range data.Result {
|
for _, r := range data.Result {
|
||||||
out = append(out, entity.DnsRecord{
|
rec := entity.DnsRecord{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
Type: r.Type,
|
Type: r.Type,
|
||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
Value: r.Content,
|
Value: r.Content,
|
||||||
TTL: r.TTL,
|
TTL: r.TTL,
|
||||||
Priority: r.Priority,
|
Priority: r.Priority,
|
||||||
Proxied: r.Proxied,
|
|
||||||
Comment: r.Comment,
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *CloudflareAdapter) CreateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
|
func (a *CloudflareAdapter) CreateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
|
||||||
reqURL := fmt.Sprintf("%s/zones/%s/dns_records", a.baseURL, zoneID)
|
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
|
var data cfSingleRecordResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
if err := a.doJSON(ctx, http.MethodPost, reqURL, toCfRecord(record), &data, func() []cfError { return data.Errors }); err != nil {
|
||||||
return err
|
return fmt.Errorf("create %s %s: %w", record.Type, record.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !data.Success {
|
if !data.Success {
|
||||||
errMsg := "create record failed"
|
return fmt.Errorf("cloudflare: create %s %s failed", record.Type, record.Name)
|
||||||
if len(data.Errors) > 0 {
|
|
||||||
errMsg = data.Errors[0].Message
|
|
||||||
}
|
}
|
||||||
return fmt.Errorf("cloudflare API error: %s", errMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *CloudflareAdapter) UpdateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
|
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)
|
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
|
var data cfSingleRecordResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
if err := a.doJSON(ctx, http.MethodPut, reqURL, toCfRecord(record), &data, func() []cfError { return data.Errors }); err != nil {
|
||||||
return err
|
return fmt.Errorf("update %s %s: %w", record.Type, record.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !data.Success {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *CloudflareAdapter) DeleteRecord(ctx context.Context, zoneID string, recordID string) error {
|
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)
|
reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, recordID)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil)
|
|
||||||
|
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],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
a.setHeaders(req)
|
req.Header.Set("Authorization", "Bearer "+a.apiToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := a.httpClient.Do(req)
|
resp, err := a.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
return nil
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *CloudflareAdapter) setHeaders(req *http.Request) {
|
respBody, readErr := io.ReadAll(resp.Body)
|
||||||
req.Header.Set("Authorization", "Bearer "+a.apiToken)
|
resp.Body.Close()
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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
|
// 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) {
|
func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*port.MailboxAuth, error) {
|
||||||
row := r.pool.QueryRow(ctx, `
|
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
|
FROM mailboxes m
|
||||||
JOIN domains d ON d.id = m.domain_id
|
JOIN domains d ON d.id = m.domain_id
|
||||||
WHERE m.email_address = $1
|
WHERE m.email_address = $1
|
||||||
@@ -31,7 +31,7 @@ func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*po
|
|||||||
|
|
||||||
var rec port.MailboxAuth
|
var rec port.MailboxAuth
|
||||||
var id uuid.UUID
|
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 {
|
if err == pgx.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package postgres
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
WITH ready_jobs AS (
|
WITH ready_jobs AS (
|
||||||
SELECT id FROM outbound_jobs
|
SELECT id FROM outbound_jobs
|
||||||
WHERE status IN ('QUEUED', 'DEFERRED')
|
WHERE (
|
||||||
AND next_attempt_at <= NOW()
|
(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
|
ORDER BY next_attempt_at ASC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
@@ -90,6 +98,22 @@ func (r *OutboundRepository) FetchNextReady(ctx context.Context, workerID string
|
|||||||
return jobs, nil
|
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 {
|
func (r *OutboundRepository) UpdateJob(ctx context.Context, job *entity.OutboundJob) error {
|
||||||
_, err := r.pool.Exec(ctx, `
|
_, err := r.pool.Exec(ctx, `
|
||||||
UPDATE outbound_jobs
|
UPDATE outbound_jobs
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"lambdamail/protocols/internal/application/port"
|
||||||
"lambdamail/protocols/internal/domain/valueobject"
|
"lambdamail/protocols/internal/domain/valueobject"
|
||||||
@@ -30,7 +31,10 @@ func NewRspamdAdapter(baseURL string) *RspamdAdapter {
|
|||||||
}
|
}
|
||||||
return &RspamdAdapter{
|
return &RspamdAdapter{
|
||||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||||
httpClient: &http.Client{},
|
// 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 {
|
switch parsed.Action {
|
||||||
case "reject":
|
case "reject":
|
||||||
res.Verdict = valueobject.ScanVerdictSpamReject
|
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
|
res.Verdict = valueobject.ScanVerdictGreylist
|
||||||
case "add header", "rewrite subject":
|
case "add header", "rewrite subject":
|
||||||
res.Verdict = valueobject.ScanVerdictSpamJunk
|
res.Verdict = valueobject.ScanVerdictSpamJunk
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ func TestRspamdAdapter_Scan_MapsActions(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"no action", valueobject.ScanVerdictClean},
|
{"no action", valueobject.ScanVerdictClean},
|
||||||
{"add header", valueobject.ScanVerdictSpamJunk},
|
{"add header", valueobject.ScanVerdictSpamJunk},
|
||||||
|
{"rewrite subject", valueobject.ScanVerdictSpamJunk},
|
||||||
{"greylist", valueobject.ScanVerdictGreylist},
|
{"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},
|
{"reject", valueobject.ScanVerdictSpamReject},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package httppresentation
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,10 +12,16 @@ import (
|
|||||||
"lambdamail/protocols/internal/domain/valueobject"
|
"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 {
|
type Router struct {
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
reportUseCase *appusecase.IngestReportsUseCase
|
reportUseCase *appusecase.IngestReportsUseCase
|
||||||
pingFunc func() error
|
pingFunc func() error
|
||||||
|
mtaStsMode valueobject.MtaStsMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() error) *Router {
|
func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() error) *Router {
|
||||||
@@ -22,11 +29,24 @@ func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() e
|
|||||||
mux: http.NewServeMux(),
|
mux: http.NewServeMux(),
|
||||||
reportUseCase: reportUseCase,
|
reportUseCase: reportUseCase,
|
||||||
pingFunc: pingFunc,
|
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()
|
r.registerRoutes()
|
||||||
return r
|
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) {
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
r.mux.ServeHTTP(w, req)
|
r.mux.ServeHTTP(w, req)
|
||||||
}
|
}
|
||||||
@@ -57,19 +77,23 @@ func (r *Router) handleMtaSts(w http.ResponseWriter, req *http.Request) {
|
|||||||
host := extractHost(req.Host)
|
host := extractHost(req.Host)
|
||||||
domainName := strings.TrimPrefix(host, "mta-sts.")
|
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("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.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte(policy.Format()))
|
w.Write([]byte(policy.Format()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) handleSecurityTxt(w http.ResponseWriter, req *http.Request) {
|
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)
|
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.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -144,12 +168,27 @@ func (r *Router) handleTlsRptIngest(w http.ResponseWriter, req *http.Request) {
|
|||||||
w.Write([]byte(`{"status":"ok"}`))
|
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 {
|
func extractHost(hostHeader string) string {
|
||||||
if h, _, err := strings.Cut(hostHeader, ":"); err && h != "" {
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
if hostHeader == "" {
|
if hostHeader == "" {
|
||||||
return "example.test"
|
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) {
|
func (s *session) Append(_ string, _ imap.LiteralReader, _ *imap.AppendOptions) (*imap.AppendData, error) {
|
||||||
return nil, errNotYetImplemented
|
return nil, errNotYetImplemented
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
gosmtp "github.com/emersion/go-smtp"
|
gosmtp "github.com/emersion/go-smtp"
|
||||||
@@ -24,19 +25,44 @@ func NewBackend(uc *usecase.ProcessInboundEmailUseCase) *Backend {
|
|||||||
return &Backend{useCase: uc}
|
return &Backend{useCase: uc}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Backend) NewSession(_ *gosmtp.Conn) (gosmtp.Session, error) {
|
func (b *Backend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) {
|
||||||
return &session{useCase: b.useCase}, nil
|
return &session{useCase: b.useCase, conn: c}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// session holds one SMTP transaction's accumulated state (RFC 5321 section
|
// session holds one SMTP transaction's accumulated state (RFC 5321 section
|
||||||
// 4.1.1: MAIL, then one or more RCPT, then DATA, then reset).
|
// 4.1.1: MAIL, then one or more RCPT, then DATA, then reset).
|
||||||
type session struct {
|
type session struct {
|
||||||
useCase *usecase.ProcessInboundEmailUseCase
|
useCase *usecase.ProcessInboundEmailUseCase
|
||||||
|
conn *gosmtp.Conn
|
||||||
from string
|
from string
|
||||||
recipients []port.MailboxRecord
|
recipients []port.MailboxRecord
|
||||||
recipientAddresses []string
|
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() {
|
func (s *session) Reset() {
|
||||||
s.from = ""
|
s.from = ""
|
||||||
s.recipients = nil
|
s.recipients = nil
|
||||||
@@ -107,6 +133,8 @@ func (s *session) Data(r io.Reader) error {
|
|||||||
Recipients: s.recipients,
|
Recipients: s.recipients,
|
||||||
RecipientAddresses: s.recipientAddresses,
|
RecipientAddresses: s.recipientAddresses,
|
||||||
Body: r,
|
Body: r,
|
||||||
|
ClientIP: s.clientIP(),
|
||||||
|
HeloDomain: s.heloDomain(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var smtpErr *gosmtp.SMTPError
|
var smtpErr *gosmtp.SMTPError
|
||||||
@@ -114,6 +142,13 @@ func (s *session) Data(r io.Reader) error {
|
|||||||
return smtpErr
|
return smtpErr
|
||||||
}
|
}
|
||||||
errMsg := err.Error()
|
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 ") {
|
if strings.HasPrefix(errMsg, "554 ") {
|
||||||
return &gosmtp.SMTPError{
|
return &gosmtp.SMTPError{
|
||||||
Code: 554,
|
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
|
||||||
Reference in new issue
Block a user