From d56f29d084cc003a47f04f8ec82e01d46670b243 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 1 Aug 2026 22:49:02 -0300 Subject: [PATCH] #18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics --- .env.example | 50 +- config/rspamd/actions.conf | 7 + config/rspamd/logging.inc | 2 + config/rspamd/options.inc | 5 + config/rspamd/worker-normal.inc | 3 + docker-compose.yaml | 246 +++++-- .../cmd/lambdamail-protocols/acme_mode.go | 135 ++++ .../cmd/lambdamail-protocols/config.go | 137 ++++ .../cmd/lambdamail-protocols/disk_other.go | 12 + .../cmd/lambdamail-protocols/disk_unix.go | 22 + .../cmd/lambdamail-protocols/main.go | 611 ++++++++++++++---- .../cmd/lambdamail-protocols/preflight.go | 112 ++++ services/protocols/go.mod | 27 +- services/protocols/go.sum | 43 +- .../application/port/auth_repository.go | 7 +- .../port/imap_folder_repository.go | 10 +- .../application/port/outbound_repository.go | 8 + .../application/usecase/inbound_auth_test.go | 140 ++++ .../usecase/inbound_scan_headers_test.go | 99 +++ .../usecase/managesieve_session.go | 6 +- .../usecase/managesieve_session_test.go | 2 +- .../application/usecase/outbound_dsn_test.go | 168 +++++ .../usecase/outbound_tls_policy_test.go | 190 ++++++ .../application/usecase/outbound_worker.go | 217 ++++++- .../usecase/outbound_worker_test.go | 4 + .../internal/application/usecase/preflight.go | 404 ++++++++++++ .../application/usecase/preflight_test.go | 266 ++++++++ .../usecase/process_inbound_email.go | 112 +++- .../usecase/sync_dns_conflict_test.go | 179 +++++ .../application/usecase/sync_dns_records.go | 230 ++++++- .../usecase/sync_dns_records_test.go | 69 +- .../internal/domain/entity/dmarc_report.go | 16 +- .../internal/domain/entity/dns_record.go | 119 +++- .../internal/domain/entity/dns_record_test.go | 50 +- .../internal/domain/entity/mailbox.go | 2 +- .../internal/domain/entity/outbound_job.go | 18 +- .../domain/entity/outbound_job_test.go | 52 +- .../internal/domain/entity/sieve_validator.go | 8 +- .../domain/entity/sieve_validator_test.go | 4 +- .../internal/domain/entity/tls_policy.go | 144 ++++- .../protocols/internal/domain/event/events.go | 30 +- .../domain/valueobject/domain_name.go | 6 +- .../domain/valueobject/email_address.go | 6 +- .../internal/domain/valueobject/message_id.go | 2 +- .../domain/valueobject/spam_verdict_test.go | 2 +- .../e2e/antispam_antivirus_e2e_test.go | 21 +- .../internal/e2e/dns_sync_e2e_test.go | 27 +- .../internal/e2e/f2_complete_e2e_test.go | 20 +- .../internal/e2e/f3_complete_e2e_test.go | 47 +- .../internal/e2e/http_services_e2e_test.go | 19 +- .../internal/e2e/imap_core_e2e_test.go | 4 +- .../e2e/imap_idle_condstore_e2e_test.go | 4 +- .../protocols/internal/e2e/migrations_test.go | 79 +++ .../internal/e2e/outbound_queue_e2e_test.go | 19 +- .../protocols/internal/e2e/pop3_e2e_test.go | 2 +- .../internal/e2e/smtp_inbound_e2e_test.go | 2 +- .../internal/e2e/submission_e2e_test.go | 360 +++++++++++ .../cloudflare/cloudflare_adapter.go | 462 ++++++++----- .../cloudflare/cloudflare_hardening_test.go | 237 +++++++ .../postgres/auth_repository.go | 4 +- .../postgres/outbound_repository.go | 28 +- .../infrastructure/rspamd/rspamd_adapter.go | 18 +- .../rspamd/rspamd_adapter_test.go | 5 + .../internal/presentation/http/router.go | 55 +- .../internal/presentation/imap/stubs.go | 1 - .../internal/presentation/smtp/backend.go | 39 +- .../internal/presentation/smtp/sasl_login.go | 67 ++ .../presentation/smtp/sasl_login_test.go | 67 ++ .../presentation/smtp/submission_backend.go | 159 +++++ .../smtp/submission_backend_test.go | 94 +++ 70 files changed, 5163 insertions(+), 659 deletions(-) create mode 100644 config/rspamd/actions.conf create mode 100644 config/rspamd/logging.inc create mode 100644 config/rspamd/options.inc create mode 100644 config/rspamd/worker-normal.inc create mode 100644 services/protocols/cmd/lambdamail-protocols/acme_mode.go create mode 100644 services/protocols/cmd/lambdamail-protocols/config.go create mode 100644 services/protocols/cmd/lambdamail-protocols/disk_other.go create mode 100644 services/protocols/cmd/lambdamail-protocols/disk_unix.go create mode 100644 services/protocols/cmd/lambdamail-protocols/preflight.go create mode 100644 services/protocols/internal/application/usecase/inbound_auth_test.go create mode 100644 services/protocols/internal/application/usecase/inbound_scan_headers_test.go create mode 100644 services/protocols/internal/application/usecase/outbound_dsn_test.go create mode 100644 services/protocols/internal/application/usecase/outbound_tls_policy_test.go create mode 100644 services/protocols/internal/application/usecase/preflight.go create mode 100644 services/protocols/internal/application/usecase/preflight_test.go create mode 100644 services/protocols/internal/application/usecase/sync_dns_conflict_test.go create mode 100644 services/protocols/internal/e2e/migrations_test.go create mode 100644 services/protocols/internal/e2e/submission_e2e_test.go create mode 100644 services/protocols/internal/infrastructure/cloudflare/cloudflare_hardening_test.go create mode 100644 services/protocols/internal/presentation/smtp/sasl_login.go create mode 100644 services/protocols/internal/presentation/smtp/sasl_login_test.go create mode 100644 services/protocols/internal/presentation/smtp/submission_backend.go create mode 100644 services/protocols/internal/presentation/smtp/submission_backend_test.go diff --git a/.env.example b/.env.example index a75a6e3..283d73b 100644 --- a/.env.example +++ b/.env.example @@ -8,23 +8,65 @@ REDIS_PASSWORD=change-me-in-local-dev # --- Shared secrets (local dev values — never reuse in production) --- JWT_SECRET=local-dev-jwt-secret-change-me +# Encrypts DKIM private keys and provider tokens at rest. Losing it makes every +# stored secret unrecoverable — back it up separately from the database. LAMBDAMAIL_MASTER_KEY=local-dev-master-key-32-bytes-min -# --- Cloudflare (optional in F0 — DNS automation lands in F3) --- -CLOUDFLARE_API_TOKEN= - # --- Host identity --- PRIMARY_MAIL_HOST=mail.localhost +MAIL_DOMAIN=localhost PUBLIC_IPV4=127.0.0.1 PUBLIC_IPV6= -# --- TLS (F0 has no real certs — protocols stub reports degraded, never crashes) --- +# --- DNS automation (Cloudflare) --- +# The token needs Zone.DNS:Edit plus Zone.Zone:Read on the zone. Leave it empty +# to disable reconciliation; the service then logs that DNS sync is off. +CLOUDFLARE_API_TOKEN= +DNS_SYNC_INTERVAL_SECONDS=21600 + +# --- TLS --- +# traefik: read the certificate Traefik manages (default, DANE not possible). +# acme: obtain the certificate ourselves via DNS-01. Required for DANE, +# because only this mode can reuse a pre-generated key. TLS_MODE=traefik +# Used only when TLS_MODE=acme. Leave ACME_DIRECTORY_URL empty for Let's +# Encrypt production; point it at the staging directory while testing, since +# production rate limits can lock a domain out for a week. +ACME_EMAIL= +ACME_DIRECTORY_URL= COOLIFY_PROXY_DIR=./local/acme +TRAEFIK_ACME_FILENAME=acme.json +CERT_POLL_INTERVAL_SECONDS=60 +# Static certificate files, used when no ACME store is available (local dev). PROTOCOLS_CERT_PATH=/etc/lambdamail/certs/cert.pem PROTOCOLS_KEY_PATH=/etc/lambdamail/certs/key.pem PROTOCOLS_SPOOL_DIR=/var/lambdamail/spool +# --- Transport security policy --- +# Start at "testing": senders honour the policy but do not hard-fail on it. +# Promote to "enforce" only once TLS-RPT reports come back clean. +MTA_STS_MODE=testing +OUTBOUND_MTA_STS_ENABLED=true +# DANE stays off unless TLS_MODE=acme. Under Traefik-managed certificates the +# key changes at every renewal and the published TLSA record stops matching, +# which permanently rejects mail at every validating MTA. +OUTBOUND_DANE_ENABLED=false +DNSSEC_RESOLVER=1.1.1.1:53 +MAX_MESSAGE_BYTES=52428800 + +# ARC seals accepted messages so a downstream receiver can still tell that the +# mail authenticated at the first hop. Only worth enabling on a host that +# forwards mail onward. +ARC_SEAL_ENABLED=false + +# --- Smarthost (for hosts where outbound port 25 is blocked) --- +# Setting RELAY_HOST routes all delivery through the relay and adds it to the +# published SPF record, without which every relayed message fails SPF. +RELAY_HOST= +RELAY_PORT=587 +RELAY_USER= +RELAY_PASS= + # --- Webmail --- WEBMAIL_HOST=localhost:3000 ADMIN_HOST= diff --git a/config/rspamd/actions.conf b/config/rspamd/actions.conf new file mode 100644 index 0000000..c32bee3 --- /dev/null +++ b/config/rspamd/actions.conf @@ -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 diff --git a/config/rspamd/logging.inc b/config/rspamd/logging.inc new file mode 100644 index 0000000..80fb523 --- /dev/null +++ b/config/rspamd/logging.inc @@ -0,0 +1,2 @@ +type = "console"; +level = "info"; diff --git a/config/rspamd/options.inc b/config/rspamd/options.inc new file mode 100644 index 0000000..ab0ab03 --- /dev/null +++ b/config/rspamd/options.inc @@ -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; +} diff --git a/config/rspamd/worker-normal.inc b/config/rspamd/worker-normal.inc new file mode 100644 index 0000000..52b09c3 --- /dev/null +++ b/config/rspamd/worker-normal.inc @@ -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"; diff --git a/docker-compose.yaml b/docker-compose.yaml index 66c8181..06f50c4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,42 +2,62 @@ x-logging: &default-logging driver: json-file options: { max-size: "10m", max-file: "3" } +x-restart: &restart unless-stopped + services: + # ---------------------------------------------------------------- DB db: image: postgres:16-alpine - restart: unless-stopped + restart: *restart logging: *default-logging environment: POSTGRES_DB: ${POSTGRES_DB:?required} POSTGRES_USER: ${POSTGRES_USER:?required} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required} - ports: ["5432:5432"] + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required} # no default: fail fast + POSTGRES_INITDB_ARGS: "--data-checksums" + command: > + postgres -c shared_buffers=256MB -c max_connections=200 + -c wal_level=replica volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] - interval: 5s + interval: 10s timeout: 5s - retries: 10 + retries: 5 + start_period: 30s + mem_limit: 1g + cpus: 1.0 + networks: [backend] + # ------------------------------------------------------------- REDIS redis: image: redis:7-alpine - restart: unless-stopped + restart: *restart logging: *default-logging command: > - redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:?required} + redis-server --appendonly yes --appendfsync everysec + --requirepass ${REDIS_PASSWORD:?required} + --maxmemory 384mb --maxmemory-policy noeviction + # REDIS_PASSWORD has to exist inside the container for the healthcheck: + # $${VAR} expands in the container shell, not in Compose. environment: REDIS_PASSWORD: ${REDIS_PASSWORD} volumes: [redis_data:/data] healthcheck: test: ["CMD-SHELL", "redis-cli --no-auth-warning -a $${REDIS_PASSWORD} ping | grep -q PONG"] - interval: 5s + interval: 10s timeout: 5s - retries: 10 + retries: 5 + mem_limit: 512m + cpus: 0.5 + networks: [backend] + # ----------------------------------------------------------- MIGRATE migrate: image: migrate/migrate:v4.17.1 restart: "no" + logging: *default-logging volumes: ["./migrations:/migrations"] depends_on: db: { condition: service_healthy } @@ -47,91 +67,243 @@ services: - -database - postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable - up + networks: [backend] + # ------------------------------------------------------------ RSPAMD + rspamd: + image: ghcr.io/rspamd/rspamd:3.9 + restart: *restart + logging: *default-logging + environment: + RSPAMD_REDIS_SERVERS: redis + RSPAMD_REDIS_PASSWORD: ${REDIS_PASSWORD} + volumes: + - ./config/rspamd:/etc/rspamd/local.d:ro + - rspamd_data:/var/lib/rspamd + depends_on: + redis: { condition: service_healthy } + healthcheck: + test: ["CMD-SHELL", "rspamadm control stat >/dev/null 2>&1 || exit 1"] + interval: 30s + retries: 3 + mem_limit: 768m + networks: [backend, egress] # egress: RBL/DNSBL lookups and fuzzy storage + + # ------------------------------------------------------------ CLAMAV + clamav: + image: clamav/clamav:1.3 + restart: *restart + logging: *default-logging + volumes: [clamav_data:/var/lib/clamav] + healthcheck: + test: ["CMD-SHELL", "clamdcheck.sh || exit 1"] + interval: 60s + retries: 5 + start_period: 300s # the initial signature load is slow + mem_limit: 2g # ClamAV genuinely needs this + networks: [backend, egress] # egress: freshclam signature updates + + # -------------------------------------------------------------- AUTH auth: build: { context: ./services/auth } - restart: unless-stopped + restart: *restart logging: *default-logging environment: PORT: 3001 DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 JWT_SECRET: ${JWT_SECRET:?required} - LAMBDAMAIL_MASTER_KEY: ${LAMBDAMAIL_MASTER_KEY:?required} + LAMBDAMAIL_MASTER_KEY: ${LAMBDAMAIL_MASTER_KEY:?required} # KEK for sealed secrets + CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-} + PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:?required} + PUBLIC_IPV4: ${PUBLIC_IPV4:?required} + PUBLIC_IPV6: ${PUBLIC_IPV6:-} depends_on: db: { condition: service_healthy } redis: { condition: service_healthy } migrate: { condition: service_completed_successfully } healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:3001/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] - interval: 10s + interval: 15s timeout: 5s - retries: 5 - start_period: 15s + retries: 3 + start_period: 20s + mem_limit: 512m + networks: [backend, egress] # egress: Cloudflare API. Without it DNS sync + # fails silently, since backend is internal. + # ----------------------------------------------------------- STORAGE storage: build: { context: ./services/storage } - restart: unless-stopped + restart: *restart logging: *default-logging environment: PORT: 3002 DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + STORAGE_DRIVER: ${STORAGE_DRIVER:-local} + STORAGE_LOCAL_PATH: /var/lambdamail/mails + S3_ENDPOINT: ${S3_ENDPOINT:-} + S3_BUCKET: ${S3_BUCKET:-} + S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} + S3_SECRET_KEY: ${S3_SECRET_KEY:-} + volumes: [mail_storage:/var/lambdamail/mails] depends_on: db: { condition: service_healthy } redis: { condition: service_healthy } migrate: { condition: service_completed_successfully } healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:3002/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 15s + interval: 15s + retries: 3 + start_period: 20s + mem_limit: 1g + cpus: 1.0 + networks: [backend, egress] # egress: external S3 when STORAGE_DRIVER=s3 + # --------------------------------------------------------- PROTOCOLS protocols: build: { context: ./services/protocols } - restart: unless-stopped + restart: *restart logging: *default-logging - ports: ["8080:8080", "25:25", "143:143"] + ports: + - "25:25" # SMTP MX + - "587:587" # Submission (STARTTLS) + - "465:465" # Submission (implicit TLS) + - "143:143" # IMAP (STARTTLS) + - "993:993" # IMAPS + - "110:110" # POP3 (STARTTLS) + - "995:995" # POP3S + - "4190:4190" # ManageSieve environment: DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable - PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:-mail.localhost} - PROTOCOLS_CERT_PATH: /etc/lambdamail/certs/cert.pem - PROTOCOLS_KEY_PATH: /etc/lambdamail/certs/key.pem + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + RSPAMD_URL: http://rspamd:11333 + CLAMAV_ADDR: clamav:3310 + PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:?required} + MAIL_DOMAIN: ${MAIL_DOMAIN:?required} + LAMBDAMAIL_MASTER_KEY: ${LAMBDAMAIL_MASTER_KEY:?required} # required for DKIM keys + # --- TLS --- + TLS_MODE: ${TLS_MODE:-traefik} # traefik | acme + TRAEFIK_ACME_DIR: /etc/traefik # the DIRECTORY, not the file (section 8.2) + TRAEFIK_ACME_FILENAME: acme.json + CERT_POLL_INTERVAL_SECONDS: "60" + # --- Policy --- + MTA_STS_MODE: ${MTA_STS_MODE:-testing} # promote to enforce once TLS-RPT is clean + # DANE requires TLS_MODE=acme; the preflight refuses the combination + # with Traefik-managed certificates (section 5.1). + OUTBOUND_DANE_ENABLED: ${OUTBOUND_DANE_ENABLED:-false} + OUTBOUND_MTA_STS_ENABLED: "true" + DNSSEC_RESOLVER: ${DNSSEC_RESOLVER:-1.1.1.1:53} + # ARC only earns its keep on a host that forwards mail onward. + ARC_SEAL_ENABLED: ${ARC_SEAL_ENABLED:-false} + # Smarthost, for providers that block outbound port 25 (section 10.4). + # Setting RELAY_HOST also adds the relay to the published SPF record. + RELAY_HOST: ${RELAY_HOST:-} + RELAY_PORT: ${RELAY_PORT:-587} + RELAY_USER: ${RELAY_USER:-} + RELAY_PASS: ${RELAY_PASS:-} + MAX_MESSAGE_BYTES: "52428800" + # --- Self-managed ACME (mode B, section 8.3) --- + ACME_EMAIL: ${ACME_EMAIL:-} + ACME_DIRECTORY_URL: ${ACME_DIRECTORY_URL:-} + # --- DNS automation --- + CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-} + PUBLIC_IPV4: ${PUBLIC_IPV4:?required} + PUBLIC_IPV6: ${PUBLIC_IPV6:-} + DNS_SYNC_INTERVAL_SECONDS: "21600" # 6 h drift detection PROTOCOLS_SPOOL_DIR: /var/lambdamail/spool volumes: - - ./local/certs:/etc/lambdamail/certs:ro + # Bind-mount the DIRECTORY: Traefik's atomic rename breaks a + # file-scoped mount and the watcher would go blind (section 8.2). + - ${COOLIFY_PROXY_DIR:-/data/coolify/proxy}:/etc/traefik:ro - protocols_spool:/var/lambdamail/spool depends_on: db: { condition: service_healthy } + redis: { condition: service_healthy } migrate: { condition: service_completed_successfully } + rspamd: { condition: service_started } + clamav: { condition: service_started } healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health/ready || exit 1"] - interval: 10s + test: ["CMD", "/app/lambdamail-protocols", "healthcheck"] + interval: 15s timeout: 5s - retries: 5 - start_period: 15s + retries: 3 + start_period: 30s + mem_limit: 1g + cpus: 1.5 + ulimits: + nofile: { soft: 65536, hard: 65536 } + networks: [backend, egress] # egress: port 25 delivery, DNSSEC, MTA-STS fetch + # -------------------------------------------------------------- WEB web: build: { context: ./services/webmail } - restart: unless-stopped + restart: *restart logging: *default-logging - ports: ["3000:3000"] environment: PORT: 3000 AUTH_SERVICE_URL: http://auth:3001 STORAGE_SERVICE_URL: http://storage:3002 + NEXT_PUBLIC_APP_URL: https://${WEBMAIL_HOST:?required} + # Isolation of the two surfaces (section 14.1). With ADMIN_HOST set the + # console answers only on that host; otherwise it lives under + # ${WEBMAIL_HOST}/admin. + ADMIN_HOST: ${ADMIN_HOST:-} + TOTP_ISSUER: ${TOTP_ISSUER:-LambdaMail} + MFA_CHALLENGE_TTL_SECONDS: "300" + ADMIN_SESSION_TTL_SECONDS: "28800" # 8 h + ADMIN_STEPUP_INTERVAL_SECONDS: "900" # re-require TOTP every 15 min + USER_SESSION_TTL_SECONDS: "2592000" # 30 d + TRUSTED_DEVICE_TTL_SECONDS: "2592000" depends_on: auth: { condition: service_healthy } storage: { condition: service_healthy } - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health || exit 1"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 15s + labels: + - traefik.enable=true + # With the service on two networks Traefik may route via the wrong one + # and return 504. This pins it to the proxy network. + - traefik.docker.network=${COOLIFY_PROXY_NETWORK:-coolify} + # Webmail (/user/*) + - traefik.http.routers.lm-web.rule=Host(`${WEBMAIL_HOST}`) + - traefik.http.routers.lm-web.tls.certresolver=letsencrypt + - traefik.http.services.lm-web.loadbalancer.server.port=3000 + # Admin console. Nested fallback: with no ADMIN_HOST the console lives + # under the webmail host at /admin. Never use a fictitious host as the + # default - Traefik would loop on ACME for it and risk a rate limit. + - traefik.http.routers.lm-admin.rule=Host(`${ADMIN_HOST:-${WEBMAIL_HOST}}`) && PathPrefix(`/admin`) + - traefik.http.routers.lm-admin.priority=20 + - traefik.http.routers.lm-admin.tls.certresolver=letsencrypt + # MTA-STS needs a valid certificate on mta-sts. + - 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. never appears in acme.json and every + # TLS listener comes up without a certificate (section 8.2, trap 2). + - traefik.http.routers.lm-mailhost.rule=Host(`${PRIMARY_MAIL_HOST}`) + - traefik.http.routers.lm-mailhost.tls.certresolver=letsencrypt + mem_limit: 512m + networks: [backend, proxy] volumes: postgres_data: redis_data: + mail_storage: + rspamd_data: + clamav_data: protocols_spool: + +networks: + backend: + internal: true # no route to the internet: internal traffic only + # Controlled egress: only the services that must reach the internet join it + # (auth -> Cloudflare API; protocols -> remote MX, DNS, MTA-STS fetch; + # rspamd -> RBLs; clamav -> freshclam; storage -> external S3). + egress: + driver: bridge + proxy: + external: true + name: ${COOLIFY_PROXY_NETWORK:-coolify} diff --git a/services/protocols/cmd/lambdamail-protocols/acme_mode.go b/services/protocols/cmd/lambdamail-protocols/acme_mode.go new file mode 100644 index 0000000..ea1a891 --- /dev/null +++ b/services/protocols/cmd/lambdamail-protocols/acme_mode.go @@ -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, ".")) +} diff --git a/services/protocols/cmd/lambdamail-protocols/config.go b/services/protocols/cmd/lambdamail-protocols/config.go new file mode 100644 index 0000000..0324db4 --- /dev/null +++ b/services/protocols/cmd/lambdamail-protocols/config.go @@ -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 +} diff --git a/services/protocols/cmd/lambdamail-protocols/disk_other.go b/services/protocols/cmd/lambdamail-protocols/disk_other.go new file mode 100644 index 0000000..eddd6f6 --- /dev/null +++ b/services/protocols/cmd/lambdamail-protocols/disk_other.go @@ -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") +} diff --git a/services/protocols/cmd/lambdamail-protocols/disk_unix.go b/services/protocols/cmd/lambdamail-protocols/disk_unix.go new file mode 100644 index 0000000..f80a636 --- /dev/null +++ b/services/protocols/cmd/lambdamail-protocols/disk_unix.go @@ -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 +} diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go index a0d7275..495103e 100644 --- a/services/protocols/cmd/lambdamail-protocols/main.go +++ b/services/protocols/cmd/lambdamail-protocols/main.go @@ -1,86 +1,430 @@ // Command lambdamail-protocols is the entry point for the protocols service. +// +// Subcommands: +// +// (none) run every protocol listener +// preflight run the environment checks of PLAN.md section 15 and exit +// healthcheck probe the local HTTP health endpoint and exit package main import ( "context" "crypto/tls" + "fmt" "log" + "net" "net/http" "os" + "os/signal" + "syscall" "time" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapserver" gosmtp "github.com/emersion/go-smtp" + "github.com/jackc/pgx/v5/pgxpool" "lambdamail/protocols/internal/application/port" "lambdamail/protocols/internal/application/usecase" + "lambdamail/protocols/internal/domain/valueobject" + "lambdamail/protocols/internal/infrastructure/arc" "lambdamail/protocols/internal/infrastructure/clamav" + "lambdamail/protocols/internal/infrastructure/cloudflare" "lambdamail/protocols/internal/infrastructure/diskstorage" + "lambdamail/protocols/internal/infrastructure/dkim" + "lambdamail/protocols/internal/infrastructure/mailauth" "lambdamail/protocols/internal/infrastructure/netdns" "lambdamail/protocols/internal/infrastructure/postgres" "lambdamail/protocols/internal/infrastructure/rspamd" tlsprovider "lambdamail/protocols/internal/infrastructure/tls" - imappresentation "lambdamail/protocols/internal/presentation/imap" + "lambdamail/protocols/internal/infrastructure/tlspolicy" + "lambdamail/protocols/internal/infrastructure/vault" httppresentation "lambdamail/protocols/internal/presentation/http" + imappresentation "lambdamail/protocols/internal/presentation/imap" managesievepresentation "lambdamail/protocols/internal/presentation/managesieve" pop3presentation "lambdamail/protocols/internal/presentation/pop3" smtppresentation "lambdamail/protocols/internal/presentation/smtp" ) func main() { - ctx := context.Background() + cfg := loadConfig() - pool, err := postgres.NewPool(ctx, os.Getenv("DATABASE_URL")) + if len(os.Args) > 1 { + switch os.Args[1] { + case "preflight": + runPreflight(cfg) + return + case "healthcheck": + runHealthcheck() + return + default: + log.Fatalf("unknown subcommand %q (expected preflight or healthcheck)", os.Args[1]) + } + } + + run(cfg) +} + +func run(cfg config) { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + pool, err := postgres.NewPool(ctx, cfg.DatabaseURL) if err != nil { log.Fatalf("open database pool: %v", err) } defer pool.Close() - spoolDir := envOrDefault("PROTOCOLS_SPOOL_DIR", "/var/lambdamail/spool") - if err := os.MkdirAll(spoolDir, 0o755); err != nil { - log.Fatalf("create spool dir %s: %v", spoolDir, err) + if err := os.MkdirAll(cfg.SpoolDir, 0o755); err != nil { + log.Fatalf("create spool dir %s: %v", cfg.SpoolDir, err) } + secretVault, vaultErr := vault.New(cfg.MasterKey) + certProvider := buildCertProvider(ctx, cfg, pool, secretVault, vaultErr) + + // ---------------------------------------------------------------- repos mailboxes := postgres.NewMailboxRepository(pool) - blobs := diskstorage.NewLocalDiskBlobStorage(pool, spoolDir) + blobs := diskstorage.NewLocalDiskBlobStorage(pool, cfg.SpoolDir) + blobReader := diskstorage.NewLocalDiskBlobReader(pool) messages := postgres.NewInboundMessageRepository(pool) - useCase := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages) + authRepo := postgres.NewAuthRepository(pool) + imapFolders := postgres.NewImapFolderRepository(pool) + messageQuery := postgres.NewMessageQueryRepository(pool) + flagRepo := postgres.NewFlagRepository(pool) + expungeRepo := postgres.NewExpungeRepository(pool) + copyRepo := postgres.NewCopyRepository(pool) + sieveRepo := postgres.NewSieveRepository(pool) + reportRepo := postgres.NewReportRepository(pool) + outboundRepo := postgres.NewOutboundRepository(pool) + aliasRepo := postgres.NewAliasRepository(pool) - clamavAddr := os.Getenv("CLAMAV_ADDR") - rspamdURL := os.Getenv("RSPAMD_URL") - if clamavAddr != "" || rspamdURL != "" { - var scanners []port.ContentScanner - if clamavAddr != "" { - scanners = append(scanners, clamav.NewClamAVAdapter(clamavAddr)) - } - if rspamdURL != "" { - scanners = append(scanners, rspamd.NewRspamdAdapter(rspamdURL)) - } - pipeline := usecase.NewScanningPipeline(scanners...) - useCase.SetScanner(pipeline) - log.Printf("lambdamail-protocols antispam/antivirus scanner enabled (clamav: %s, rspamd: %s)", clamavAddr, rspamdURL) + // The vault is required for DKIM: without it the private keys could not + // be stored, and unsigned outbound mail fails the DMARC alignment that + // PLAN.md section 5 depends on. + var dkimRepo *postgres.DkimRepository + if vaultErr != nil { + log.Printf("WARNING: %v - DKIM signing is disabled, outbound mail will not be signed", vaultErr) + } else { + dkimRepo = postgres.NewDkimRepository(pool, secretVault) } - certProvider, err := tlsprovider.NewLocalFileCertProvider( - os.Getenv("PROTOCOLS_CERT_PATH"), - os.Getenv("PROTOCOLS_KEY_PATH"), - ) - if err != nil { - log.Printf("WARNING: could not load configured TLS certificate (%v) - falling back to an ephemeral self-signed certificate; this is degraded mode, not suitable for real deployments", err) - certProvider, err = tlsprovider.NewEphemeralSelfSignedCertProvider() + // ------------------------------------------------------------- inbound + inboundUC := usecase.NewProcessInboundEmailUseCase(mailboxes, blobs, messages) + inboundUC.SetAuthenticator(mailauth.NewAuthenticator(cfg.PrimaryMailHost)) + + if scanner := buildScanner(cfg); scanner != nil { + inboundUC.SetScanner(scanner) + } + + // ------------------------------------------------------------ outbound + var signer port.DkimSigner + if dkimRepo != nil { + signer = dkim.NewSigner(dkimRepo) + } + submissionUC := usecase.NewProcessOutboundEmailUseCase(authRepo, outboundRepo, blobs, signer) + + mxResolver := netdns.NewNetMXResolver() + outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, inboundUC, mailboxes, cfg.PrimaryMailHost) + outboundWorker.SetPolicyResolver(buildPolicyResolver(cfg)) + if relay := relayConfig(cfg); relay.Configured() { + outboundWorker.SetRelay(relay) + log.Printf("lambdamail-protocols delivering through the smarthost %s", relay.Address()) + } + + // ARC needs a signing key, so it can only be enabled where DKIM is. + if cfg.ArcSealEnabled && dkimRepo != nil { + if sealer := buildArcSealer(ctx, cfg, dkimRepo); sealer != nil { + inboundUC.SetArcSealer(sealer) + log.Printf("lambdamail-protocols ARC sealing enabled for %s", cfg.domain()) + } + } + + // --------------------------------------------------------- access UCs + imapUseCase := usecase.NewImapSessionUseCase(authRepo, imapFolders, messageQuery, flagRepo, blobReader, expungeRepo, copyRepo) + inboundUC.SetTrackerManager(imapUseCase.GetTrackerManager(), imapFolders) + pop3UseCase := usecase.NewPop3SessionUseCase(authRepo, imapFolders, messageQuery, blobReader, expungeRepo) + sieveUseCase := usecase.NewManageSieveSessionUseCase(authRepo, sieveRepo) + + // ----------------------------------------------------------- listeners + startSMTPListeners(cfg, certProvider, inboundUC, submissionUC) + startIMAPListeners(cfg, certProvider, imapUseCase) + startPOP3Listeners(certProvider, pop3UseCase) + startManageSieveListener(certProvider, sieveUseCase) + + // ------------------------------------------------------- background jobs + go runDeliveryWorker(ctx, outboundWorker) + if cfg.CloudflareToken != "" { + go runDnsReconciler(ctx, cfg, aliasRepo, dkimRepo) + } else { + log.Printf("CLOUDFLARE_API_TOKEN is not set: DNS reconciliation is disabled") + } + + // ------------------------------------------------------------- HTTP API + router := httppresentation.NewRouter(usecase.NewIngestReportsUseCase(reportRepo), func() error { return pool.Ping(ctx) }) + applyMtaStsMode(router, cfg) + + server := &http.Server{ + Addr: ":8080", + Handler: router, + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + + log.Printf("lambdamail-protocols HTTP service listening on %s", server.Addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("http serve: %v", err) + } + log.Printf("lambdamail-protocols shut down") +} + +// buildCertProvider picks the certificate source. Mode "traefik" reads the +// store Traefik maintains and keeps watching it, because the certificate is +// replaced every sixty days and a stale one takes every listener down +// (PLAN.md section 8). +func buildCertProvider(ctx context.Context, cfg config, pool *pgxpool.Pool, secretVault *vault.SecretVault, vaultErr error) port.CertProvider { + // Mode B: LambdaMail obtains the certificate itself. This is the only + // mode DANE can run in, because it is the only one where the key for the + // next certificate exists before that certificate does + // (PLAN.md sections 5.1 and 8.3). + if cfg.TLSMode == "acme" { + if vaultErr != nil { + log.Fatalf("TLS_MODE=acme needs LAMBDAMAIL_MASTER_KEY to seal the account and certificate keys: %v", vaultErr) + } + if cfg.CloudflareToken == "" { + log.Fatalf("TLS_MODE=acme needs CLOUDFLARE_API_TOKEN: the DNS-01 challenge is answered through the Cloudflare API") + } + + provider, err := startAcmeManager(ctx, cfg, pool, secretVault) if err != nil { - log.Fatalf("generate fallback self-signed certificate: %v", err) + log.Fatalf("start self-managed ACME: %v", err) + } + return provider + } + + if cfg.TraefikAcmeDir != "" { + watcher, err := tlsprovider.NewAcmeCertWatcher(cfg.TraefikAcmeDir, cfg.TraefikAcmeFile, cfg.PrimaryMailHost) + if err == nil { + if !watcher.HasCertificateFor(cfg.PrimaryMailHost) { + log.Printf("CRITICAL: the ACME store has no certificate for %s - declare a Traefik router for that host so ACME issues one (PLAN.md section 8.2)", cfg.PrimaryMailHost) + } + go watcher.Watch(ctx.Done(), cfg.CertPollInterval) + log.Printf("lambdamail-protocols reading TLS certificates from %s (polling every %s)", cfg.TraefikAcmeDir, cfg.CertPollInterval) + return watcher + } + log.Printf("WARNING: could not read the ACME store (%v) - falling back", err) + } + + if cfg.StaticCertPath != "" { + if provider, err := tlsprovider.NewLocalFileCertProvider(cfg.StaticCertPath, cfg.StaticCertKeyPath); err == nil { + return provider + } else { + log.Printf("WARNING: could not load the configured TLS certificate: %v", err) } } - backend := smtppresentation.NewBackend(useCase) - smtpServer := gosmtp.NewServer(backend) - smtpServer.Addr = ":25" - smtpServer.Domain = envOrDefault("PRIMARY_MAIL_HOST", "mail.localhost") - smtpServer.MaxMessageBytes = 52428800 // 50 MB default; per-domain limit is a future iteration - smtpServer.MaxRecipients = 100 - smtpServer.TLSConfig = &tls.Config{ + log.Printf("CRITICAL: falling back to an ephemeral self-signed certificate; this is degraded mode, not suitable for a real deployment") + provider, err := tlsprovider.NewEphemeralSelfSignedCertProvider() + if err != nil { + log.Fatalf("generate fallback self-signed certificate: %v", err) + } + return provider +} + +func relayConfig(cfg config) usecase.RelayConfig { + return usecase.RelayConfig{ + Host: cfg.RelayHost, + Port: cfg.RelayPort, + Username: cfg.RelayUser, + Password: cfg.RelayPass, + } +} + +// buildArcSealer signs ARC sets with the domain's RSA DKIM key, which is what +// a verifier resolves through the same selector. +func buildArcSealer(ctx context.Context, cfg config, keys port.DkimKeyRepository) port.ArcSealer { + domain := cfg.domain() + + active, err := keys.FindActiveKeys(ctx, domain) + if err != nil || len(active) == 0 { + log.Printf("WARNING: ARC sealing requested but no DKIM key is active for %s yet; it will start once one is provisioned", domain) + return nil + } + + for _, key := range active { + signer, err := dkim.ParsePrivateKey(key.PrivateKeyPEM) + if err != nil { + continue + } + return arc.NewSealer(domain, key.Selector, cfg.PrimaryMailHost, signer) + } + + log.Printf("WARNING: none of the DKIM keys for %s could be used for ARC", domain) + return nil +} + +func buildScanner(cfg config) port.ContentScanner { + var scanners []port.ContentScanner + if cfg.ClamavAddr != "" { + scanners = append(scanners, clamav.NewClamAVAdapter(cfg.ClamavAddr)) + } + if cfg.RspamdURL != "" { + scanners = append(scanners, rspamd.NewRspamdAdapter(cfg.RspamdURL)) + } + if len(scanners) == 0 { + return nil + } + log.Printf("lambdamail-protocols content scanning enabled (clamav: %q, rspamd: %q)", cfg.ClamavAddr, cfg.RspamdURL) + return usecase.NewScanningPipeline(scanners...) +} + +// buildPolicyResolver assembles the outbound transport security policy of +// PLAN.md section 6.2. +func buildPolicyResolver(cfg config) port.TLSPolicyResolver { + // With a smarthost the relay takes responsibility for transport security + // (PLAN.md section 10.4), so applying DANE and MTA-STS here would only + // evaluate policies against the wrong hop. + if cfg.RelayHost != "" { + log.Printf("lambdamail-protocols relaying through %s: DANE and MTA-STS are handled by the relay", cfg.RelayHost) + return tlspolicy.NewResolver(tlspolicy.WithMtaSts(false), tlspolicy.WithDane(false, nil)) + } + + options := []tlspolicy.Option{tlspolicy.WithMtaSts(cfg.OutboundMtaSts)} + if cfg.OutboundDane { + options = append(options, tlspolicy.WithDane(true, netdns.NewTlsaResolver(cfg.DnssecResolver))) + } + + log.Printf("lambdamail-protocols outbound TLS policy: dane=%v mta-sts=%v", cfg.OutboundDane, cfg.OutboundMtaSts) + return tlspolicy.NewResolver(options...) +} + +func applyMtaStsMode(router *httppresentation.Router, cfg config) { + mode := valueobject.MtaStsMode(cfg.MtaStsMode) + switch mode { + case valueobject.MtaStsModeTesting, valueobject.MtaStsModeEnforce, valueobject.MtaStsModeNone: + router.SetMtaStsMode(mode) + log.Printf("lambdamail-protocols MTA-STS policy mode: %s", mode) + default: + log.Printf("WARNING: ignoring unknown MTA_STS_MODE %q, serving %q", mode, valueobject.MtaStsModeTesting) + } +} + +// startSMTPListeners brings up the three SMTP ports of PLAN.md section 4. The +// MX port and the submission ports run different backends: port 25 never +// authenticates and only accepts local mail, while submission always +// authenticates and relays outward. +func startSMTPListeners(cfg config, certProvider port.CertProvider, inboundUC *usecase.ProcessInboundEmailUseCase, submissionUC *usecase.ProcessOutboundEmailUseCase) { + tlsConfig := mailTLSConfig(certProvider) + + // Port 25: STARTTLS is always advertised but never required. RFC 5321 + // obliges an MX to accept cleartext, and refusing it loses legitimate + // mail without improving any audit score (PLAN.md section 4). + mx := gosmtp.NewServer(smtppresentation.NewBackend(inboundUC)) + mx.Addr = ":25" + mx.Domain = cfg.PrimaryMailHost + mx.MaxMessageBytes = cfg.MaxMessageBytes + mx.MaxRecipients = 100 + mx.ReadTimeout = 5 * time.Minute + mx.WriteTimeout = 5 * time.Minute + mx.TLSConfig = tlsConfig + serveInBackground("SMTP MX", ":25", mx.ListenAndServe) + + // Port 587: submission over STARTTLS. AllowInsecureAuth stays false, so + // go-smtp withholds the AUTH capability until the connection is + // encrypted - credentials never travel in the clear. + submission := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC)) + submission.Addr = ":587" + submission.Domain = cfg.PrimaryMailHost + submission.MaxMessageBytes = cfg.MaxMessageBytes + submission.MaxRecipients = 100 + submission.ReadTimeout = 5 * time.Minute + submission.WriteTimeout = 5 * time.Minute + submission.TLSConfig = tlsConfig + submission.AllowInsecureAuth = false + serveInBackground("SMTP submission", ":587", submission.ListenAndServe) + + // Port 465: implicit TLS (RFC 8314), the recommended submission port. + submissions := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC)) + submissions.Addr = ":465" + submissions.Domain = cfg.PrimaryMailHost + submissions.MaxMessageBytes = cfg.MaxMessageBytes + submissions.MaxRecipients = 100 + submissions.ReadTimeout = 5 * time.Minute + submissions.WriteTimeout = 5 * time.Minute + submissions.TLSConfig = tlsConfig + serveInBackground("SMTPS submission", ":465", submissions.ListenAndServeTLS) +} + +func startIMAPListeners(cfg config, certProvider port.CertProvider, imapUseCase *usecase.ImapSessionUseCase) { + newServer := func() *imapserver.Server { + return imapserver.New(&imapserver.Options{ + NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { + return imappresentation.NewSession(c, imapUseCase) + }, + Caps: imap.CapSet{ + imap.CapIMAP4rev1: {}, + imap.CapMove: {}, + imap.CapUIDPlus: {}, + imap.CapIdle: {}, + imap.CapCondStore: {}, + }, + TLSConfig: mailTLSConfig(certProvider), + // LOGINDISABLED is advertised before TLS: the client must + // upgrade before it may send credentials (PLAN.md section 4). + InsecureAuth: false, + }) + } + + serveInBackground("IMAP", ":143", func() error { return newServer().ListenAndServe(":143") }) + serveInBackground("IMAPS", ":993", func() error { + return serveImplicitTLS(":993", mailTLSConfig(certProvider), newServer().Serve) + }) +} + +func startPOP3Listeners(certProvider port.CertProvider, pop3UseCase *usecase.Pop3SessionUseCase) { + serveInBackground("POP3", ":110", pop3presentation.NewServer(":110", pop3UseCase, certProvider).ListenAndServe) + serveInBackground("POP3S", ":995", func() error { + server := pop3presentation.NewServer(":995", pop3UseCase, certProvider) + return serveImplicitTLS(":995", mailTLSConfig(certProvider), server.Serve) + }) +} + +func startManageSieveListener(certProvider port.CertProvider, sieveUseCase *usecase.ManageSieveSessionUseCase) { + serveInBackground("ManageSieve", ":4190", managesievepresentation.NewServer(":4190", sieveUseCase, certProvider).ListenAndServe) +} + +// serveImplicitTLS wraps a plain listener in TLS, which is what the implicit +// ports (465, 993, 995) need: the handshake happens before any protocol +// greeting rather than after a STARTTLS command. +func serveImplicitTLS(addr string, tlsConfig *tls.Config, serve func(net.Listener) error) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + return serve(tls.NewListener(ln, tlsConfig)) +} + +// serveInBackground runs a listener and logs a fatal error if it dies. A +// protocol port that silently fails to bind would look healthy while being +// unreachable, so failure is loud. +func serveInBackground(name, addr string, serve func() error) { + go func() { + log.Printf("lambdamail-protocols %s listening on %s", name, addr) + if err := serve(); err != nil { + log.Fatalf("%s serve on %s: %v", name, addr, err) + } + }() +} + +// mailTLSConfig is the cipher policy of PLAN.md section 4: TLS 1.2 floor, +// forward secrecy only, and the three curves named there. +func mailTLSConfig(certProvider port.CertProvider) *tls.Config { + return &tls.Config{ GetCertificate: certProvider.GetCertificate, MinVersion: tls.VersionTLS12, CipherSuites: []uint16{ @@ -93,99 +437,120 @@ func main() { }, CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384}, } +} - go func() { - log.Printf("lambdamail-protocols SMTP listening on %s", smtpServer.Addr) - if err := smtpServer.ListenAndServe(); err != nil { - log.Fatalf("smtp serve: %v", err) - } - }() +func runDeliveryWorker(ctx context.Context, worker *usecase.OutboundWorkerUseCase) { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() - authRepo := postgres.NewAuthRepository(pool) - imapFolders := postgres.NewImapFolderRepository(pool) - messageQuery := postgres.NewMessageQueryRepository(pool) - flagRepo := postgres.NewFlagRepository(pool) - blobReader := diskstorage.NewLocalDiskBlobReader(pool) - expungeRepo := postgres.NewExpungeRepository(pool) - copyRepo := postgres.NewCopyRepository(pool) - imapUseCase := usecase.NewImapSessionUseCase(authRepo, imapFolders, messageQuery, flagRepo, blobReader, expungeRepo, copyRepo) - useCase.SetTrackerManager(imapUseCase.GetTrackerManager(), imapFolders) - - imapServer := imapserver.New(&imapserver.Options{ - NewSession: func(c *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { - return imappresentation.NewSession(c, imapUseCase) - }, - Caps: imap.CapSet{ - imap.CapIMAP4rev1: {}, - imap.CapMove: {}, - imap.CapUIDPlus: {}, - imap.CapIdle: {}, - imap.CapCondStore: {}, - }, - TLSConfig: &tls.Config{GetCertificate: certProvider.GetCertificate, MinVersion: tls.VersionTLS12}, - InsecureAuth: false, - }) - - go func() { - log.Printf("lambdamail-protocols IMAP listening on :143") - if err := imapServer.ListenAndServe(":143"); err != nil { - log.Fatalf("imap serve: %v", err) - } - }() - - pop3UseCase := usecase.NewPop3SessionUseCase(authRepo, imapFolders, messageQuery, blobReader, expungeRepo) - pop3Server := pop3presentation.NewServer(":110", pop3UseCase, certProvider) - - go func() { - log.Printf("lambdamail-protocols POP3 listening on :110") - if err := pop3Server.ListenAndServe(); err != nil { - log.Fatalf("pop3 serve: %v", err) - } - }() - - sieveRepo := postgres.NewSieveRepository(pool) - sieveUseCase := usecase.NewManageSieveSessionUseCase(authRepo, sieveRepo) - sieveServer := managesievepresentation.NewServer(":4190", sieveUseCase, certProvider) - - go func() { - log.Printf("lambdamail-protocols ManageSieve listening on :4190") - if err := sieveServer.ListenAndServe(); err != nil { - log.Fatalf("managesieve serve: %v", err) - } - }() - - reportRepo := postgres.NewReportRepository(pool) - reportUseCase := usecase.NewIngestReportsUseCase(reportRepo) - - outboundRepo := postgres.NewOutboundRepository(pool) - mxResolver := netdns.NewNetMXResolver() - outboundWorker := usecase.NewOutboundWorkerUseCase(outboundRepo, mxResolver, blobReader, useCase, mailboxes, smtpServer.Domain) - - go func() { - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - _, _ = outboundWorker.ProcessBatch(ctx, "protocols-worker-1", 10) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if _, err := worker.ProcessBatch(ctx, "protocols-worker-1", 10); err != nil { + log.Printf("delivery worker batch failed: %v", err) } } - }() - - router := httppresentation.NewRouter(reportUseCase, func() error { return pool.Ping(ctx) }) - - addr := ":8080" - log.Printf("lambdamail-protocols HTTP service listening on %s", addr) - if err := http.ListenAndServe(addr, router); err != nil { - log.Fatalf("http serve: %v", err) } } -func envOrDefault(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v +// runDnsReconciler provisions the DKIM keys and reconciles the zone, then +// repeats on the interval of PLAN.md section 7.5 to detect drift. +func runDnsReconciler(ctx context.Context, cfg config, aliasRepo port.SystemAliasRepository, dkimRepo *postgres.DkimRepository) { + syncUC := usecase.NewSyncDnsRecordsUseCase(cloudflare.NewCloudflareAdapter(cfg.CloudflareToken), aliasRepo) + syncUC.SetVerifier(netdns.NewPublicVerifier()) + + var provisioner *usecase.ProvisionDkimKeysUseCase + if dkimRepo != nil { + provisioner = usecase.NewProvisionDkimKeysUseCase(dkimRepo, generateDkimKey) + } + + reconcile := func() { + domain := cfg.domain() + + input := usecase.SyncDnsRecordsInput{ + DomainName: domain, + MailHost: cfg.PrimaryMailHost, + ServerIPv4: cfg.PublicIPv4, + ServerIPv6: cfg.PublicIPv6, + DaneEnabled: cfg.OutboundDane, + // A smarthost sends on the domain's behalf, so the hard-fail SPF + // has to authorise it or every relayed message fails + // (PLAN.md section 10.4). + RelaySpfInclude: relayConfig(cfg).SpfInclude(), + } + + if provisioner != nil { + keys, err := provisioner.Execute(ctx, domain) + if err != nil { + log.Printf("DNS reconcile: could not provision DKIM keys for %s: %v", domain, err) + return + } + input.RsaDkimPubKey = keys.RsaPublicKey + input.EdDkimPubKey = keys.Ed25519PublicKey + if len(keys.Created) > 0 { + log.Printf("DNS reconcile: generated DKIM keys for %s: %v", domain, keys.Created) + } + } + + out, err := syncUC.Execute(ctx, input) + if err != nil { + log.Printf("DNS reconcile for %s failed: %v", domain, err) + return + } + + log.Printf("DNS reconcile for %s: %s (created=%d updated=%d unchanged=%d conflicts=%d)", + domain, out.Status, out.CreatedCount, out.UpdatedCount, out.UnchangedCount, out.ConflictCount) + + // Conflicts and unresolvable records are reported individually + // because each needs a different operator action. + for _, conflict := range out.Conflicts { + log.Printf("DNS conflict (left untouched): %s", conflict) + } + for _, unverified := range out.Unverified { + log.Printf("DNS record not yet visible to public resolvers: %s", unverified) + } + for _, e := range out.Errors { + log.Printf("DNS reconcile error: %s", e) + } + } + + reconcile() + + ticker := time.NewTicker(cfg.DnsSyncInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + reconcile() + } + } +} + +// generateDkimKey adapts the key generator to the use case's signature, +// keeping the crypto in the infrastructure layer. +func generateDkimKey(algorithm string) ([]byte, string, error) { + generated, err := dkim.Generate(algorithm) + if err != nil { + return nil, "", err + } + return generated.PrivateKeyPEM, generated.PublicKeyBase64, nil +} + +func runHealthcheck() { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/health") + if err != nil { + fmt.Fprintf(os.Stderr, "healthcheck failed: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "healthcheck failed: HTTP %d\n", resp.StatusCode) + os.Exit(1) } - return fallback } diff --git a/services/protocols/cmd/lambdamail-protocols/preflight.go b/services/protocols/cmd/lambdamail-protocols/preflight.go new file mode 100644 index 0000000..10831e4 --- /dev/null +++ b/services/protocols/cmd/lambdamail-protocols/preflight.go @@ -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) +} diff --git a/services/protocols/go.mod b/services/protocols/go.mod index d1f046c..9b778b0 100644 --- a/services/protocols/go.mod +++ b/services/protocols/go.mod @@ -3,24 +3,33 @@ module lambdamail/protocols go 1.25.0 require ( + blitiri.com.ar/go/spf v1.5.1 + github.com/alexedwards/argon2id v1.0.0 + github.com/emersion/go-imap/v2 v2.0.0-beta.8 + github.com/emersion/go-message v0.18.2 + github.com/emersion/go-msgauth v0.7.0 + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-smtp v0.24.0 + github.com/fergusstrange/embedded-postgres v1.30.0 + github.com/go-acme/lego/v4 v4.35.2 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 + github.com/miekg/dns v1.1.72 + golang.org/x/crypto v0.54.0 ) require ( - github.com/alexedwards/argon2id v1.0.0 // indirect - github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect - github.com/emersion/go-message v0.18.2 // indirect - github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect - github.com/fergusstrange/embedded-postgres v1.30.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/lib/pq v1.10.9 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect - golang.org/x/crypto v0.14.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect ) diff --git a/services/protocols/go.sum b/services/protocols/go.sum index 019f9c8..7d55489 100644 --- a/services/protocols/go.sum +++ b/services/protocols/go.sum @@ -1,18 +1,30 @@ +blitiri.com.ar/go/spf v1.5.1 h1:CWUEasc44OrANJD8CzceRnRn1Jv0LttY68cYym2/pbE= +blitiri.com.ar/go/spf v1.5.1/go.mod h1:E71N92TfL4+Yyd5lpKuE9CAF2pd4JrUq1xQfkTxoNdk= github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc= +github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= github.com/fergusstrange/embedded-postgres v1.30.0 h1:ewv1e6bBlqOIYtgGgRcEnNDpfGlmfPxB8T3PO9tV68Q= github.com/fergusstrange/embedded-postgres v1.30.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= +github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= +github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -25,8 +37,11 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -35,22 +50,29 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -58,8 +80,9 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -72,12 +95,14 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/protocols/internal/application/port/auth_repository.go b/services/protocols/internal/application/port/auth_repository.go index 8962e94..47677dd 100644 --- a/services/protocols/internal/application/port/auth_repository.go +++ b/services/protocols/internal/application/port/auth_repository.go @@ -6,10 +6,15 @@ import ( "github.com/google/uuid" ) -// MailboxAuth is the subset of mailbox data needed to verify a login. +// MailboxAuth is the subset of mailbox data needed to verify a login and, +// once authenticated, to apply the sender policy of PLAN.md section 5.2. type MailboxAuth struct { ID uuid.UUID PasswordHash string + EmailAddress string + DomainName string + // MaxRecipientsPerHour caps a compromised account's blast radius. + MaxRecipientsPerHour int } // AuthRepository resolves a login address to its stored credential. diff --git a/services/protocols/internal/application/port/imap_folder_repository.go b/services/protocols/internal/application/port/imap_folder_repository.go index 0d4ed9f..870797e 100644 --- a/services/protocols/internal/application/port/imap_folder_repository.go +++ b/services/protocols/internal/application/port/imap_folder_repository.go @@ -4,11 +4,11 @@ import "context" // ImapFolderRecord is the IMAP-relevant subset of a folder row. type ImapFolderRecord struct { - ID string - Name string - UIDNext uint32 - UIDValidity uint32 - NumMessages uint32 + ID string + Name string + UIDNext uint32 + UIDValidity uint32 + NumMessages uint32 HighestModSeq uint64 } diff --git a/services/protocols/internal/application/port/outbound_repository.go b/services/protocols/internal/application/port/outbound_repository.go index 4adb572..e8d7f34 100644 --- a/services/protocols/internal/application/port/outbound_repository.go +++ b/services/protocols/internal/application/port/outbound_repository.go @@ -2,6 +2,9 @@ package port import ( "context" + "time" + + "github.com/google/uuid" "lambdamail/protocols/internal/domain/entity" ) @@ -10,4 +13,9 @@ type OutboundRepository interface { Enqueue(ctx context.Context, job *entity.OutboundJob) error FetchNextReady(ctx context.Context, workerID string, limit int) ([]*entity.OutboundJob, error) UpdateJob(ctx context.Context, job *entity.OutboundJob) error + + // CountRecipientsSince reports how many recipients a mailbox has queued + // since a point in time. It backs the per-mailbox send limit that keeps a + // compromised account from becoming a spam source (PLAN.md section 5.2). + CountRecipientsSince(ctx context.Context, mailboxID uuid.UUID, since time.Time) (int, error) } diff --git a/services/protocols/internal/application/usecase/inbound_auth_test.go b/services/protocols/internal/application/usecase/inbound_auth_test.go new file mode 100644 index 0000000..f705eb6 --- /dev/null +++ b/services/protocols/internal/application/usecase/inbound_auth_test.go @@ -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) } diff --git a/services/protocols/internal/application/usecase/inbound_scan_headers_test.go b/services/protocols/internal/application/usecase/inbound_scan_headers_test.go new file mode 100644 index 0000000..91b2329 --- /dev/null +++ b/services/protocols/internal/application/usecase/inbound_scan_headers_test.go @@ -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) + } +} diff --git a/services/protocols/internal/application/usecase/managesieve_session.go b/services/protocols/internal/application/usecase/managesieve_session.go index af080dd..5348b7b 100644 --- a/services/protocols/internal/application/usecase/managesieve_session.go +++ b/services/protocols/internal/application/usecase/managesieve_session.go @@ -12,10 +12,10 @@ import ( ) var ( - ErrScriptNotFound = errors.New("script does not exist") - ErrActiveScriptDelete = errors.New("cannot delete active script") + ErrScriptNotFound = errors.New("script does not exist") + ErrActiveScriptDelete = errors.New("cannot delete active script") ErrScriptAlreadyExists = errors.New("script with new name already exists") - ErrScriptQuotaExceeded = errors.New("script size exceeds storage limit") + ErrScriptQuotaExceeded = errors.New("script size exceeds storage limit") ) const MaxScriptSizeBytes = 1024 * 1024 // 1 MB limit per Sieve script diff --git a/services/protocols/internal/application/usecase/managesieve_session_test.go b/services/protocols/internal/application/usecase/managesieve_session_test.go index 4112565..3069b6c 100644 --- a/services/protocols/internal/application/usecase/managesieve_session_test.go +++ b/services/protocols/internal/application/usecase/managesieve_session_test.go @@ -135,7 +135,7 @@ func TestManageSieveSessionUseCase_Put_Get_List_SetActive_Delete(t *testing.T) { t.Fatalf("SetActive failed: %v", err) } - // 6. Delete active script fails per RFC 5804 §2.7 + // 6. Delete active script fails per RFC 5804 section 2.7 if err := uc.DeleteScript(ctx, gotID, "main_rules"); !errors.Is(err, ErrActiveScriptDelete) { t.Fatalf("expected ErrActiveScriptDelete, got %v", err) } diff --git a/services/protocols/internal/application/usecase/outbound_dsn_test.go b/services/protocols/internal/application/usecase/outbound_dsn_test.go new file mode 100644 index 0000000..61a8417 --- /dev/null +++ b/services/protocols/internal/application/usecase/outbound_dsn_test.go @@ -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) + } + } +} diff --git a/services/protocols/internal/application/usecase/outbound_tls_policy_test.go b/services/protocols/internal/application/usecase/outbound_tls_policy_test.go new file mode 100644 index 0000000..a10d018 --- /dev/null +++ b/services/protocols/internal/application/usecase/outbound_tls_policy_test.go @@ -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 .\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) + } +} diff --git a/services/protocols/internal/application/usecase/outbound_worker.go b/services/protocols/internal/application/usecase/outbound_worker.go index 08e082b..055c783 100644 --- a/services/protocols/internal/application/usecase/outbound_worker.go +++ b/services/protocols/internal/application/usecase/outbound_worker.go @@ -4,9 +4,13 @@ import ( "bytes" "context" "crypto/tls" + "crypto/x509" + "errors" "fmt" "net" "net/smtp" + "net/textproto" + "strconv" "strings" "time" @@ -15,13 +19,35 @@ import ( "lambdamail/protocols/internal/domain/valueobject" ) +// ErrPolicyDowngradeRefused is returned when a destination's published TLS +// policy could not be satisfied. PLAN.md section 6.2 is explicit: the message +// is deferred with 4.7.5, never delivered over an unvalidated channel. +var ErrPolicyDowngradeRefused = errors.New("outbound: refusing to deliver without the TLS validation the destination requires") + type OutboundWorkerUseCase struct { - outboundRepo port.OutboundRepository - mxResolver port.MXResolver - blobReader port.BlobReader - inboundUC *ProcessInboundEmailUseCase - mailboxes port.MailboxRepository - localHost string + outboundRepo port.OutboundRepository + mxResolver port.MXResolver + blobReader port.BlobReader + inboundUC *ProcessInboundEmailUseCase + mailboxes port.MailboxRepository + policyResolver port.TLSPolicyResolver + relay RelayConfig + localHost string +} + +// SetRelay routes delivery through a smarthost (PLAN.md section 10.4). This is +// the fallback for hosts whose provider blocks outbound port 25, and it +// replaces MX resolution entirely: every message goes to the relay. +func (w *OutboundWorkerUseCase) SetRelay(relay RelayConfig) { + w.relay = relay +} + +// SetPolicyResolver enables DANE and MTA-STS on the delivery path. Without +// one the worker falls back to opportunistic TLS, which is correct for a +// destination that publishes no policy but must never be the choice for one +// that does. +func (w *OutboundWorkerUseCase) SetPolicyResolver(resolver port.TLSPolicyResolver) { + w.policyResolver = resolver } func NewOutboundWorkerUseCase( @@ -72,6 +98,21 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit return } + // With a smarthost every message goes to the relay regardless of + // destination, and the relay owns transport security from there on. + if w.relay.Configured() { + if err := w.deliverViaRelay(job.EnvelopeFrom, job.EnvelopeTo, payload); err != nil { + w.finishFailed(ctx, job, err) + return + } + job.Status = entity.OutboundJobStatusDelivered + job.LastError = "" + job.LastSmtpCode = "" + job.TlsPolicyUsed = entity.TLSModeRelay + _ = w.outboundRepo.UpdateJob(ctx, job) + return + } + mxHosts, err := w.mxResolver.LookupMX(ctx, job.DestinationDomain) if err != nil || len(mxHosts) == 0 { mxHosts = []string{job.DestinationDomain} @@ -79,10 +120,28 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit var lastErr error delivered := false + policyUsed := entity.TLSModeOpportunistic for _, mxHost := range mxHosts { - if err := w.deliverToMX(ctx, mxHost, job.EnvelopeFrom, job.EnvelopeTo, payload); err == nil { + policy, err := w.resolvePolicy(ctx, job.DestinationDomain, mxHost) + if err != nil { + // A published policy we cannot evaluate is a reason to wait, not + // a reason to send in the clear. + lastErr = err + continue + } + + // RFC 8461 section 4.1: a host outside the policy's mx set must not + // be used while that policy is in force. + if policy.RequiresValidation() && !policy.CoversHost(mxHost) { + lastErr = fmt.Errorf("%w: %s is not listed in the MTA-STS policy of %s", + ErrPolicyDowngradeRefused, mxHost, job.DestinationDomain) + continue + } + + if err := w.deliverToMX(ctx, mxHost, job.EnvelopeFrom, job.EnvelopeTo, payload, policy); err == nil { delivered = true + policyUsed = policy.Effective() break } else { lastErr = err @@ -92,18 +151,52 @@ func (w *OutboundWorkerUseCase) processSingleJob(ctx context.Context, job *entit if delivered { job.Status = entity.OutboundJobStatusDelivered job.LastError = "" - job.TlsPolicyUsed = "opportunistic" + job.LastSmtpCode = "" + job.TlsPolicyUsed = policyUsed _ = w.outboundRepo.UpdateJob(ctx, job) return } + w.finishFailed(ctx, job, lastErr) +} + +// finishFailed classifies a delivery failure and records the outcome. +func (w *OutboundWorkerUseCase) finishFailed(ctx context.Context, job *entity.OutboundJob, lastErr error) { errStr := "delivery failed" if lastErr != nil { errStr = lastErr.Error() } - isPermanent := strings.HasPrefix(errStr, "5") - w.handleFailure(ctx, job, errStr, isPermanent) + // The remote reply code decides the outcome (PLAN.md section 6.3): 5xx is + // a permanent failure that bounces now, anything else - including a dial + // or TLS error, which carries no reply code at all - is retried. + code := smtpReplyCode(lastErr) + if code > 0 { + job.LastSmtpCode = strconv.Itoa(code) + } + + // A refused downgrade is always temporary: the destination may fix its + // certificate, and bouncing would hand the attacker the outcome they + // wanted (PLAN.md section 6.2). + if errors.Is(lastErr, ErrPolicyDowngradeRefused) { + job.LastSmtpCode = "451" + job.TlsPolicyUsed = "none" + w.handleFailure(ctx, job, "4.7.5 "+errStr, false) + return + } + + w.handleFailure(ctx, job, errStr, code >= 500 && code < 600) +} + +// smtpReplyCode extracts the reply code from an error returned by net/smtp, +// which wraps remote replies in *textproto.Error. It returns 0 when the +// failure happened before any reply was read. +func smtpReplyCode(err error) int { + var protoErr *textproto.Error + if errors.As(err, &protoErr) { + return protoErr.Code + } + return 0 } func (w *OutboundWorkerUseCase) handleFailure(ctx context.Context, job *entity.OutboundJob, errStr string, isPermanent bool) { @@ -141,20 +234,87 @@ func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject. return } - if w.mailboxes != nil { - targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom) - if err == nil && len(targets) > 0 { - _ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{ - Sender: "postmaster@lambdamail.local", - Recipients: targets, - RecipientAddresses: []string{job.EnvelopeFrom}, - Body: bytes.NewReader(dsnBytes), - }) - } + if w.mailboxes == nil { + return } + + targets, err := w.mailboxes.ResolveDeliveryTargets(ctx, job.EnvelopeFrom) + if err != nil || len(targets) == 0 { + return + } + + // Handle indexes the two slices in lockstep, so the envelope address is + // repeated once per resolved mailbox. An alias sender fans out to several + // mailboxes under the same address. + addresses := make([]string, len(targets)) + for i := range targets { + addresses[i] = job.EnvelopeFrom + } + + _ = w.inboundUC.Handle(ctx, ProcessInboundEmailInput{ + Sender: "postmaster@lambdamail.local", + Recipients: targets, + RecipientAddresses: addresses, + Body: bytes.NewReader(dsnBytes), + }) } -func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, from string, to string, payload []byte) error { +// resolvePolicy asks the resolver what transport security the destination +// requires. With no resolver configured every destination is opportunistic. +func (w *OutboundWorkerUseCase) resolvePolicy(ctx context.Context, destinationDomain, mxHost string) (entity.TLSPolicy, error) { + if w.policyResolver == nil { + return entity.NewTLSPolicy(false, false), nil + } + return w.policyResolver.Resolve(ctx, destinationDomain, mxHost) +} + +// tlsConfigFor turns a policy into a TLS configuration. +// +// Opportunistic delivery deliberately skips verification: RFC 7435 prefers an +// unauthenticated encrypted channel to a cleartext one, and the alternative - +// refusing every destination with a self-signed certificate - would lose +// legitimate mail. When a policy is in force the verification is real, and a +// failure surfaces as a refusal to deliver. +func tlsConfigFor(mxHost string, policy entity.TLSPolicy) *tls.Config { + config := &tls.Config{ + ServerName: strings.TrimSuffix(mxHost, "."), + MinVersion: tls.VersionTLS12, + } + + if !policy.RequiresValidation() { + config.InsecureSkipVerify = true + return config + } + + if policy.Effective() == entity.TLSModeDane { + // DANE authenticates the certificate directly against the + // DNSSEC-signed association, so the PKIX chain and the name are not + // what is being trusted (RFC 7672 section 3.1). Verification is done + // by hand below instead of by the standard chain builder. + config.InsecureSkipVerify = true + config.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + chain := make([]*x509.Certificate, 0, len(rawCerts)) + for _, raw := range rawCerts { + cert, err := x509.ParseCertificate(raw) + if err != nil { + return fmt.Errorf("parse peer certificate: %w", err) + } + chain = append(chain, cert) + } + if !policy.MatchesCertificate(chain) { + return fmt.Errorf("%w: certificate of %s matches no TLSA record", ErrPolicyDowngradeRefused, mxHost) + } + return nil + } + return config + } + + // MTA-STS in enforce mode requires a normal, publicly trusted PKIX + // validation against the MX name (RFC 8461 section 4.1). + return config +} + +func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, from string, to string, payload []byte, policy entity.TLSPolicy) error { addr := mxHost if _, _, err := net.SplitHostPort(mxHost); err != nil { addr = net.JoinHostPort(mxHost, "25") @@ -177,11 +337,20 @@ func (w *OutboundWorkerUseCase) deliverToMX(ctx context.Context, mxHost string, return fmt.Errorf("EHLO failed: %w", err) } - if ok, _ := client.Extension("STARTTLS"); ok { - config := &tls.Config{InsecureSkipVerify: true, ServerName: mxHost} - if err := client.StartTLS(config); err != nil { + starttlsOffered, _ := client.Extension("STARTTLS") + if starttlsOffered { + if err := client.StartTLS(tlsConfigFor(mxHost, policy)); err != nil { + if policy.RequiresValidation() { + return fmt.Errorf("%w: STARTTLS to %s failed validation: %v", ErrPolicyDowngradeRefused, mxHost, err) + } return fmt.Errorf("STARTTLS failed: %w", err) } + } else if policy.RequiresValidation() { + // The destination's own published policy says it speaks TLS. A peer + // that does not offer STARTTLS is either broken or being stripped, + // and handing it the message either way is exactly the downgrade the + // policy exists to prevent. + return fmt.Errorf("%w: %s did not offer STARTTLS", ErrPolicyDowngradeRefused, mxHost) } if err := client.Mail(from); err != nil { diff --git a/services/protocols/internal/application/usecase/outbound_worker_test.go b/services/protocols/internal/application/usecase/outbound_worker_test.go index aa87569..8d0837e 100644 --- a/services/protocols/internal/application/usecase/outbound_worker_test.go +++ b/services/protocols/internal/application/usecase/outbound_worker_test.go @@ -30,6 +30,10 @@ func (f *fakeOutboundRepo) FetchNextReady(_ context.Context, _ string, _ int) ([ return ready, nil } +func (f *fakeOutboundRepo) CountRecipientsSince(_ context.Context, _ uuid.UUID, _ time.Time) (int, error) { + return 0, nil +} + func (f *fakeOutboundRepo) UpdateJob(_ context.Context, job *entity.OutboundJob) error { for i, j := range f.jobs { if j.ID == job.ID { diff --git a/services/protocols/internal/application/usecase/preflight.go b/services/protocols/internal/application/usecase/preflight.go new file mode 100644 index 0000000..d1f2e41 --- /dev/null +++ b/services/protocols/internal/application/usecase/preflight.go @@ -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]) +} diff --git a/services/protocols/internal/application/usecase/preflight_test.go b/services/protocols/internal/application/usecase/preflight_test.go new file mode 100644 index 0000000..fbc85b4 --- /dev/null +++ b/services/protocols/internal/application/usecase/preflight_test.go @@ -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) + } +} diff --git a/services/protocols/internal/application/usecase/process_inbound_email.go b/services/protocols/internal/application/usecase/process_inbound_email.go index 33c55fc..842fb97 100644 --- a/services/protocols/internal/application/usecase/process_inbound_email.go +++ b/services/protocols/internal/application/usecase/process_inbound_email.go @@ -6,7 +6,11 @@ import ( "bytes" "context" "errors" + "fmt" "io" + "log" + "net" + "sort" "lambdamail/protocols/internal/application/port" "lambdamail/protocols/internal/domain/valueobject" @@ -31,6 +35,11 @@ type ProcessInboundEmailInput struct { Recipients []port.MailboxRecord RecipientAddresses []string Body io.Reader + // ClientIP and HeloDomain identify the peer. SPF is defined over them + // (RFC 7208 section 2.3), so a delivery without them can only ever + // produce a "none" verdict. + ClientIP net.IP + HeloDomain string } // ProcessInboundEmailUseCase implements the inbound half of PLAN.md section @@ -39,6 +48,8 @@ type ProcessInboundEmailInput struct { // later sub-projects). type ProcessInboundEmailUseCase struct { scanner port.ContentScanner + authenticator port.MailAuthenticator + arcSealer port.ArcSealer mailboxes port.MailboxRepository blobs port.BlobStorage messages port.InboundMessageRepository @@ -54,11 +65,49 @@ func (uc *ProcessInboundEmailUseCase) SetScanner(scanner port.ContentScanner) { uc.scanner = scanner } +func (uc *ProcessInboundEmailUseCase) SetAuthenticator(authenticator port.MailAuthenticator) { + uc.authenticator = authenticator +} + +// SetArcSealer enables ARC sealing of accepted messages. It is only worth +// enabling on a host that forwards mail onward (PLAN.md section 5). +func (uc *ProcessInboundEmailUseCase) SetArcSealer(sealer port.ArcSealer) { + uc.arcSealer = sealer +} + func (uc *ProcessInboundEmailUseCase) SetTrackerManager(tm *MailboxTrackerManager, folders port.ImapFolderRepository) { uc.trackerManager = tm uc.folders = folders } +// prependHeaders inserts the scanner's verdict headers at the top of the +// message. A receiving MTA adds its trace headers above the existing ones +// (RFC 5321 section 4.4), which also keeps the original bytes - and therefore +// any DKIM signature over them - untouched. +func prependHeaders(payload []byte, headers map[string]string) []byte { + if len(headers) == 0 { + return payload + } + + // Sorted so the delivered message is byte-for-byte reproducible instead of + // depending on Go's randomized map iteration order. + names := make([]string, 0, len(headers)) + for name := range headers { + names = append(names, name) + } + sort.Strings(names) + + var buf bytes.Buffer + for _, name := range names { + buf.WriteString(name) + buf.WriteString(": ") + buf.WriteString(headers[name]) + buf.WriteString("\r\n") + } + buf.Write(payload) + return buf.Bytes() +} + // ResolveRecipient is called from the SMTP session's RCPT TO handler. It // returns every mailbox address should be delivered to (more than one for // an alias that fans out), or ErrRecipientNotFound / ErrMailboxQuotaExceeded. @@ -84,12 +133,68 @@ func (uc *ProcessInboundEmailUseCase) ResolveRecipient(ctx context.Context, addr // the same blob (PLAN.md section 9's dedup design: 1 email to 50 // recipients = 1 file). func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessInboundEmailInput) error { + // The two slices are indexed in lockstep below: one envelope address per + // resolved mailbox, repeated when an alias fans out. Refusing a mismatch + // turns a caller's bug into an error instead of an out-of-range panic that + // would take down the calling worker. + if len(input.Recipients) != len(input.RecipientAddresses) { + return fmt.Errorf( + "process inbound email: %d recipients but %d envelope addresses", + len(input.Recipients), len(input.RecipientAddresses), + ) + } + payload, err := io.ReadAll(input.Body) if err != nil { return err } targetFolder := "INBOX" + authResult := port.InboundAuthResult{ + SPF: port.AuthResultNone, + DKIM: port.AuthResultNone, + DMARC: port.AuthResultNone, + } + + // Authentication runs before the content scanners so that the spam filter + // sees our Authentication-Results header and can score on it + // (PLAN.md section 6.1). + if uc.authenticator != nil { + authResult = uc.authenticator.Authenticate(ctx, port.InboundAuthInput{ + ClientIP: input.ClientIP, + HeloDomain: input.HeloDomain, + EnvelopeFrom: input.Sender, + Message: payload, + }) + + // Honouring a published "p=reject" is the whole point of the sender + // having published it. Quarantine is left to the spam filter, which + // files rather than refuses. + if authResult.DMARC == port.AuthResultFail && authResult.DmarcPolicy == "reject" { + return errors.New("550 5.7.1 DMARC policy rejects this message") + } + + if authResult.AuthenticationResults != "" { + payload = prependHeaders(payload, map[string]string{ + "Authentication-Results": authResult.AuthenticationResults, + }) + } + + // The ARC set has to be built over the message as it arrived, with + // this hop's verdict already known, so it is sealed here rather than + // after the content scanners rewrite headers. + if uc.arcSealer != nil { + sealed, err := uc.arcSealer.Seal(ctx, payload, authResult) + if err != nil { + // A sealing failure must not lose the message: it is delivered + // without the ARC set, which is exactly the state it would + // have been in had sealing not been enabled. + log.Printf("arc: could not seal message from %s: %v", input.Sender, err) + } else { + payload = sealed + } + } + } if uc.scanner != nil { recipientAddr := "" @@ -115,6 +220,7 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI case valueobject.ScanVerdictSpamJunk: targetFolder = "Junk" } + payload = prependHeaders(payload, scanRes.HeadersToAdd) } } @@ -130,9 +236,9 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI SenderAddress: input.Sender, RecipientAddress: input.RecipientAddresses[i], TargetFolderName: targetFolder, - SPFResult: "none", - DKIMResult: "none", - DMARCResult: "none", + SPFResult: authResult.SPF, + DKIMResult: authResult.DKIM, + DMARCResult: authResult.DMARC, }) if err != nil { return err diff --git a/services/protocols/internal/application/usecase/sync_dns_conflict_test.go b/services/protocols/internal/application/usecase/sync_dns_conflict_test.go new file mode 100644 index 0000000..2fd4629 --- /dev/null +++ b/services/protocols/internal/application/usecase/sync_dns_conflict_test.go @@ -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) + } +} diff --git a/services/protocols/internal/application/usecase/sync_dns_records.go b/services/protocols/internal/application/usecase/sync_dns_records.go index 4ac6b71..62a0c99 100644 --- a/services/protocols/internal/application/usecase/sync_dns_records.go +++ b/services/protocols/internal/application/usecase/sync_dns_records.go @@ -9,28 +9,68 @@ import ( "lambdamail/protocols/internal/domain/entity" ) +// SyncStatus is the outcome reported to the admin console (PLAN.md section 7.5). +type SyncStatus string + +const ( + SyncStatusVerified SyncStatus = "VERIFIED" + SyncStatusPartial SyncStatus = "PARTIAL" + SyncStatusDrift SyncStatus = "DRIFT" +) + +// ownedRecordCommentPrefix marks the records this reconciler manages. Anything +// in the zone without it that would collide with a desired record belongs to +// somebody else and is reported instead of overwritten. +const ownedRecordCommentPrefix = "LambdaMail" + type SyncDnsRecordsInput struct { - DomainName string - MailHost string - ServerIPv4 string - ServerIPv6 string - RsaDkimPubKey string - EdDkimPubKey string + DomainName string + MailHost string + ServerIPv4 string + ServerIPv6 string + RsaDkimPubKey string + EdDkimPubKey string + // TlsaHash publishes a single DANE association. TlsaHashes supersedes it + // and is what a rollover uses, since it can carry the current and the + // next key at once. TlsaHash string + TlsaHashes []string DaneEnabled bool + RelaySpfInclude string AdminEmailAddress string } +// SyncConflict describes a record that could not be reconciled because a +// foreign record already occupies its name. +type SyncConflict struct { + Type string + Name string + Existing string + Desired string +} + +func (c SyncConflict) String() string { + return fmt.Sprintf("%s %s: existing %q blocks desired %q", c.Type, c.Name, c.Existing, c.Desired) +} + type SyncDnsRecordsOutput struct { ZoneID string + Status SyncStatus CreatedCount int UpdatedCount int UnchangedCount int + ConflictCount int + Conflicts []SyncConflict + Errors []string + // Unverified lists records that were written but could not be read back + // through the public resolvers. + Unverified []string } type SyncDnsRecordsUseCase struct { dnsProvider port.DnsProvider aliasRepo port.SystemAliasRepository + verifier port.DnsVerifier } func NewSyncDnsRecordsUseCase(dnsProvider port.DnsProvider, aliasRepo port.SystemAliasRepository) *SyncDnsRecordsUseCase { @@ -40,21 +80,35 @@ func NewSyncDnsRecordsUseCase(dnsProvider port.DnsProvider, aliasRepo port.Syste } } +// SetVerifier enables the independent check of PLAN.md section 7.5: after +// writing, the records are read back through public resolvers. The provider +// accepting a write only proves the API accepted it, not that the record +// resolves for anyone else. +func (uc *SyncDnsRecordsUseCase) SetVerifier(verifier port.DnsVerifier) { + uc.verifier = verifier +} + func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecordsInput) (*SyncDnsRecordsOutput, error) { if input.DomainName == "" { return nil, fmt.Errorf("domain name is required") } - desiredSpecs := entity.Build13DnsRecordSpecs( - input.DomainName, - input.MailHost, - input.ServerIPv4, - input.ServerIPv6, - input.RsaDkimPubKey, - input.EdDkimPubKey, - input.TlsaHash, - input.DaneEnabled, - ) + tlsaHashes := input.TlsaHashes + if len(tlsaHashes) == 0 && input.TlsaHash != "" { + tlsaHashes = []string{input.TlsaHash} + } + + desiredSpecs := entity.BuildDnsRecordSpecs(entity.DnsRecordSpec{ + DomainName: input.DomainName, + MailHost: input.MailHost, + ServerIPv4: input.ServerIPv4, + ServerIPv6: input.ServerIPv6, + RsaDkimPubKey: input.RsaDkimPubKey, + EdDkimPubKey: input.EdDkimPubKey, + TlsaHashes: tlsaHashes, + DaneEnabled: input.DaneEnabled, + RelaySpfInclude: input.RelaySpfInclude, + }) zoneID, err := uc.dnsProvider.GetZoneID(ctx, input.DomainName) if err != nil { @@ -66,31 +120,48 @@ func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecor return nil, fmt.Errorf("list dns records for zone %s: %w", zoneID, err) } - existingMap := make(map[string]entity.DnsRecord) + // A single type+name can legitimately hold several records (multiple TXT at + // the zone apex is the common case), so the index maps to every candidate. + existingByKey := make(map[string][]entity.DnsRecord) for _, rec := range existingRecords { - key := fmt.Sprintf("%s:%s", strings.ToUpper(rec.Type), strings.ToLower(rec.Name)) - existingMap[key] = rec + key := recordKey(rec.Type, rec.Name) + existingByKey[key] = append(existingByKey[key], rec) } output := &SyncDnsRecordsOutput{ZoneID: zoneID} for _, spec := range desiredSpecs { - key := fmt.Sprintf("%s:%s", strings.ToUpper(spec.Type), strings.ToLower(spec.Name)) - existing, found := existingMap[key] + candidates := existingByKey[recordKey(spec.Type, spec.Name)] + match, foreign := classifyCandidates(spec, candidates) - if !found { - if err := uc.dnsProvider.CreateRecord(ctx, zoneID, spec); err != nil { - return nil, fmt.Errorf("create dns record %s %s: %w", spec.Type, spec.Name, err) + switch { + case match != nil: + if spec.EqualsNormalized(*match) { + output.UnchangedCount++ + continue } - output.CreatedCount++ - } else if !spec.EqualsNormalized(existing) { - spec.ID = existing.ID + spec.ID = match.ID if err := uc.dnsProvider.UpdateRecord(ctx, zoneID, spec); err != nil { - return nil, fmt.Errorf("update dns record %s %s: %w", spec.Type, spec.Name, err) + output.Errors = append(output.Errors, fmt.Sprintf("update %s %s: %v", spec.Type, spec.Name, err)) + continue } output.UpdatedCount++ - } else { - output.UnchangedCount++ + + case foreign != nil: + output.ConflictCount++ + output.Conflicts = append(output.Conflicts, SyncConflict{ + Type: spec.Type, + Name: spec.Name, + Existing: foreign.Value, + Desired: spec.Value, + }) + + default: + if err := uc.dnsProvider.CreateRecord(ctx, zoneID, spec); err != nil { + output.Errors = append(output.Errors, fmt.Sprintf("create %s %s: %v", spec.Type, spec.Name, err)) + continue + } + output.CreatedCount++ } } @@ -100,5 +171,104 @@ func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecor } } + output.Unverified = uc.verifyPublished(ctx, desiredSpecs) + + output.Status = SyncStatusVerified + switch { + case output.ConflictCount > 0 || len(output.Errors) > 0: + output.Status = SyncStatusPartial + case len(output.Unverified) > 0: + // The records were written but the world cannot see them yet. That is + // drift, not success: reporting VERIFIED here is what would let a + // broken zone look healthy. + output.Status = SyncStatusDrift + } + return output, nil } + +// verifyPublished re-reads every desired record through public resolvers and +// returns the ones that did not check out. +func (uc *SyncDnsRecordsUseCase) verifyPublished(ctx context.Context, specs []entity.DnsRecord) []string { + if uc.verifier == nil { + return nil + } + + var unverified []string + for _, spec := range specs { + visible, detail := uc.verifier.VerifyRecord(ctx, spec) + if visible { + continue + } + entry := fmt.Sprintf("%s %s", spec.Type, spec.Name) + if detail != "" { + entry += ": " + detail + } + unverified = append(unverified, entry) + } + return unverified +} + +func recordKey(recType, name string) string { + return strings.ToUpper(recType) + ":" + strings.ToLower(strings.TrimSuffix(name, ".")) +} + +// classifyCandidates picks, among the records already published under a +// desired record's type and name, the one this reconciler owns. It returns +// either that match, or - when no record is ours and the name admits only a +// single value - the foreign record blocking it. +func classifyCandidates(spec entity.DnsRecord, candidates []entity.DnsRecord) (match *entity.DnsRecord, blocking *entity.DnsRecord) { + for i := range candidates { + if isOurRecord(spec, candidates[i]) { + return &candidates[i], nil + } + } + if len(candidates) > 0 && !allowsCoexistence(spec.Type) { + return nil, &candidates[0] + } + return nil, nil +} + +// isOurRecord decides ownership. The record comment is the primary marker, but +// it is not preserved by every provider or migration path, so TXT records fall +// back to their protocol tag: only one "v=spf1" record may exist per name, so a +// record carrying our tag is unambiguously the one we manage. +func isOurRecord(spec, existing entity.DnsRecord) bool { + if strings.HasPrefix(existing.Comment, ownedRecordCommentPrefix) { + return true + } + if strings.EqualFold(spec.Type, "TXT") { + tag := txtTag(spec.Value) + return tag != "" && strings.EqualFold(txtTag(existing.Value), tag) + } + // For single-valued names an identical value means the record is already + // what we want, whoever created it. + return strings.TrimSpace(spec.Value) == strings.TrimSpace(existing.Value) +} + +// txtTag extracts the leading "v=" 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 + } +} diff --git a/services/protocols/internal/application/usecase/sync_dns_records_test.go b/services/protocols/internal/application/usecase/sync_dns_records_test.go index abeb32a..3fa5c1c 100644 --- a/services/protocols/internal/application/usecase/sync_dns_records_test.go +++ b/services/protocols/internal/application/usecase/sync_dns_records_test.go @@ -9,12 +9,35 @@ import ( "lambdamail/protocols/internal/domain/entity" ) +// fakeDnsProvider models a real zone: several records may share a type and +// name (multiple TXT at the apex), so records are held in a list keyed by id. type fakeDnsProvider struct { - records map[string]entity.DnsRecord // key -> record + records []entity.DnsRecord + nextID int } func newFakeDnsProvider() *fakeDnsProvider { - return &fakeDnsProvider{records: make(map[string]entity.DnsRecord)} + return &fakeDnsProvider{} +} + +// seed publishes a pre-existing record that this reconciler did not create. +func (f *fakeDnsProvider) seed(record entity.DnsRecord) { + if record.ID == "" { + f.nextID++ + record.ID = fmt.Sprintf("seed_%d", f.nextID) + } + f.records = append(f.records, record) +} + +// find returns the records published under a type and name. +func (f *fakeDnsProvider) find(recType, name string) []entity.DnsRecord { + var out []entity.DnsRecord + for _, r := range f.records { + if strings.EqualFold(r.Type, recType) && strings.EqualFold(r.Name, name) { + out = append(out, r) + } + } + return out } func (f *fakeDnsProvider) GetZoneID(_ context.Context, domainName string) (string, error) { @@ -22,31 +45,31 @@ func (f *fakeDnsProvider) GetZoneID(_ context.Context, domainName string) (strin } func (f *fakeDnsProvider) ListRecords(_ context.Context, _ string) ([]entity.DnsRecord, error) { - var out []entity.DnsRecord - for _, r := range f.records { - out = append(out, r) - } - return out, nil + return append([]entity.DnsRecord(nil), f.records...), nil } func (f *fakeDnsProvider) CreateRecord(_ context.Context, _ string, record entity.DnsRecord) error { - key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name)) - record.ID = fmt.Sprintf("id_%s", key) - f.records[key] = record + f.nextID++ + record.ID = fmt.Sprintf("id_%d", f.nextID) + f.records = append(f.records, record) return nil } func (f *fakeDnsProvider) UpdateRecord(_ context.Context, _ string, record entity.DnsRecord) error { - key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name)) - f.records[key] = record - return nil + for i, r := range f.records { + if r.ID == record.ID { + f.records[i] = record + return nil + } + } + return fmt.Errorf("record %s not found", record.ID) } func (f *fakeDnsProvider) DeleteRecord(_ context.Context, _ string, recordID string) error { - for k, r := range f.records { + for i, r := range f.records { if r.ID == recordID { - delete(f.records, k) - break + f.records = append(f.records[:i], f.records[i+1:]...) + return nil } } return nil @@ -61,7 +84,11 @@ func (f *fakeSystemAliasRepository) EnsureSystemAliases(_ context.Context, _ str return nil } -func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) { +// The full desired state is the 13 numbered records of PLAN.md section 7.1 +// plus the three client-autoconfiguration records of section 7.2. +const expectedRecordCount = 16 + +func TestSyncDnsRecordsUseCase_CreatesFullRecordSetAndAliases(t *testing.T) { dns := newFakeDnsProvider() alias := &fakeSystemAliasRepository{} uc := NewSyncDnsRecordsUseCase(dns, alias) @@ -85,8 +112,8 @@ func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) { t.Fatalf("Execute failed: %v", err) } - if out.CreatedCount != 13 { - t.Errorf("CreatedCount = %d, want 13", out.CreatedCount) + if out.CreatedCount != expectedRecordCount { + t.Errorf("CreatedCount = %d, want %d", out.CreatedCount, expectedRecordCount) } if !alias.ensured { t.Error("expected system aliases to be ensured") @@ -98,7 +125,7 @@ func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) { t.Fatalf("Execute 2 failed: %v", err) } - if out2.UnchangedCount != 13 || out2.CreatedCount != 0 || out2.UpdatedCount != 0 { - t.Errorf("expected 13 unchanged, got created=%d, updated=%d, unchanged=%d", out2.CreatedCount, out2.UpdatedCount, out2.UnchangedCount) + if out2.UnchangedCount != expectedRecordCount || out2.CreatedCount != 0 || out2.UpdatedCount != 0 { + t.Errorf("expected %d unchanged, got created=%d, updated=%d, unchanged=%d", expectedRecordCount, out2.CreatedCount, out2.UpdatedCount, out2.UnchangedCount) } } diff --git a/services/protocols/internal/domain/entity/dmarc_report.go b/services/protocols/internal/domain/entity/dmarc_report.go index a06c98a..fe37e28 100644 --- a/services/protocols/internal/domain/entity/dmarc_report.go +++ b/services/protocols/internal/domain/entity/dmarc_report.go @@ -10,12 +10,12 @@ import ( ) type DmarcRecord struct { - SourceIP string - Count int - Disposition string - DKIMResult string - SPFResult string - HeaderFrom string + SourceIP string + Count int + Disposition string + DKIMResult string + SPFResult string + HeaderFrom string } type DmarcReport struct { @@ -41,8 +41,8 @@ type dmarcXmlReport struct { } `xml:"policy_published"` Record []struct { Row struct { - SourceIP string `xml:"source_ip"` - Count int `xml:"count"` + SourceIP string `xml:"source_ip"` + Count int `xml:"count"` PolicyEvaluated struct { Disposition string `xml:"disposition"` DKIM string `xml:"dkim"` diff --git a/services/protocols/internal/domain/entity/dns_record.go b/services/protocols/internal/domain/entity/dns_record.go index 1af98d7..cc9ccdf 100644 --- a/services/protocols/internal/domain/entity/dns_record.go +++ b/services/protocols/internal/domain/entity/dns_record.go @@ -1,9 +1,10 @@ package entity import ( + "crypto/sha256" + "encoding/binary" "fmt" "strings" - "time" ) type DnsRecord struct { @@ -17,8 +18,34 @@ type DnsRecord struct { Comment string } -// Build13DnsRecordSpecs generates the 13 mandatory DNS record specifications per PLAN.md section 7. -func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkimPubKey, edDkimPubKey, tlsaHash string, daneEnabled bool) []DnsRecord { +// DnsRecordSpec describes the zone the reconciler should converge on. +type DnsRecordSpec struct { + DomainName string + MailHost string + ServerIPv4 string + ServerIPv6 string + RsaDkimPubKey string + EdDkimPubKey string + // TlsaHashes are the DANE associations to publish. Two are normally + // present during a rollover: the current key and the next one + // (RFC 7671 section 8.1). + TlsaHashes []string + DaneEnabled bool + // RelaySpfInclude is the mechanism authorising a smarthost to send for + // this domain. Without it every message the relay forwards fails SPF + // (PLAN.md section 10.4). + RelaySpfInclude string +} + +// BuildDnsRecordSpecs generates the DNS records of PLAN.md section 7: the 13 +// numbered ones of section 7.1 plus the client-autoconfiguration records of +// section 7.2. +func BuildDnsRecordSpecs(spec DnsRecordSpec) []DnsRecord { + domainName := spec.DomainName + mailHost := spec.MailHost + serverIPv4, serverIPv6 := spec.ServerIPv4, spec.ServerIPv6 + rsaDkimPubKey, edDkimPubKey := spec.RsaDkimPubKey, spec.EdDkimPubKey + if mailHost == "" { mailHost = fmt.Sprintf("mail.%s", domainName) } @@ -46,7 +73,7 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim { Type: "TXT", Name: domainName, - Value: fmt.Sprintf("v=spf1 mx a:%s -all", mailHost), + Value: buildSpfRecord(mailHost, spec.RelaySpfInclude), TTL: 1, Comment: "LambdaMail SPF Record", }, @@ -78,7 +105,7 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim { Type: "TXT", Name: fmt.Sprintf("_mta-sts.%s", domainName), - Value: fmt.Sprintf("v=STSv1; id=%d", time.Now().Unix()), + Value: fmt.Sprintf("v=STSv1; id=%s", mtaStsPolicyID(domainName, mailHost)), TTL: 1, Comment: "LambdaMail MTA-STS Policy Version", }, @@ -117,6 +144,33 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim Priority: intPtr(0), Comment: "LambdaMail Submissions Autoconfig", }, + // The remaining client-autoconfiguration records of PLAN.md section + // 7.2. They are unnumbered there because they are conveniences, but + // the HTTPS endpoints of section 7.3 are unreachable without them. + { + Type: "SRV", + Name: fmt.Sprintf("_pop3s._tcp.%s", domainName), + Value: fmt.Sprintf("0 0 995 %s", mailHost), + TTL: 1, + Priority: intPtr(0), + Comment: "LambdaMail POP3S Autoconfig", + }, + { + Type: "CNAME", + Name: fmt.Sprintf("autoconfig.%s", domainName), + Value: mailHost, + TTL: 1, + Proxied: true, + Comment: "LambdaMail Thunderbird Autoconfig Endpoint", + }, + { + Type: "SRV", + Name: fmt.Sprintf("_autodiscover._tcp.%s", domainName), + Value: fmt.Sprintf("0 0 443 %s", mailHost), + TTL: 1, + Priority: intPtr(0), + Comment: "LambdaMail Outlook Autodiscover", + }, } // 2. Conditional AAAA record if IPv6 is supplied @@ -131,24 +185,53 @@ func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkim }) } - // 11. Conditional TLSA record if DANE is enabled - if daneEnabled && tlsaHash != "" { - records = append(records, DnsRecord{ - Type: "TLSA", - Name: fmt.Sprintf("_25._tcp.%s", mailHost), - Value: fmt.Sprintf("3 1 1 %s", tlsaHash), - TTL: 1, - Comment: "LambdaMail DANE TLSA Record", - }) + // 11. Conditional TLSA records if DANE is enabled. More than one is normal + // during a rollover: the current association and the next one are both + // published so the certificate can change without a gap (RFC 7671 8.1). + if spec.DaneEnabled { + for _, hash := range spec.TlsaHashes { + if hash == "" { + continue + } + records = append(records, DnsRecord{ + Type: "TLSA", + Name: fmt.Sprintf("_25._tcp.%s", mailHost), + Value: fmt.Sprintf("3 1 1 %s", hash), + TTL: 1, + Comment: "LambdaMail DANE TLSA Record", + }) + } } return records } +// buildSpfRecord assembles the sender policy. PLAN.md section 5 uses a hard +// fail, which is only safe once every legitimate sending path is listed - so +// the relay, when there is one, has to be included here. +func buildSpfRecord(mailHost, relayInclude string) string { + mechanisms := []string{"v=spf1", "mx", "a:" + mailHost} + if relayInclude != "" { + mechanisms = append(mechanisms, relayInclude) + } + return strings.Join(append(mechanisms, "-all"), " ") +} + func intPtr(v int) *int { return &v } +// mtaStsPolicyID derives the MTA-STS policy id from the policy's own content +// rather than from the wall clock. RFC 8461 section 3.1 requires the id to +// change whenever the served policy changes; PLAN.md section 7.5 requires the +// reconciler to be fully idempotent. A timestamp satisfies the first and +// violates the second - every sync would rewrite the record and force every +// sender to re-fetch. Hashing the inputs that define the policy satisfies both. +func mtaStsPolicyID(domainName, mailHost string) string { + sum := sha256.Sum256([]byte(domainName + "\x00" + mailHost)) + return fmt.Sprintf("%016x", binary.BigEndian.Uint64(sum[:8])) +} + // EqualsNormalized compares two DNS records ignoring subtle formatting whitespace/case differences. func (r DnsRecord) EqualsNormalized(other DnsRecord) bool { if r.Type != other.Type { @@ -163,5 +246,13 @@ func (r DnsRecord) EqualsNormalized(other DnsRecord) bool { if r.Proxied != other.Proxied { return false } + // MX and SRV priority drift routes mail to the wrong host, so it counts as + // a difference even though the record value itself matches. + if (r.Priority == nil) != (other.Priority == nil) { + return false + } + if r.Priority != nil && *r.Priority != *other.Priority { + return false + } return true } diff --git a/services/protocols/internal/domain/entity/dns_record_test.go b/services/protocols/internal/domain/entity/dns_record_test.go index 440695e..25b1427 100644 --- a/services/protocols/internal/domain/entity/dns_record_test.go +++ b/services/protocols/internal/domain/entity/dns_record_test.go @@ -4,21 +4,43 @@ import ( "testing" ) -func TestBuild13DnsRecordSpecs_GeneratesMandatoryRecords(t *testing.T) { - specs := Build13DnsRecordSpecs( - "example.test", - "mail.example.test", - "192.0.2.1", - "2001:db8::1", - "rsaPubKeyBase64", - "edPubKeyBase64", - "hash123", - true, - ) +func TestBuildDnsRecordSpecs_GeneratesMandatoryRecords(t *testing.T) { + specs := BuildDnsRecordSpecs(DnsRecordSpec{ + DomainName: "example.test", + MailHost: "mail.example.test", + ServerIPv4: "192.0.2.1", + ServerIPv6: "2001:db8::1", + RsaDkimPubKey: "rsaPubKeyBase64", + EdDkimPubKey: "edPubKeyBase64", + TlsaHashes: []string{"hash123"}, + DaneEnabled: true, + }) - // Should contain 13 records (11 mandatory + 1 AAAA + 1 TLSA) - if len(specs) != 13 { - t.Fatalf("expected 13 records, got %d", len(specs)) + // PLAN.md section 7.1 numbers 13 records (11 always-on, plus AAAA when + // IPv6 is configured and TLSA when DANE is on). Section 7.2 adds three + // unnumbered client-autoconfiguration records: the _pop3s SRV, the + // autoconfig CNAME and the _autodiscover SRV. + const numberedRecords, convenienceRecords = 13, 3 + if len(specs) != numberedRecords+convenienceRecords { + t.Fatalf("expected %d records, got %d", numberedRecords+convenienceRecords, len(specs)) + } + + // The HTTPS endpoints of section 7.3 are unreachable without the names + // that point at them. + for _, required := range []struct{ recType, name string }{ + {"CNAME", "autoconfig.example.test"}, + {"SRV", "_pop3s._tcp.example.test"}, + {"SRV", "_autodiscover._tcp.example.test"}, + } { + found := false + for _, r := range specs { + if r.Type == required.recType && r.Name == required.name { + found = true + } + } + if !found { + t.Errorf("%s %s not found in specs", required.recType, required.name) + } } // Verify MX diff --git a/services/protocols/internal/domain/entity/mailbox.go b/services/protocols/internal/domain/entity/mailbox.go index ec65611..66654ff 100644 --- a/services/protocols/internal/domain/entity/mailbox.go +++ b/services/protocols/internal/domain/entity/mailbox.go @@ -24,7 +24,7 @@ func NewMailbox(domainID uuid.UUID, address valueobject.EmailAddress, hash value func (m *Mailbox) Address() valueobject.EmailAddress { return m.address } func (m *Mailbox) UsedBytes() int64 { return m.usedBytes } -func (m *Mailbox) Quota() valueobject.QuotaLimit { return m.quota } +func (m *Mailbox) Quota() valueobject.QuotaLimit { return m.quota } // RecordUsage sets used_bytes, enforcing "used_bytes <= quota_bytes" (PLAN.md section 3). func (m *Mailbox) RecordUsage(bytes int64) error { diff --git a/services/protocols/internal/domain/entity/outbound_job.go b/services/protocols/internal/domain/entity/outbound_job.go index 4684462..ee2c78e 100644 --- a/services/protocols/internal/domain/entity/outbound_job.go +++ b/services/protocols/internal/domain/entity/outbound_job.go @@ -36,15 +36,23 @@ type OutboundJob struct { CreatedAt time.Time } -// CalculateNextBackoff computes the next retry time and DSN requirements based on attempt count. -func CalculateNextBackoff(now time.Time, attempt int, expiresAt time.Time) (nextAttemptAt time.Time, sendDelayDsn bool, permanentFailure bool) { +// CalculateNextBackoff computes the next retry time and DSN requirements from +// the attempt that has just failed. The delays follow PLAN.md section 6.3, +// which tabulates the wait *before* each attempt: attempt 2 runs 5 min after +// attempt 1 failed, attempt 4 an hour later and also emits the delay DSN, and +// from attempt 5 on the wait carries +/-20% jitter to avoid a thundering herd. +func CalculateNextBackoff(now time.Time, failedAttempt int, expiresAt time.Time) (nextAttemptAt time.Time, sendDelayDsn bool, permanentFailure bool) { if !expiresAt.IsZero() && (now.After(expiresAt) || now.Equal(expiresAt)) { return now, false, true } + // The delay to apply is the one the retry table associates with the + // attempt about to be scheduled, not with the one that just failed. + nextAttempt := failedAttempt + 1 + var baseDelay time.Duration - switch attempt { + switch nextAttempt { case 1: baseDelay = 0 case 2: @@ -64,8 +72,8 @@ func CalculateNextBackoff(now time.Time, attempt int, expiresAt time.Time) (next baseDelay = 12 * time.Hour } - if baseDelay > 0 && attempt >= 5 { - // Apply ±20% jitter + if baseDelay > 0 && nextAttempt >= 5 { + // Apply +/-20% jitter jitterFactor := 0.8 + rand.Float64()*0.4 baseDelay = time.Duration(float64(baseDelay) * jitterFactor) } diff --git a/services/protocols/internal/domain/entity/outbound_job_test.go b/services/protocols/internal/domain/entity/outbound_job_test.go index ceab529..8229152 100644 --- a/services/protocols/internal/domain/entity/outbound_job_test.go +++ b/services/protocols/internal/domain/entity/outbound_job_test.go @@ -5,26 +5,50 @@ import ( "time" ) -func TestCalculateNextBackoff_SchedulesCorrectDelays(t *testing.T) { +// PLAN.md section 6.3 defines the wait *before the next* attempt: after +// attempt 1 fails the next try is +5 min, after attempt 3 it is +1 h together +// with the delay DSN, and from the fifth try on the delay carries +/-20% +// jitter. A zero delay after the first failure would hammer the destination in +// a tight loop. +func TestCalculateNextBackoff_FollowsRetryTable(t *testing.T) { now := time.Now() expiresAt := now.Add(5 * 24 * time.Hour) - // Attempt 2 -> +5 min - next, delayDsn, perm := CalculateNextBackoff(now, 2, expiresAt) - if perm || delayDsn || next.Sub(now) < 4*time.Minute { - t.Errorf("attempt 2 backoff mismatch: next=%v, delayDsn=%v, perm=%v", next, delayDsn, perm) + cases := []struct { + failedAttempt int + wantMin time.Duration + wantMax time.Duration + wantDelayDsn bool + }{ + {1, 5 * time.Minute, 5 * time.Minute, false}, + {2, 15 * time.Minute, 15 * time.Minute, false}, + {3, time.Hour, time.Hour, true}, + {4, 96 * time.Minute, 144 * time.Minute, false}, // 2 h +/- 20% + {5, 192 * time.Minute, 288 * time.Minute, false}, // 4 h +/- 20% + {6, 384 * time.Minute, 576 * time.Minute, false}, // 8 h +/- 20% + {7, 576 * time.Minute, 864 * time.Minute, false}, // 12 h +/- 20% + {20, 576 * time.Minute, 864 * time.Minute, false}, // steady 12 h } - // Attempt 4 -> +1 hr + delayDsn=true - next, delayDsn, perm = CalculateNextBackoff(now, 4, expiresAt) - if perm || !delayDsn || next.Sub(now) < 55*time.Minute { - t.Errorf("attempt 4 backoff mismatch: next=%v, delayDsn=%v, perm=%v", next, delayDsn, perm) + for _, tc := range cases { + next, delayDsn, perm := CalculateNextBackoff(now, tc.failedAttempt, expiresAt) + if perm { + t.Errorf("attempt %d: unexpected permanent failure", tc.failedAttempt) + continue + } + got := next.Sub(now) + if got < tc.wantMin || got > tc.wantMax { + t.Errorf("attempt %d: delay = %v, want between %v and %v", tc.failedAttempt, got, tc.wantMin, tc.wantMax) + } + if delayDsn != tc.wantDelayDsn { + t.Errorf("attempt %d: delayDsn = %v, want %v", tc.failedAttempt, delayDsn, tc.wantDelayDsn) + } } +} - // Expired -> permanent failure - expiredTime := now.Add(-1 * time.Hour) - _, _, perm = CalculateNextBackoff(now, 5, expiredTime) - if !perm { - t.Errorf("expected permanent failure for expired job") +func TestCalculateNextBackoff_ExpiredJobIsPermanent(t *testing.T) { + now := time.Now() + if _, _, perm := CalculateNextBackoff(now, 5, now.Add(-time.Hour)); !perm { + t.Error("expected permanent failure for an expired job") } } diff --git a/services/protocols/internal/domain/entity/sieve_validator.go b/services/protocols/internal/domain/entity/sieve_validator.go index b5f5f9e..032800c 100644 --- a/services/protocols/internal/domain/entity/sieve_validator.go +++ b/services/protocols/internal/domain/entity/sieve_validator.go @@ -7,10 +7,10 @@ import ( ) var ( - ErrEmptyScript = errors.New("sieve script cannot be empty") - ErrUnmatchedBrace = errors.New("unmatched curly brace in sieve script") - ErrUnmatchedBracket = errors.New("unmatched square bracket in sieve script") - ErrUnterminatedQuote = errors.New("unterminated string literal in sieve script") + ErrEmptyScript = errors.New("sieve script cannot be empty") + ErrUnmatchedBrace = errors.New("unmatched curly brace in sieve script") + ErrUnmatchedBracket = errors.New("unmatched square bracket in sieve script") + ErrUnterminatedQuote = errors.New("unterminated string literal in sieve script") ErrUnterminatedComment = errors.New("unterminated comment block in sieve script") ) diff --git a/services/protocols/internal/domain/entity/sieve_validator_test.go b/services/protocols/internal/domain/entity/sieve_validator_test.go index e27e87c..8928ac9 100644 --- a/services/protocols/internal/domain/entity/sieve_validator_test.go +++ b/services/protocols/internal/domain/entity/sieve_validator_test.go @@ -32,8 +32,8 @@ if size :over 1M { func TestValidateSieveScript_InvalidScripts(t *testing.T) { tests := []struct { - name string - script string + name string + script string wantErr error }{ {"empty", "", ErrEmptyScript}, diff --git a/services/protocols/internal/domain/entity/tls_policy.go b/services/protocols/internal/domain/entity/tls_policy.go index fedb7c0..82a4702 100644 --- a/services/protocols/internal/domain/entity/tls_policy.go +++ b/services/protocols/internal/domain/entity/tls_policy.go @@ -1,25 +1,155 @@ package entity -// TLSPolicy is the effective transport security policy for an outbound destination -// (PLAN.md section 3: DANE has precedence over MTA-STS, RFC 8461 section 2). +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "strings" + + "lambdamail/protocols/internal/domain/valueobject" +) + +// Transport security modes, in the precedence order of RFC 8461 section 2: +// DANE wins when TLSA records exist, MTA-STS applies otherwise, and a +// destination with neither gets opportunistic TLS (RFC 7435). +const ( + TLSModeDane = "dane" + TLSModeMtaSts = "mta-sts" + TLSModeOpportunistic = "opportunistic" + // TLSModeRelay records that the message left through a smarthost, which + // owns transport security from that point on (PLAN.md section 10.4). + TLSModeRelay = "relay" +) + +// TLSPolicy is the effective transport security policy for one outbound +// destination (PLAN.md sections 5 and 6.2). type TLSPolicy struct { daneAvailable bool mtaSTSAvailable bool + + // enforce is true when a validation failure must defer the message + // instead of falling back to an unvalidated connection. It is false for + // MTA-STS "testing", whose whole purpose is to report without breaking. + enforce bool + + // mxPatterns are the "mx:" entries of the MTA-STS policy. A host that + // matches none of them is not covered by the policy. + mxPatterns []string + + // tlsaRecords are the DANE-EE associations published for the target host. + tlsaRecords []valueobject.TLSARecord } +// NewTLSPolicy builds an opportunistic-or-simple policy. It is kept for +// callers that only need the precedence decision. func NewTLSPolicy(daneAvailable, mtaSTSAvailable bool) TLSPolicy { return TLSPolicy{daneAvailable: daneAvailable, mtaSTSAvailable: mtaSTSAvailable} } -// Effective returns "dane", "mta-sts" or "opportunistic" following the precedence -// rule in PLAN.md section 5 / RFC 8461 section 2. +// NewDanePolicy builds a policy backed by TLSA records. DANE is always +// enforcing: the records are DNSSEC-signed, so a mismatch is an attack or a +// misconfiguration, never an acceptable downgrade (RFC 7672 section 2.2). +func NewDanePolicy(records []valueobject.TLSARecord) TLSPolicy { + return TLSPolicy{daneAvailable: true, enforce: true, tlsaRecords: records} +} + +// NewMtaStsPolicy builds a policy from a fetched MTA-STS policy file. +func NewMtaStsPolicy(mxPatterns []string, enforce bool) TLSPolicy { + return TLSPolicy{mtaSTSAvailable: true, enforce: enforce, mxPatterns: mxPatterns} +} + +// Effective returns "dane", "mta-sts" or "opportunistic" following the +// precedence rule in PLAN.md section 5 / RFC 8461 section 2. func (p TLSPolicy) Effective() string { switch { case p.daneAvailable: - return "dane" + return TLSModeDane case p.mtaSTSAvailable: - return "mta-sts" + return TLSModeMtaSts default: - return "opportunistic" + return TLSModeOpportunistic + } +} + +// RequiresValidation reports whether the connection's certificate has to be +// checked before any mail is handed over. +func (p TLSPolicy) RequiresValidation() bool { + return p.enforce && (p.daneAvailable || p.mtaSTSAvailable) +} + +// CoversHost reports whether a candidate MX is inside the policy's scope. An +// MTA-STS policy that does not list the host means the host must not be used +// under that policy (RFC 8461 section 4.1). +func (p TLSPolicy) CoversHost(host string) bool { + if !p.mtaSTSAvailable { + return true + } + for _, pattern := range p.mxPatterns { + if matchMxPattern(pattern, host) { + return true + } + } + return false +} + +// matchMxPattern implements the single-label wildcard of RFC 8461 section 4.1: +// "*.example.com" matches "mx1.example.com" but not "a.mx1.example.com". +func matchMxPattern(pattern, host string) bool { + pattern = strings.ToLower(strings.TrimSuffix(pattern, ".")) + host = strings.ToLower(strings.TrimSuffix(host, ".")) + + if !strings.HasPrefix(pattern, "*.") { + return pattern == host + } + + suffix := pattern[1:] // ".example.com" + if !strings.HasSuffix(host, suffix) { + return false + } + label := strings.TrimSuffix(host, suffix) + return label != "" && !strings.Contains(label, ".") +} + +// MatchesCertificate checks a presented certificate chain against the DANE +// associations. Only usage 3 (DANE-EE) is supported by explicit decision +// (PLAN.md section 5.1), so the end-entity certificate is what gets matched +// and the PKIX chain is irrelevant. +func (p TLSPolicy) MatchesCertificate(chain []*x509.Certificate) bool { + if len(p.tlsaRecords) == 0 || len(chain) == 0 { + return false + } + + leaf := chain[0] + for _, record := range p.tlsaRecords { + if digestFor(leaf, record) == strings.ToLower(record.Data()) { + return true + } + } + return false +} + +// digestFor renders the certificate the way a TLSA record's selector and +// matching type describe it (RFC 6698 section 2.1.2). +func digestFor(cert *x509.Certificate, record valueobject.TLSARecord) string { + var material []byte + switch record.Selector() { + case 0: // full certificate + material = cert.Raw + case 1: // SubjectPublicKeyInfo + material = cert.RawSubjectPublicKeyInfo + default: + return "" + } + + switch record.MatchingType() { + case 0: // exact match on the material itself + return strings.ToLower(hex.EncodeToString(material)) + case 1: // SHA-256 + sum := sha256.Sum256(material) + return hex.EncodeToString(sum[:]) + default: + // SHA-512 (type 2) is deliberately unsupported: the reconciler only + // ever publishes "3 1 1" records (PLAN.md section 7.1). + return "" } } diff --git a/services/protocols/internal/domain/event/events.go b/services/protocols/internal/domain/event/events.go index ad44111..f0ed2b6 100644 --- a/services/protocols/internal/domain/event/events.go +++ b/services/protocols/internal/domain/event/events.go @@ -13,75 +13,75 @@ type DomainEvent interface { type EmailReceived struct{ MessageAggregateID uuid.UUID } -func (e EmailReceived) Type() string { return "EmailReceived" } +func (e EmailReceived) Type() string { return "EmailReceived" } func (e EmailReceived) AggregateID() uuid.UUID { return e.MessageAggregateID } type EmailAccepted struct{ MessageAggregateID uuid.UUID } -func (e EmailAccepted) Type() string { return "EmailAccepted" } +func (e EmailAccepted) Type() string { return "EmailAccepted" } func (e EmailAccepted) AggregateID() uuid.UUID { return e.MessageAggregateID } type EmailDelivered struct{ JobAggregateID uuid.UUID } -func (e EmailDelivered) Type() string { return "EmailDelivered" } +func (e EmailDelivered) Type() string { return "EmailDelivered" } func (e EmailDelivered) AggregateID() uuid.UUID { return e.JobAggregateID } type EmailDeferred struct{ JobAggregateID uuid.UUID } -func (e EmailDeferred) Type() string { return "EmailDeferred" } +func (e EmailDeferred) Type() string { return "EmailDeferred" } func (e EmailDeferred) AggregateID() uuid.UUID { return e.JobAggregateID } type EmailBounced struct{ JobAggregateID uuid.UUID } -func (e EmailBounced) Type() string { return "EmailBounced" } +func (e EmailBounced) Type() string { return "EmailBounced" } func (e EmailBounced) AggregateID() uuid.UUID { return e.JobAggregateID } type MailboxQuotaWarning struct{ MailboxAggregateID uuid.UUID } -func (e MailboxQuotaWarning) Type() string { return "MailboxQuotaWarning" } +func (e MailboxQuotaWarning) Type() string { return "MailboxQuotaWarning" } func (e MailboxQuotaWarning) AggregateID() uuid.UUID { return e.MailboxAggregateID } type MailboxQuotaExceeded struct{ MailboxAggregateID uuid.UUID } -func (e MailboxQuotaExceeded) Type() string { return "MailboxQuotaExceeded" } +func (e MailboxQuotaExceeded) Type() string { return "MailboxQuotaExceeded" } func (e MailboxQuotaExceeded) AggregateID() uuid.UUID { return e.MailboxAggregateID } type SpamDetected struct{ MessageAggregateID uuid.UUID } -func (e SpamDetected) Type() string { return "SpamDetected" } +func (e SpamDetected) Type() string { return "SpamDetected" } func (e SpamDetected) AggregateID() uuid.UUID { return e.MessageAggregateID } type VirusDetected struct{ MessageAggregateID uuid.UUID } -func (e VirusDetected) Type() string { return "VirusDetected" } +func (e VirusDetected) Type() string { return "VirusDetected" } func (e VirusDetected) AggregateID() uuid.UUID { return e.MessageAggregateID } type DkimRotationStarted struct{ DomainAggregateID uuid.UUID } -func (e DkimRotationStarted) Type() string { return "DkimRotationStarted" } +func (e DkimRotationStarted) Type() string { return "DkimRotationStarted" } func (e DkimRotationStarted) AggregateID() uuid.UUID { return e.DomainAggregateID } type DkimRotationCompleted struct{ DomainAggregateID uuid.UUID } -func (e DkimRotationCompleted) Type() string { return "DkimRotationCompleted" } +func (e DkimRotationCompleted) Type() string { return "DkimRotationCompleted" } func (e DkimRotationCompleted) AggregateID() uuid.UUID { return e.DomainAggregateID } type DnsRecordDrifted struct{ DomainAggregateID uuid.UUID } -func (e DnsRecordDrifted) Type() string { return "DnsRecordDrifted" } +func (e DnsRecordDrifted) Type() string { return "DnsRecordDrifted" } func (e DnsRecordDrifted) AggregateID() uuid.UUID { return e.DomainAggregateID } type CertificateRotated struct{ HostAggregateID uuid.UUID } -func (e CertificateRotated) Type() string { return "CertificateRotated" } +func (e CertificateRotated) Type() string { return "CertificateRotated" } func (e CertificateRotated) AggregateID() uuid.UUID { return e.HostAggregateID } type AuthenticationFailed struct{ MailboxAggregateID uuid.UUID } -func (e AuthenticationFailed) Type() string { return "AuthenticationFailed" } +func (e AuthenticationFailed) Type() string { return "AuthenticationFailed" } func (e AuthenticationFailed) AggregateID() uuid.UUID { return e.MailboxAggregateID } type RateLimitTriggered struct{ SubjectAggregateID uuid.UUID } -func (e RateLimitTriggered) Type() string { return "RateLimitTriggered" } +func (e RateLimitTriggered) Type() string { return "RateLimitTriggered" } func (e RateLimitTriggered) AggregateID() uuid.UUID { return e.SubjectAggregateID } diff --git a/services/protocols/internal/domain/valueobject/domain_name.go b/services/protocols/internal/domain/valueobject/domain_name.go index 6903d4d..3e1a59b 100644 --- a/services/protocols/internal/domain/valueobject/domain_name.go +++ b/services/protocols/internal/domain/valueobject/domain_name.go @@ -11,9 +11,9 @@ type DomainName struct { } var ( - ErrDomainNameEmpty = errors.New("domain name: must not be empty") - ErrDomainNameNotFQDN = errors.New("domain name: must contain at least one dot (not a FQDN)") - ErrDomainNameInvalidLabel = errors.New("domain name: label must not start or end with a hyphen") + ErrDomainNameEmpty = errors.New("domain name: must not be empty") + ErrDomainNameNotFQDN = errors.New("domain name: must contain at least one dot (not a FQDN)") + ErrDomainNameInvalidLabel = errors.New("domain name: label must not start or end with a hyphen") ) // NewDomainName validates and normalizes raw into a DomainName. diff --git a/services/protocols/internal/domain/valueobject/email_address.go b/services/protocols/internal/domain/valueobject/email_address.go index 8df8845..1baa10d 100644 --- a/services/protocols/internal/domain/valueobject/email_address.go +++ b/services/protocols/internal/domain/valueobject/email_address.go @@ -14,9 +14,9 @@ type EmailAddress struct { } var ( - ErrEmailMissingAtSign = errors.New("email address: missing '@' separator") - ErrEmailEmptyLocalPart = errors.New("email address: local part must not be empty") - ErrEmailEmptyDomain = errors.New("email address: domain must not be empty") + ErrEmailMissingAtSign = errors.New("email address: missing '@' separator") + ErrEmailEmptyLocalPart = errors.New("email address: local part must not be empty") + ErrEmailEmptyDomain = errors.New("email address: domain must not be empty") ErrEmailLocalPartTooLong = errors.New("email address: local part exceeds 64 octets (RFC 5321 4.5.3.1.1)") ) diff --git a/services/protocols/internal/domain/valueobject/message_id.go b/services/protocols/internal/domain/valueobject/message_id.go index 8c728f4..28cc630 100644 --- a/services/protocols/internal/domain/valueobject/message_id.go +++ b/services/protocols/internal/domain/valueobject/message_id.go @@ -11,7 +11,7 @@ type MessageID struct { } var ( - ErrMessageIDEmpty = errors.New("message id: must not be empty") + ErrMessageIDEmpty = errors.New("message id: must not be empty") ErrMessageIDNotBracketed = errors.New("message id: must be wrapped in angle brackets") ) diff --git a/services/protocols/internal/domain/valueobject/spam_verdict_test.go b/services/protocols/internal/domain/valueobject/spam_verdict_test.go index f23175f..6a9e913 100644 --- a/services/protocols/internal/domain/valueobject/spam_verdict_test.go +++ b/services/protocols/internal/domain/valueobject/spam_verdict_test.go @@ -12,7 +12,7 @@ func TestSpamVerdictFromScore_MatchesThresholds(t *testing.T) { {3.9, SpamHam}, {4, SpamProbable}, {8.9, SpamProbable}, - {9, SpamReject}, // section 10.1: 9-15 greylists/defers, modeled here as reject-eligible + {9, SpamReject}, // section 10.1: 9-15 greylists/defers, modeled here as reject-eligible {15.1, SpamReject}, } for _, c := range cases { diff --git a/services/protocols/internal/e2e/antispam_antivirus_e2e_test.go b/services/protocols/internal/e2e/antispam_antivirus_e2e_test.go index 25915b0..5d5547e 100644 --- a/services/protocols/internal/e2e/antispam_antivirus_e2e_test.go +++ b/services/protocols/internal/e2e/antispam_antivirus_e2e_test.go @@ -6,14 +6,12 @@ import ( "net" "net/http" "net/http/httptest" - "os" - "path/filepath" "strings" "testing" "time" - embeddedpostgres "github.com/fergusstrange/embedded-postgres" gosmtp "github.com/emersion/go-smtp" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" "github.com/google/uuid" appusecase "lambdamail/protocols/internal/application/usecase" @@ -47,22 +45,7 @@ func TestAntispamAntivirusEndToEnd(t *testing.T) { } defer pool.Close() - root := repoRoot(t) - migrations := []string{ - "0001_init_schema.up.sql", - "0002_add_is_system_to_aliases.up.sql", - "0003_create_report_tables.up.sql", - } - - for _, m := range migrations { - sql, err := os.ReadFile(filepath.Join(root, "migrations", m)) - if err != nil { - t.Fatalf("read migration %s: %v", m, err) - } - if _, err := pool.Exec(ctx, string(sql)); err != nil { - t.Fatalf("apply migration %s: %v", m, err) - } - } + applyMigrations(t, ctx, pool) // Seed domain, mailbox, INBOX and Junk folders domainID := uuid.New() diff --git a/services/protocols/internal/e2e/dns_sync_e2e_test.go b/services/protocols/internal/e2e/dns_sync_e2e_test.go index 11a7d90..ea1fffe 100644 --- a/services/protocols/internal/e2e/dns_sync_e2e_test.go +++ b/services/protocols/internal/e2e/dns_sync_e2e_test.go @@ -2,13 +2,11 @@ package e2e import ( "context" - "os" - "path/filepath" "testing" "time" - "github.com/google/uuid" embeddedpostgres "github.com/fergusstrange/embedded-postgres" + "github.com/google/uuid" appusecase "lambdamail/protocols/internal/application/usecase" "lambdamail/protocols/internal/domain/entity" @@ -38,21 +36,7 @@ func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) { } defer pool.Close() - root := repoRoot(t) - migrations := []string{ - "0001_init_schema.up.sql", - "0002_add_is_system_to_aliases.up.sql", - "0003_create_report_tables.up.sql", - } - for _, m := range migrations { - sql, err := os.ReadFile(filepath.Join(root, "migrations", m)) - if err != nil { - t.Fatalf("read migration %s: %v", m, err) - } - if _, err := pool.Exec(ctx, string(sql)); err != nil { - t.Fatalf("apply migration %s: %v", m, err) - } - } + applyMigrations(t, ctx, pool) // Seed domain & admin mailbox domainID := uuid.New() @@ -86,8 +70,11 @@ func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) { t.Fatalf("SyncDnsRecords execute: %v", err) } - if out.CreatedCount != 13 { - t.Errorf("CreatedCount = %d, want 13", out.CreatedCount) + // The 13 numbered records of PLAN.md section 7.1 plus the three + // client-autoconfiguration records of section 7.2. + const expectedRecords = 16 + if out.CreatedCount != expectedRecords { + t.Errorf("CreatedCount = %d, want %d", out.CreatedCount, expectedRecords) } // Verify 4 system aliases in PostgreSQL (postmaster@, abuse@, dmarc@, tlsrpt@) diff --git a/services/protocols/internal/e2e/f2_complete_e2e_test.go b/services/protocols/internal/e2e/f2_complete_e2e_test.go index 24ee8e2..f08f705 100644 --- a/services/protocols/internal/e2e/f2_complete_e2e_test.go +++ b/services/protocols/internal/e2e/f2_complete_e2e_test.go @@ -7,8 +7,6 @@ import ( "encoding/base64" "fmt" "net" - "os" - "path/filepath" "strings" "testing" "time" @@ -52,21 +50,7 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) { } defer pool.Close() - root := repoRoot(t) - migrations := []string{ - "0001_init_schema.up.sql", - "0002_add_is_system_to_aliases.up.sql", - "0003_create_report_tables.up.sql", - } - for _, m := range migrations { - sql, err := os.ReadFile(filepath.Join(root, "migrations", m)) - if err != nil { - t.Fatalf("read migration %s: %v", m, err) - } - if _, err := pool.Exec(ctx, string(sql)); err != nil { - t.Fatalf("apply migration %s: %v", m, err) - } - } + applyMigrations(t, ctx, pool) // 2. Seed domain, mailbox, INBOX, Archive password := "correct horse battery staple" @@ -443,7 +427,7 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) { t.Fatalf("SETACTIVE = %q", activeResp) } - // Verify RFC 5804 §2.7: DELETESCRIPT on active script MUST fail + // Verify RFC 5804 section 2.7: DELETESCRIPT on active script MUST fail sendCmdSieve("DELETESCRIPT \"auto_rules\"") if delActiveResp := readLineSieve(); !strings.HasPrefix(delActiveResp, "NO") { t.Fatalf("expected NO for deleting active script, got %q", delActiveResp) diff --git a/services/protocols/internal/e2e/f3_complete_e2e_test.go b/services/protocols/internal/e2e/f3_complete_e2e_test.go index a56321d..a43de09 100644 --- a/services/protocols/internal/e2e/f3_complete_e2e_test.go +++ b/services/protocols/internal/e2e/f3_complete_e2e_test.go @@ -8,8 +8,6 @@ import ( "net" "net/http" "net/http/httptest" - "os" - "path/filepath" "strings" "testing" "time" @@ -19,12 +17,14 @@ import ( appusecase "lambdamail/protocols/internal/application/usecase" "lambdamail/protocols/internal/domain/entity" + "lambdamail/protocols/internal/domain/valueobject" "lambdamail/protocols/internal/infrastructure/clamav" "lambdamail/protocols/internal/infrastructure/diskstorage" "lambdamail/protocols/internal/infrastructure/postgres" "lambdamail/protocols/internal/infrastructure/rspamd" httppresentation "lambdamail/protocols/internal/presentation/http" smtppresentation "lambdamail/protocols/internal/presentation/smtp" + gosmtp "github.com/emersion/go-smtp" ) @@ -96,22 +96,7 @@ func TestPhase3CompleteEndToEnd(t *testing.T) { defer pool.Close() // Apply Database Migrations - root := repoRoot(t) - migrations := []string{ - "0001_init_schema.up.sql", - "0002_add_is_system_to_aliases.up.sql", - "0003_create_report_tables.up.sql", - } - - for _, m := range migrations { - sql, err := os.ReadFile(filepath.Join(root, "migrations", m)) - if err != nil { - t.Fatalf("read migration %s: %v", m, err) - } - if _, err := pool.Exec(ctx, string(sql)); err != nil { - t.Fatalf("apply migration %s: %v", m, err) - } - } + applyMigrations(t, ctx, pool) // ------------------------------------------------------------------- // 1. Sub-project 1: Cloudflare DNS Automation Engine & System Aliases @@ -156,8 +141,14 @@ func TestPhase3CompleteEndToEnd(t *testing.T) { if err != nil { t.Fatalf("sync dns records: %v", err) } - if dnsOut.CreatedCount != 13 { - t.Fatalf("expected 13 created DNS records, got %d", dnsOut.CreatedCount) + // The 13 numbered records of PLAN.md section 7.1 plus the three + // client-autoconfiguration records of section 7.2. + const expectedDnsRecords = 16 + if dnsOut.CreatedCount != expectedDnsRecords { + t.Fatalf("expected %d created DNS records, got %d", expectedDnsRecords, dnsOut.CreatedCount) + } + if dnsOut.Status != appusecase.SyncStatusVerified { + t.Errorf("sync status = %s, want VERIFIED", dnsOut.Status) } // Verify system aliases created @@ -174,14 +165,26 @@ func TestPhase3CompleteEndToEnd(t *testing.T) { reportUC := appusecase.NewIngestReportsUseCase(reportRepo) httpRouter := httppresentation.NewRouter(reportUC, func() error { return pool.Ping(ctx) }) - // Test MTA-STS endpoint + // Test MTA-STS endpoint. The default is the safe end of the ramp + // (PLAN.md section 5): senders honour the policy but do not hard-fail on + // it until the operator promotes the domain to enforce. reqSts := httptest.NewRequest("GET", "/.well-known/mta-sts.txt", nil) reqSts.Host = "mta-sts.f3e2e.test" recSts := httptest.NewRecorder() httpRouter.ServeHTTP(recSts, reqSts) - if recSts.Code != http.StatusOK || !strings.Contains(recSts.Body.String(), "mode: enforce") { + if recSts.Code != http.StatusOK || !strings.Contains(recSts.Body.String(), "mode: testing") { t.Errorf("MTA-STS endpoint error: code=%d body=%s", recSts.Code, recSts.Body.String()) } + if !strings.Contains(recSts.Body.String(), "mx: mail.f3e2e.test") { + t.Errorf("MTA-STS policy does not advertise the mail host: %s", recSts.Body.String()) + } + + httpRouter.SetMtaStsMode(valueobject.MtaStsModeEnforce) + recEnforced := httptest.NewRecorder() + httpRouter.ServeHTTP(recEnforced, httptest.NewRequest("GET", "/.well-known/mta-sts.txt", nil)) + if !strings.Contains(recEnforced.Body.String(), "mode: enforce") { + t.Errorf("promoting the domain to enforce had no effect: %s", recEnforced.Body.String()) + } // Test Autoconfig endpoint reqAuto := httptest.NewRequest("GET", "/.well-known/autoconfig/mail/config-v1.1.xml", nil) diff --git a/services/protocols/internal/e2e/http_services_e2e_test.go b/services/protocols/internal/e2e/http_services_e2e_test.go index 3f4ac95..944faf8 100644 --- a/services/protocols/internal/e2e/http_services_e2e_test.go +++ b/services/protocols/internal/e2e/http_services_e2e_test.go @@ -6,8 +6,6 @@ import ( "io" "net/http" "net/http/httptest" - "os" - "path/filepath" "strings" "testing" "time" @@ -42,22 +40,7 @@ func TestHttpServicesEndToEnd(t *testing.T) { } defer pool.Close() - root := repoRoot(t) - migrations := []string{ - "0001_init_schema.up.sql", - "0002_add_is_system_to_aliases.up.sql", - "0003_create_report_tables.up.sql", - } - - for _, m := range migrations { - sql, err := os.ReadFile(filepath.Join(root, "migrations", m)) - if err != nil { - t.Fatalf("read migration %s: %v", m, err) - } - if _, err := pool.Exec(ctx, string(sql)); err != nil { - t.Fatalf("apply migration %s: %v", m, err) - } - } + applyMigrations(t, ctx, pool) reportRepo := postgres.NewReportRepository(pool) reportUseCase := appusecase.NewIngestReportsUseCase(reportRepo) diff --git a/services/protocols/internal/e2e/imap_core_e2e_test.go b/services/protocols/internal/e2e/imap_core_e2e_test.go index a7270f8..46e234f 100644 --- a/services/protocols/internal/e2e/imap_core_e2e_test.go +++ b/services/protocols/internal/e2e/imap_core_e2e_test.go @@ -10,13 +10,13 @@ import ( "time" "github.com/alexedwards/argon2id" - embeddedpostgres "github.com/fergusstrange/embedded-postgres" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/go-imap/v2/imapserver" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" - appusecase "lambdamail/protocols/internal/application/usecase" "lambdamail/protocols/internal/application/port" + appusecase "lambdamail/protocols/internal/application/usecase" "lambdamail/protocols/internal/infrastructure/diskstorage" "lambdamail/protocols/internal/infrastructure/postgres" imappresentation "lambdamail/protocols/internal/presentation/imap" diff --git a/services/protocols/internal/e2e/imap_idle_condstore_e2e_test.go b/services/protocols/internal/e2e/imap_idle_condstore_e2e_test.go index ef1ab08..7a72922 100644 --- a/services/protocols/internal/e2e/imap_idle_condstore_e2e_test.go +++ b/services/protocols/internal/e2e/imap_idle_condstore_e2e_test.go @@ -10,13 +10,13 @@ import ( "time" "github.com/alexedwards/argon2id" - embeddedpostgres "github.com/fergusstrange/embedded-postgres" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/go-imap/v2/imapserver" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" - appusecase "lambdamail/protocols/internal/application/usecase" "lambdamail/protocols/internal/application/port" + appusecase "lambdamail/protocols/internal/application/usecase" "lambdamail/protocols/internal/infrastructure/diskstorage" "lambdamail/protocols/internal/infrastructure/postgres" imappresentation "lambdamail/protocols/internal/presentation/imap" diff --git a/services/protocols/internal/e2e/migrations_test.go b/services/protocols/internal/e2e/migrations_test.go new file mode 100644 index 0000000..1615148 --- /dev/null +++ b/services/protocols/internal/e2e/migrations_test.go @@ -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} +} diff --git a/services/protocols/internal/e2e/outbound_queue_e2e_test.go b/services/protocols/internal/e2e/outbound_queue_e2e_test.go index 9d4e168..8949cc0 100644 --- a/services/protocols/internal/e2e/outbound_queue_e2e_test.go +++ b/services/protocols/internal/e2e/outbound_queue_e2e_test.go @@ -4,8 +4,6 @@ import ( "bufio" "context" "net" - "os" - "path/filepath" "strings" "testing" "time" @@ -50,22 +48,7 @@ func TestOutboundQueueEndToEnd(t *testing.T) { } defer pool.Close() - root := repoRoot(t) - migrations := []string{ - "0001_init_schema.up.sql", - "0002_add_is_system_to_aliases.up.sql", - "0003_create_report_tables.up.sql", - } - - for _, m := range migrations { - sql, err := os.ReadFile(filepath.Join(root, "migrations", m)) - if err != nil { - t.Fatalf("read migration %s: %v", m, err) - } - if _, err := pool.Exec(ctx, string(sql)); err != nil { - t.Fatalf("apply migration %s: %v", m, err) - } - } + applyMigrations(t, ctx, pool) // Seed domain, sender mailbox, INBOX domainID := uuid.New() diff --git a/services/protocols/internal/e2e/pop3_e2e_test.go b/services/protocols/internal/e2e/pop3_e2e_test.go index 3d8a87a..36724b9 100644 --- a/services/protocols/internal/e2e/pop3_e2e_test.go +++ b/services/protocols/internal/e2e/pop3_e2e_test.go @@ -14,8 +14,8 @@ import ( "github.com/alexedwards/argon2id" embeddedpostgres "github.com/fergusstrange/embedded-postgres" - appusecase "lambdamail/protocols/internal/application/usecase" "lambdamail/protocols/internal/application/port" + appusecase "lambdamail/protocols/internal/application/usecase" "lambdamail/protocols/internal/infrastructure/diskstorage" "lambdamail/protocols/internal/infrastructure/postgres" pop3presentation "lambdamail/protocols/internal/presentation/pop3" diff --git a/services/protocols/internal/e2e/smtp_inbound_e2e_test.go b/services/protocols/internal/e2e/smtp_inbound_e2e_test.go index 18f23be..edf7976 100644 --- a/services/protocols/internal/e2e/smtp_inbound_e2e_test.go +++ b/services/protocols/internal/e2e/smtp_inbound_e2e_test.go @@ -19,8 +19,8 @@ import ( "testing" "time" - embeddedpostgres "github.com/fergusstrange/embedded-postgres" gosmtp "github.com/emersion/go-smtp" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" "lambdamail/protocols/internal/application/usecase" "lambdamail/protocols/internal/infrastructure/diskstorage" diff --git a/services/protocols/internal/e2e/submission_e2e_test.go b/services/protocols/internal/e2e/submission_e2e_test.go new file mode 100644 index 0000000..26f4a18 --- /dev/null +++ b/services/protocols/internal/e2e/submission_e2e_test.go @@ -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: \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 +} diff --git a/services/protocols/internal/infrastructure/cloudflare/cloudflare_adapter.go b/services/protocols/internal/infrastructure/cloudflare/cloudflare_adapter.go index 0f12dfa..9b2e8e3 100644 --- a/services/protocols/internal/infrastructure/cloudflare/cloudflare_adapter.go +++ b/services/protocols/internal/infrastructure/cloudflare/cloudflare_adapter.go @@ -5,240 +5,392 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/url" + "strconv" + "strings" + "time" "lambdamail/protocols/internal/application/port" "lambdamail/protocols/internal/domain/entity" ) +const ( + // rateLimitMaxAttempts bounds the 429 retry loop so a throttled zone + // delays the reconcile instead of blocking it forever. + rateLimitMaxAttempts = 4 + rateLimitMaxBackoff = 30 * time.Second + recordsPerPage = 100 +) + type CloudflareAdapter struct { - apiToken string - baseURL string + apiToken string + baseURL string httpClient *http.Client } func NewCloudflareAdapter(apiToken string) *CloudflareAdapter { return &CloudflareAdapter{ - apiToken: apiToken, - baseURL: "https://api.cloudflare.com/client/v4", - httpClient: &http.Client{}, + apiToken: apiToken, + baseURL: "https://api.cloudflare.com/client/v4", + // Without a timeout a stalled API call would hold the reconcile - and + // on the periodic drift job, the goroutine - open indefinitely. + httpClient: &http.Client{Timeout: 30 * time.Second}, } } +type cfError struct { + Message string `json:"message"` +} + +type cfResultInfo struct { + Page int `json:"page"` + TotalPages int `json:"total_pages"` +} + type cfZoneResponse struct { Success bool `json:"success"` Result []struct { ID string `json:"id"` Name string `json:"name"` } `json:"result"` - Errors []struct { - Message string `json:"message"` - } `json:"errors"` + Errors []cfError `json:"errors"` } type cfRecord struct { - ID string `json:"id,omitempty"` - Type string `json:"type"` - Name string `json:"name"` - Content string `json:"content"` - TTL int `json:"ttl"` - Priority *int `json:"priority,omitempty"` - Proxied bool `json:"proxied,omitempty"` - Comment string `json:"comment,omitempty"` + ID string `json:"id,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content,omitempty"` + TTL int `json:"ttl"` + // Pointers so that "absent" and "explicitly false" stay distinguishable: + // Cloudflare applies its own default for an omitted proxied flag, and a + // proxied mail host silently breaks SMTP (PLAN.md section 7.1). + Priority *int `json:"priority,omitempty"` + Proxied *bool `json:"proxied,omitempty"` + // Data carries the structured form Cloudflare requires for SRV and TLSA; + // those types reject a flat content string. + Data map[string]any `json:"data,omitempty"` + Comment string `json:"comment,omitempty"` } type cfRecordResponse struct { - Success bool `json:"success"` - Result []cfRecord `json:"result"` - Errors []struct { - Message string `json:"message"` - } `json:"errors"` + Success bool `json:"success"` + Result []cfRecord `json:"result"` + ResultInfo cfResultInfo `json:"result_info"` + Errors []cfError `json:"errors"` } type cfSingleRecordResponse struct { - Success bool `json:"success"` - Result cfRecord `json:"result"` - Errors []struct { - Message string `json:"message"` - } `json:"errors"` + Success bool `json:"success"` + Result cfRecord `json:"result"` + Errors []cfError `json:"errors"` } func (a *CloudflareAdapter) GetZoneID(ctx context.Context, domainName string) (string, error) { reqURL := fmt.Sprintf("%s/zones?name=%s", a.baseURL, url.QueryEscape(domainName)) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) - if err != nil { - return "", err - } - a.setHeaders(req) - - resp, err := a.httpClient.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() var data cfZoneResponse - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return "", fmt.Errorf("decode json: %w", err) + if err := a.doJSON(ctx, http.MethodGet, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil { + return "", err } if !data.Success || len(data.Result) == 0 { - errMsg := "zone not found" - if len(data.Errors) > 0 { - errMsg = data.Errors[0].Message - } - return "", fmt.Errorf("cloudflare API error: %s", errMsg) + return "", fmt.Errorf("cloudflare: zone %q not found or token lacks Zone:Read on it", domainName) } return data.Result[0].ID, nil } func (a *CloudflareAdapter) ListRecords(ctx context.Context, zoneID string) ([]entity.DnsRecord, error) { - reqURL := fmt.Sprintf("%s/zones/%s/dns_records?per_page=100", a.baseURL, zoneID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) - if err != nil { - return nil, err - } - a.setHeaders(req) - - resp, err := a.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var data cfRecordResponse - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return nil, fmt.Errorf("decode json: %w", err) - } - - if !data.Success { - return nil, fmt.Errorf("cloudflare API error listing records") - } - var out []entity.DnsRecord - for _, r := range data.Result { - out = append(out, entity.DnsRecord{ - ID: r.ID, - Type: r.Type, - Name: r.Name, - Value: r.Content, - TTL: r.TTL, - Priority: r.Priority, - Proxied: r.Proxied, - Comment: r.Comment, - }) + + for page := 1; ; page++ { + reqURL := fmt.Sprintf("%s/zones/%s/dns_records?per_page=%d&page=%d", a.baseURL, zoneID, recordsPerPage, page) + + var data cfRecordResponse + if err := a.doJSON(ctx, http.MethodGet, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil { + return nil, err + } + if !data.Success { + return nil, fmt.Errorf("cloudflare: listing records for zone %s failed", zoneID) + } + + for _, r := range data.Result { + rec := entity.DnsRecord{ + ID: r.ID, + Type: r.Type, + Name: r.Name, + Value: r.Content, + TTL: r.TTL, + Priority: r.Priority, + Comment: r.Comment, + } + if r.Proxied != nil { + rec.Proxied = *r.Proxied + } + // Cloudflare returns SRV and TLSA with content already rendered, + // but older zones and some responses only populate data; fall back + // to it so a structured record is not seen as an empty value and + // rewritten on every reconcile. + if rec.Value == "" && len(r.Data) > 0 { + rec.Value = renderStructuredValue(r.Type, r.Data) + } + out = append(out, rec) + } + + // Zones larger than one page would otherwise look like they are + // missing records, and the reconciler would try to re-create them. + if data.ResultInfo.TotalPages <= page || len(data.Result) == 0 { + break + } } + return out, nil } func (a *CloudflareAdapter) CreateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error { reqURL := fmt.Sprintf("%s/zones/%s/dns_records", a.baseURL, zoneID) - cfBody := cfRecord{ - Type: record.Type, - Name: record.Name, - Content: record.Value, - TTL: record.TTL, - Priority: record.Priority, - Proxied: record.Proxied, - Comment: record.Comment, - } - - payload, err := json.Marshal(cfBody) - if err != nil { - return err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(payload)) - if err != nil { - return err - } - a.setHeaders(req) - - resp, err := a.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() var data cfSingleRecordResponse - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return err + if err := a.doJSON(ctx, http.MethodPost, reqURL, toCfRecord(record), &data, func() []cfError { return data.Errors }); err != nil { + return fmt.Errorf("create %s %s: %w", record.Type, record.Name, err) } - if !data.Success { - errMsg := "create record failed" - if len(data.Errors) > 0 { - errMsg = data.Errors[0].Message - } - return fmt.Errorf("cloudflare API error: %s", errMsg) + return fmt.Errorf("cloudflare: create %s %s failed", record.Type, record.Name) } - return nil } func (a *CloudflareAdapter) UpdateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error { reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, record.ID) - cfBody := cfRecord{ - Type: record.Type, - Name: record.Name, - Content: record.Value, - TTL: record.TTL, - Priority: record.Priority, - Proxied: record.Proxied, - Comment: record.Comment, - } - - payload, err := json.Marshal(cfBody) - if err != nil { - return err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewBuffer(payload)) - if err != nil { - return err - } - a.setHeaders(req) - - resp, err := a.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() var data cfSingleRecordResponse - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return err + if err := a.doJSON(ctx, http.MethodPut, reqURL, toCfRecord(record), &data, func() []cfError { return data.Errors }); err != nil { + return fmt.Errorf("update %s %s: %w", record.Type, record.Name, err) } - if !data.Success { - return fmt.Errorf("cloudflare API error updating record") + return fmt.Errorf("cloudflare: update %s %s failed", record.Type, record.Name) } - return nil } func (a *CloudflareAdapter) DeleteRecord(ctx context.Context, zoneID string, recordID string) error { reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, recordID) - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) - if err != nil { - return err - } - a.setHeaders(req) - resp, err := a.httpClient.Do(req) - if err != nil { - return err + var data cfSingleRecordResponse + if err := a.doJSON(ctx, http.MethodDelete, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil { + return fmt.Errorf("delete record %s: %w", recordID, err) + } + if !data.Success { + return fmt.Errorf("cloudflare: delete record %s failed", recordID) + } + return nil +} + +// toCfRecord maps a desired record onto the API payload. The proxied flag is +// only meaningful for the record types Cloudflare can proxy; sending it for a +// TXT or MX record is rejected. +func toCfRecord(record entity.DnsRecord) cfRecord { + body := cfRecord{ + ID: record.ID, + Type: record.Type, + Name: record.Name, + Content: record.Value, + TTL: record.TTL, + Priority: record.Priority, + Comment: record.Comment, + } + + switch strings.ToUpper(record.Type) { + case "A", "AAAA", "CNAME": + proxied := record.Proxied + body.Proxied = &proxied + + case "SRV", "TLSA": + // These types are rejected with a flat content string; the API wants + // the components broken out. + if data := structuredData(record); data != nil { + body.Data = data + body.Content = "" + body.Priority = nil + } + } + + return body +} + +// structuredData splits an SRV or TLSA record value into the field map the +// Cloudflare API expects. It returns nil when the value is not in the +// canonical presentation form, letting the caller fall back to content. +func structuredData(record entity.DnsRecord) map[string]any { + fields := strings.Fields(record.Value) + + switch strings.ToUpper(record.Type) { + case "SRV": + // Presentation form: " ". + 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: " ". + if len(fields) != 4 { + return nil + } + usage, err1 := strconv.Atoi(fields[0]) + selector, err2 := strconv.Atoi(fields[1]) + matchingType, err3 := strconv.Atoi(fields[2]) + if err1 != nil || err2 != nil || err3 != nil { + return nil + } + return map[string]any{ + "usage": usage, + "selector": selector, + "matching_type": matchingType, + "certificate": fields[3], + } } - defer resp.Body.Close() return nil } -func (a *CloudflareAdapter) setHeaders(req *http.Request) { - req.Header.Set("Authorization", "Bearer "+a.apiToken) - req.Header.Set("Content-Type", "application/json") +// renderStructuredValue is the inverse of structuredData: it rebuilds the +// presentation form so the reconciler can compare a published record against +// the desired one. +func renderStructuredValue(recordType string, data map[string]any) string { + number := func(key string) string { + switch v := data[key].(type) { + case float64: // JSON numbers decode as float64 + return strconv.Itoa(int(v)) + case string: + return v + default: + return "" + } + } + text := func(key string) string { + if v, ok := data[key].(string); ok { + return strings.TrimSuffix(v, ".") + } + return "" + } + + switch strings.ToUpper(recordType) { + case "SRV": + return strings.Join([]string{number("priority"), number("weight"), number("port"), text("target")}, " ") + case "TLSA": + return strings.Join([]string{number("usage"), number("selector"), number("matching_type"), text("certificate")}, " ") + default: + return "" + } +} + +// doJSON performs one API call, retrying while Cloudflare reports 429 and +// honouring Retry-After (PLAN.md section 7.5). It fails on any non-2xx status +// rather than handing a decoder an error page. +func (a *CloudflareAdapter) doJSON(ctx context.Context, method, reqURL string, body any, out any, errsOf func() []cfError) error { + var payload []byte + if body != nil { + var err error + if payload, err = json.Marshal(body); err != nil { + return fmt.Errorf("encode request body: %w", err) + } + } + + backoff := time.Second + + for attempt := 1; ; attempt++ { + var reader io.Reader + if payload != nil { + reader = bytes.NewReader(payload) + } + + req, err := http.NewRequestWithContext(ctx, method, reqURL, reader) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+a.apiToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := a.httpClient.Do(req) + if err != nil { + return err + } + + if resp.StatusCode == http.StatusTooManyRequests && attempt < rateLimitMaxAttempts { + wait := retryAfter(resp.Header.Get("Retry-After"), backoff) + resp.Body.Close() + backoff = min(backoff*2, rateLimitMaxBackoff) + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(wait): + } + continue + } + + respBody, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return fmt.Errorf("read response body: %w", readErr) + } + + // Decode first so an error envelope can be quoted back to the operator, + // then judge the status. Cloudflare returns its envelope on 4xx too. + decodeErr := json.Unmarshal(respBody, out) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("cloudflare API returned HTTP %d: %s", resp.StatusCode, describeErrors(errsOf(), respBody)) + } + if decodeErr != nil { + return fmt.Errorf("decode json: %w", decodeErr) + } + return nil + } +} + +func describeErrors(errs []cfError, raw []byte) string { + if len(errs) > 0 { + messages := make([]string, 0, len(errs)) + for _, e := range errs { + messages = append(messages, e.Message) + } + return strings.Join(messages, "; ") + } + const maxRaw = 200 + if len(raw) > maxRaw { + raw = raw[:maxRaw] + } + return strings.TrimSpace(string(raw)) +} + +// retryAfter reads the header as delta-seconds (RFC 9110 section 10.2.3), +// falling back to the caller's exponential backoff when it is absent or +// unparseable. +func retryAfter(header string, fallback time.Duration) time.Duration { + if header == "" { + return fallback + } + seconds, err := strconv.Atoi(strings.TrimSpace(header)) + if err != nil || seconds < 0 { + return fallback + } + return time.Duration(seconds) * time.Second } // Ensure port interface compliance diff --git a/services/protocols/internal/infrastructure/cloudflare/cloudflare_hardening_test.go b/services/protocols/internal/infrastructure/cloudflare/cloudflare_hardening_test.go new file mode 100644 index 0000000..287d711 --- /dev/null +++ b/services/protocols/internal/infrastructure/cloudflare/cloudflare_hardening_test.go @@ -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) + } +} diff --git a/services/protocols/internal/infrastructure/postgres/auth_repository.go b/services/protocols/internal/infrastructure/postgres/auth_repository.go index 1aa7bec..e32d15b 100644 --- a/services/protocols/internal/infrastructure/postgres/auth_repository.go +++ b/services/protocols/internal/infrastructure/postgres/auth_repository.go @@ -21,7 +21,7 @@ func NewAuthRepository(pool *pgxpool.Pool) *AuthRepository { func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*port.MailboxAuth, error) { row := r.pool.QueryRow(ctx, ` - SELECT m.id, m.password_hash + SELECT m.id, m.password_hash, m.email_address, d.name, m.max_recipients_per_hour FROM mailboxes m JOIN domains d ON d.id = m.domain_id WHERE m.email_address = $1 @@ -31,7 +31,7 @@ func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*po var rec port.MailboxAuth var id uuid.UUID - if err := row.Scan(&id, &rec.PasswordHash); err != nil { + if err := row.Scan(&id, &rec.PasswordHash, &rec.EmailAddress, &rec.DomainName, &rec.MaxRecipientsPerHour); err != nil { if err == pgx.ErrNoRows { return nil, nil } diff --git a/services/protocols/internal/infrastructure/postgres/outbound_repository.go b/services/protocols/internal/infrastructure/postgres/outbound_repository.go index aff2902..f95ea43 100644 --- a/services/protocols/internal/infrastructure/postgres/outbound_repository.go +++ b/services/protocols/internal/infrastructure/postgres/outbound_repository.go @@ -3,6 +3,9 @@ package postgres import ( "context" "fmt" + "time" + + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" @@ -37,8 +40,13 @@ func (r *OutboundRepository) FetchNextReady(ctx context.Context, workerID string rows, err := r.pool.Query(ctx, ` WITH ready_jobs AS ( SELECT id FROM outbound_jobs - WHERE status IN ('QUEUED', 'DEFERRED') - AND next_attempt_at <= NOW() + WHERE ( + (status IN ('QUEUED', 'DEFERRED') AND next_attempt_at <= NOW()) + -- Reclaim jobs whose worker died mid-delivery. Without this + -- they stay SENDING forever and the message is silently + -- lost, which contradicts ADR-002's durability guarantee. + OR (status = 'SENDING' AND locked_until IS NOT NULL AND locked_until <= NOW()) + ) ORDER BY next_attempt_at ASC LIMIT $2 FOR UPDATE SKIP LOCKED @@ -90,6 +98,22 @@ func (r *OutboundRepository) FetchNextReady(ctx context.Context, workerID string return jobs, nil } +// CountRecipientsSince counts queued recipients, not messages: one message to +// fifty addresses is fifty recipients against the limit, which is the number +// that matters for abuse. +func (r *OutboundRepository) CountRecipientsSince(ctx context.Context, mailboxID uuid.UUID, since time.Time) (int, error) { + var count int + err := r.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM outbound_jobs + WHERE mailbox_id = $1 AND created_at >= $2 + `, mailboxID, since).Scan(&count) + + if err != nil { + return 0, fmt.Errorf("count recent recipients for mailbox %s: %w", mailboxID, err) + } + return count, nil +} + func (r *OutboundRepository) UpdateJob(ctx context.Context, job *entity.OutboundJob) error { _, err := r.pool.Exec(ctx, ` UPDATE outbound_jobs diff --git a/services/protocols/internal/infrastructure/rspamd/rspamd_adapter.go b/services/protocols/internal/infrastructure/rspamd/rspamd_adapter.go index de968ea..6e17a5a 100644 --- a/services/protocols/internal/infrastructure/rspamd/rspamd_adapter.go +++ b/services/protocols/internal/infrastructure/rspamd/rspamd_adapter.go @@ -7,15 +7,16 @@ import ( "fmt" "net/http" "strings" + "time" "lambdamail/protocols/internal/application/port" "lambdamail/protocols/internal/domain/valueobject" ) type RspamdResponse struct { - Action string `json:"action"` - Score float64 `json:"score"` - RequiredScore float64 `json:"required_score"` + Action string `json:"action"` + Score float64 `json:"score"` + RequiredScore float64 `json:"required_score"` Symbols map[string]map[string]interface{} `json:"symbols"` } @@ -29,8 +30,11 @@ func NewRspamdAdapter(baseURL string) *RspamdAdapter { baseURL = "http://localhost:11333" } return &RspamdAdapter{ - baseURL: strings.TrimSuffix(baseURL, "/"), - httpClient: &http.Client{}, + baseURL: strings.TrimSuffix(baseURL, "/"), + // A scan sits in the middle of an open SMTP transaction, so an + // unbounded request would pin the session (and the sender's socket) + // until the peer gives up. + httpClient: &http.Client{Timeout: 30 * time.Second}, } } @@ -86,7 +90,9 @@ func (a *RspamdAdapter) Scan(ctx context.Context, input port.ScanInput) (*valueo switch parsed.Action { case "reject": res.Verdict = valueobject.ScanVerdictSpamReject - case "greylist": + // "soft reject" is Rspamd asking the sender to retry later (rate limit or + // transient policy); like a greylist it maps to a 4xx, never to acceptance. + case "greylist", "soft reject": res.Verdict = valueobject.ScanVerdictGreylist case "add header", "rewrite subject": res.Verdict = valueobject.ScanVerdictSpamJunk diff --git a/services/protocols/internal/infrastructure/rspamd/rspamd_adapter_test.go b/services/protocols/internal/infrastructure/rspamd/rspamd_adapter_test.go index e411937..455d8bb 100644 --- a/services/protocols/internal/infrastructure/rspamd/rspamd_adapter_test.go +++ b/services/protocols/internal/infrastructure/rspamd/rspamd_adapter_test.go @@ -18,7 +18,12 @@ func TestRspamdAdapter_Scan_MapsActions(t *testing.T) { }{ {"no action", valueobject.ScanVerdictClean}, {"add header", valueobject.ScanVerdictSpamJunk}, + {"rewrite subject", valueobject.ScanVerdictSpamJunk}, {"greylist", valueobject.ScanVerdictGreylist}, + // Rspamd's "soft reject" asks the sender to come back later (rate + // limit, transient policy). Treating it as clean would accept mail + // the filter explicitly refused to accept yet. + {"soft reject", valueobject.ScanVerdictGreylist}, {"reject", valueobject.ScanVerdictSpamReject}, } diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go index 28eb897..8386131 100644 --- a/services/protocols/internal/presentation/http/router.go +++ b/services/protocols/internal/presentation/http/router.go @@ -3,6 +3,7 @@ package httppresentation import ( "fmt" "io" + "net" "net/http" "strings" "time" @@ -11,10 +12,16 @@ import ( "lambdamail/protocols/internal/domain/valueobject" ) +// mtaStsMaxAge is the policy lifetime advertised to senders. RFC 8461 +// recommends a value in the order of weeks so that an attacker cannot strip +// the policy by making the endpoint unreachable. +const mtaStsMaxAge = 604800 // 7 days + type Router struct { mux *http.ServeMux reportUseCase *appusecase.IngestReportsUseCase pingFunc func() error + mtaStsMode valueobject.MtaStsMode } func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() error) *Router { @@ -22,11 +29,24 @@ func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() e mux: http.NewServeMux(), reportUseCase: reportUseCase, pingFunc: pingFunc, + // PLAN.md section 5 ramps MTA-STS from testing to enforce. Starting at + // enforce means any TLS misconfiguration silently blackholes inbound + // mail from every sender that honours the policy, with no way to tell + // from this side - so the safe mode is the default and the operator + // promotes it once TLS-RPT reports come back clean. + mtaStsMode: valueobject.MtaStsModeTesting, } r.registerRoutes() return r } +// SetMtaStsMode selects the mode advertised in the served policy. Callers +// should promote to enforce only after TLS-RPT confirms senders can negotiate +// TLS against the published MX. +func (r *Router) SetMtaStsMode(mode valueobject.MtaStsMode) { + r.mtaStsMode = mode +} + func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.mux.ServeHTTP(w, req) } @@ -57,19 +77,23 @@ func (r *Router) handleMtaSts(w http.ResponseWriter, req *http.Request) { host := extractHost(req.Host) domainName := strings.TrimPrefix(host, "mta-sts.") - policy := valueobject.NewMtaStsPolicy(valueobject.MtaStsModeEnforce, []string{fmt.Sprintf("mail.%s", domainName)}, 604800) + policy := valueobject.NewMtaStsPolicy(r.mtaStsMode, []string{fmt.Sprintf("mail.%s", domainName)}, mtaStsMaxAge) w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("Cache-Control", "public, max-age=86400") + // Kept in step with max_age so an HTTP cache cannot outlive the policy it + // is caching. + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", mtaStsMaxAge)) w.WriteHeader(http.StatusOK) w.Write([]byte(policy.Format())) } func (r *Router) handleSecurityTxt(w http.ResponseWriter, req *http.Request) { - host := extractHost(req.Host) + // The contact must be a real mailbox on the mail domain, not on whichever + // service hostname the request happened to arrive at. + domainName := baseDomain(extractHost(req.Host)) expires := time.Now().AddDate(1, 0, 0).Format(time.RFC3339) - content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", host, expires) + content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", domainName, expires) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) @@ -144,12 +168,27 @@ func (r *Router) handleTlsRptIngest(w http.ResponseWriter, req *http.Request) { w.Write([]byte(`{"status":"ok"}`)) } +// extractHost strips the optional port from a Host header, handling the +// bracketed IPv6 literal form as well. func extractHost(hostHeader string) string { - if h, _, err := strings.Cut(hostHeader, ":"); err && h != "" { - return h - } if hostHeader == "" { return "example.test" } - return hostHeader + if host, _, err := net.SplitHostPort(hostHeader); err == nil && host != "" { + return host + } + return strings.Trim(hostHeader, "[]") +} + +// serviceHostPrefixes are the per-service names published under a mail domain +// (PLAN.md section 7.3); stripping one yields the mail domain itself. +var serviceHostPrefixes = []string{"mta-sts.", "autoconfig.", "autodiscover.", "mail."} + +func baseDomain(host string) string { + for _, prefix := range serviceHostPrefixes { + if strings.HasPrefix(host, prefix) { + return strings.TrimPrefix(host, prefix) + } + } + return host } diff --git a/services/protocols/internal/presentation/imap/stubs.go b/services/protocols/internal/presentation/imap/stubs.go index 059421e..6de89ea 100644 --- a/services/protocols/internal/presentation/imap/stubs.go +++ b/services/protocols/internal/presentation/imap/stubs.go @@ -24,4 +24,3 @@ func (s *session) Unsubscribe(_ string) error { return errN func (s *session) Append(_ string, _ imap.LiteralReader, _ *imap.AppendOptions) (*imap.AppendData, error) { return nil, errNotYetImplemented } - diff --git a/services/protocols/internal/presentation/smtp/backend.go b/services/protocols/internal/presentation/smtp/backend.go index e229442..ad74aff 100644 --- a/services/protocols/internal/presentation/smtp/backend.go +++ b/services/protocols/internal/presentation/smtp/backend.go @@ -7,6 +7,7 @@ import ( "context" "errors" "io" + "net" "strings" gosmtp "github.com/emersion/go-smtp" @@ -24,19 +25,44 @@ func NewBackend(uc *usecase.ProcessInboundEmailUseCase) *Backend { return &Backend{useCase: uc} } -func (b *Backend) NewSession(_ *gosmtp.Conn) (gosmtp.Session, error) { - return &session{useCase: b.useCase}, nil +func (b *Backend) NewSession(c *gosmtp.Conn) (gosmtp.Session, error) { + return &session{useCase: b.useCase, conn: c}, nil } // session holds one SMTP transaction's accumulated state (RFC 5321 section // 4.1.1: MAIL, then one or more RCPT, then DATA, then reset). type session struct { useCase *usecase.ProcessInboundEmailUseCase + conn *gosmtp.Conn from string recipients []port.MailboxRecord recipientAddresses []string } +// clientIP is the peer address SPF is evaluated against. It returns nil when +// the connection is not available, which downgrades SPF to "none" rather than +// producing a wrong verdict. +func (s *session) clientIP() net.IP { + if s.conn == nil || s.conn.Conn() == nil { + return nil + } + if addr, ok := s.conn.Conn().RemoteAddr().(*net.TCPAddr); ok { + return addr.IP + } + host, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String()) + if err != nil { + return nil + } + return net.ParseIP(host) +} + +func (s *session) heloDomain() string { + if s.conn == nil { + return "" + } + return s.conn.Hostname() +} + func (s *session) Reset() { s.from = "" s.recipients = nil @@ -107,6 +133,8 @@ func (s *session) Data(r io.Reader) error { Recipients: s.recipients, RecipientAddresses: s.recipientAddresses, Body: r, + ClientIP: s.clientIP(), + HeloDomain: s.heloDomain(), }) if err != nil { var smtpErr *gosmtp.SMTPError @@ -114,6 +142,13 @@ func (s *session) Data(r io.Reader) error { return smtpErr } errMsg := err.Error() + if strings.HasPrefix(errMsg, "550 ") { + return &gosmtp.SMTPError{ + Code: 550, + EnhancedCode: gosmtp.EnhancedCode{5, 7, 1}, + Message: strings.TrimPrefix(errMsg, "550 5.7.1 "), + } + } if strings.HasPrefix(errMsg, "554 ") { return &gosmtp.SMTPError{ Code: 554, diff --git a/services/protocols/internal/presentation/smtp/sasl_login.go b/services/protocols/internal/presentation/smtp/sasl_login.go new file mode 100644 index 0000000..671499e --- /dev/null +++ b/services/protocols/internal/presentation/smtp/sasl_login.go @@ -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") + } +} diff --git a/services/protocols/internal/presentation/smtp/sasl_login_test.go b/services/protocols/internal/presentation/smtp/sasl_login_test.go new file mode 100644 index 0000000..8471222 --- /dev/null +++ b/services/protocols/internal/presentation/smtp/sasl_login_test.go @@ -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") + } +} diff --git a/services/protocols/internal/presentation/smtp/submission_backend.go b/services/protocols/internal/presentation/smtp/submission_backend.go new file mode 100644 index 0000000..d637300 --- /dev/null +++ b/services/protocols/internal/presentation/smtp/submission_backend.go @@ -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) +) diff --git a/services/protocols/internal/presentation/smtp/submission_backend_test.go b/services/protocols/internal/presentation/smtp/submission_backend_test.go new file mode 100644 index 0000000..1fcb244 --- /dev/null +++ b/services/protocols/internal/presentation/smtp/submission_backend_test.go @@ -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