v3.0.0: fully autonomous engine — remove MillionVerifier, add DoH DNS worker, AI risk scorer (HF PRO, circuit breaker), i18n, normalized verdicts, remote SMTP worker
Browse files- Autonomy: MillionVerifierProvider + mv_api_key/prefer_api_provider removed
by design; mailbox verdicts come only from the built-in SMTP engine or the
new remote SMTP probe worker (SMTP_WORKER_URL, shared-secret guarded).
- DNS/MX worker (spec 3.2/7.3): DNS-over-HTTPS on Google + Cloudflare with a
dual-resolver consistency check (dns_inconsistent), 24h MX / 1h negative
TTL cache, Punycode normalization; legacy dnspython stays as fallback.
- AI Risk Scorer (spec 3.4/Phase 4/6): HF Inference API (zero-shot NLI or
text-classification), circuit breaker (429/402/quota open permanently,
transient failures open after threshold), rule-based heuristic fallback,
PII-free feature strings, burn-rate instrumentation at /metrics/inference.
- i18n (spec 7.1/7.2): Punycode via idna, EAI/SMTPUTF8 deterministic refusal
(unsupported_eai) instead of fake verdicts.
- Normalized verdict (spec Phase 5) on ValidationResult: deterministic_verdict,
ai_risk_score, ai_label, fallback_used, cache_hit; catch_all NEVER collapsed
into valid.
- New ops endpoints: /engine/status, /metrics/inference, /smtp/probe(+batch).
- Tests: 292 -> 328 passing (DoH, i18n, scorer, remote worker, worker routes,
normalized verdicts); MV tests rewritten as autonomy invariants.
- .env +11 -50
- .env.example +93 -69
- app/api/routes.py +580 -466
- app/auth.py +189 -177
- app/config.py +190 -169
- app/main.py +186 -186
- app/ml/hf_scorer.py +420 -0
- app/models.py +163 -141
- app/utils/cache.py +173 -173
- app/validator/dns_check.py +238 -184
- app/validator/doh_resolver.py +220 -0
- app/validator/engine.py +833 -662
- app/validator/i18n.py +76 -0
- app/validator/mailbox_provider.py +175 -106
- tests/test_autonomous_provider.py +8 -21
- tests/test_dns_concurrency.py +9 -2
- tests/test_dns_correctness.py +103 -95
- tests/test_doh_resolver.py +220 -0
- tests/test_engine_autonomous.py +288 -0
- tests/test_hf_scorer.py +249 -0
- tests/test_i18n.py +61 -0
- tests/test_mailbox_provider.py +161 -110
- tests/test_worker_endpoints.py +125 -0
|
@@ -1,50 +1,11 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
# ---
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
SECRET_KEY=dev-local-secret-change-in-production
|
| 13 |
-
HOST=0.0.0.0
|
| 14 |
-
PORT=8000
|
| 15 |
-
WORKERS=4
|
| 16 |
-
|
| 17 |
-
# --- Supabase (PostgreSQL + PostgREST) ---
|
| 18 |
-
SUPABASE_URL=https://ciytvulujiqemqljablq.supabase.co
|
| 19 |
-
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNpeXR2dWx1amlxZW1xbGphYmxxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODcwNjc3NjgsImV4cCI6MjEwMjY0Mzc2OH0.YQH7YvCITabZT-SVr4A4WNvGDtW-ezkEvUQ6Dht0yjo
|
| 20 |
-
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNpeXR2dWx1amlxZW1xbGphYmxxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4NzA2Nzc2OCwiZXhwIjoyMTAyNjQzNzY4fQ.qIg9WSYPP9jCkUigsozJ8dPxavCN6i2iFxs4xZnp-cM
|
| 21 |
-
ENABLE_DB_PERSISTENCE=true
|
| 22 |
-
|
| 23 |
-
# Local dev DB fallback (not used when Supabase is configured)
|
| 24 |
-
DATABASE_URL=sqlite+aiosqlite:///./emailval.db
|
| 25 |
-
|
| 26 |
-
# --- Redis (Upstash FREE TIER) ---
|
| 27 |
-
# Free databases created via https://upstash.com/start-redis (no account needed)
|
| 28 |
-
# NOTE: free-tier databases support ONLY db 0 0 -- one database per logical store.
|
| 29 |
-
|
| 30 |
-
# Main cache -- db 0 of credible-kangaroo-120640
|
| 31 |
-
REDIS_URL=rediss://default:gQAAAAAAA4vQAAIgcDEwZGE3NTU5MTU1ZjY0OTZlOWUxN2QwMjY1ZTlhMzViNA@new-parrot-232400.upstash.io:6379
|
| 32 |
-
REDIS_CACHE_TTL=86400
|
| 33 |
-
# Celery broker -- db 0 of immortal-gar-118475
|
| 34 |
-
CELERY_BROKER_URL=rediss://default:gQAAAAAAA4vQAAIgcDEwZGE3NTU5MTU1ZjY0OTZlOWUxN2QwMjY1ZTlhMzViNA@new-parrot-232400.upstash.io:6379
|
| 35 |
-
# Celery result backend -- db 0 of exciting-gannet-119131
|
| 36 |
-
CELERY_RESULT_BACKEND=rediss://default:gQAAAAAAA4vQAAIgcDEwZGE3NTU5MTU1ZjY0OTZlOWUxN2QwMjY1ZTlhMzViNA@new-parrot-232400.upstash.io:6379
|
| 37 |
-
|
| 38 |
-
# --- CORS ---
|
| 39 |
-
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000","http://127.0.0.1:5173","https://sitcod3-bridge-modul-x-api.hf.space","https://uranusmediaconnection-dot.github.io","https://frontend-j6yuq34u0-sitcod3lab-2797s-projects.vercel.app","https://frontend-grm2qel63-sitcod3lab-2797s-projects.vercel.app","https://frontend-seven-rose-23.vercel.app"]
|
| 40 |
-
|
| 41 |
-
# --- Validation Tuning ---
|
| 42 |
-
SMTP_TIMEOUT=10
|
| 43 |
-
DNS_TIMEOUT=5
|
| 44 |
-
MAX_BULK_EMAILS=10000
|
| 45 |
-
RATE_LIMIT_PER_MINUTE=60
|
| 46 |
-
USE_PROXIES=false
|
| 47 |
-
|
| 48 |
-
# --- NVIDIA NIM (free endpoint models) ---
|
| 49 |
-
NVIDIA_API_KEY=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
|
| 50 |
-
NVIDIA_API_KEY_ACC_1=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
|
|
|
|
| 1 |
+
APP_ENVIRONMENT=development
|
| 2 |
+
DEBUG=true
|
| 3 |
+
ENABLE_DB_PERSISTENCE=true
|
| 4 |
+
LICENSE_ENFORCEMENT=false
|
| 5 |
+
REDIS_URL=redis://localhost:6399/0
|
| 6 |
+
DOH_ENABLED=true
|
| 7 |
+
|
| 8 |
+
# --- AI Risk Scorer (v3.0.0) — HF PRO Inference API, zero-shot mode ---
|
| 9 |
+
AI_SCORER_MODEL=facebook/bart-large-mnli
|
| 10 |
+
AI_SCORER_ZERO_SHOT=true
|
| 11 |
+
# HF_INFERENCE_TOKEN is injected as a Space SECRET (not stored in this repo).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,69 +1,93 @@
|
|
| 1 |
-
# Copy to .env and adjust for your environment.
|
| 2 |
-
APP_ENVIRONMENT=development
|
| 3 |
-
APP_NAME="BRIDGE Modul - X"
|
| 4 |
-
APP_VERSION="2.1.0"
|
| 5 |
-
DEBUG=true
|
| 6 |
-
SECRET_KEY=replace-with-a-random-secret
|
| 7 |
-
HOST=0.0.0.0
|
| 8 |
-
PORT=8000
|
| 9 |
-
WORKERS=4
|
| 10 |
-
|
| 11 |
-
# Supabase (server-side service-role key; never expose it to the browser)
|
| 12 |
-
SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
|
| 13 |
-
SUPABASE_ANON_KEY=your-public-anon-key
|
| 14 |
-
SUPABASE_SERVICE_ROLE_KEY=your-server-only-service-role-key
|
| 15 |
-
ENABLE_DB_PERSISTENCE=true
|
| 16 |
-
LICENSE_ENFORCEMENT=false
|
| 17 |
-
|
| 18 |
-
# Local development defaults
|
| 19 |
-
DATABASE_URL=sqlite+aiosqlite:///./emailval.db
|
| 20 |
-
REDIS_URL=redis://localhost:6379/0
|
| 21 |
-
REDIS_CACHE_TTL=86400
|
| 22 |
-
CELERY_BROKER_URL=redis://localhost:6379/1
|
| 23 |
-
CELERY_RESULT_BACKEND=redis://localhost:6379/2
|
| 24 |
-
|
| 25 |
-
# For Upstash, use TLS and database index /0:
|
| 26 |
-
# REDIS_URL=rediss://default:YOUR_REDIS_TOKEN@YOUR_ENDPOINT:6379/0
|
| 27 |
-
# CELERY_BROKER_URL=rediss://default:YOUR_REDIS_TOKEN@YOUR_ENDPOINT:6379/0
|
| 28 |
-
# CELERY_RESULT_BACKEND=rediss://default:YOUR_REDIS_TOKEN@YOUR_ENDPOINT:6379/0
|
| 29 |
-
# Never disable TLS certificate verification. Upstash Free has one database;
|
| 30 |
-
# prove Celery key-prefix isolation and compatibility before sharing it, or
|
| 31 |
-
# use separate provisioned databases/a suitable paid plan for production jobs.
|
| 32 |
-
# The app does not need the Upstash Developer API key. Keep it out of runtime
|
| 33 |
-
# environment variables; use it only from a restricted operator workflow.
|
| 34 |
-
|
| 35 |
-
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000","http://127.0.0.1:5173"]
|
| 36 |
-
SMTP_TIMEOUT=10
|
| 37 |
-
DNS_TIMEOUT=5
|
| 38 |
-
MAX_BULK_EMAILS=10000
|
| 39 |
-
RATE_LIMIT_PER_MINUTE=60
|
| 40 |
-
USE_PROXIES=false
|
| 41 |
-
|
| 42 |
-
# --- Mailbox verification engine (Layers 5 + 6) ---
|
| 43 |
-
# The validator
|
| 44 |
-
#
|
| 45 |
-
#
|
| 46 |
-
#
|
| 47 |
-
# IMPORTANT: port 25 must be open outbound. Most cloud PaaS (Hugging Face
|
| 48 |
-
# Spaces, AWS Lambda, Cloudflare Workers) BLOCK it. If outbound 25 is blocked,
|
| 49 |
-
# the engine still runs but reports every mailbox as smtp_unreachable (honest
|
| 50 |
-
# "risky", refunded) — it will never fake a verdict.
|
| 51 |
-
# port-25 egress
|
| 52 |
-
|
| 53 |
-
#
|
| 54 |
-
#
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
#
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
#
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy to .env and adjust for your environment.
|
| 2 |
+
APP_ENVIRONMENT=development
|
| 3 |
+
APP_NAME="BRIDGE Modul - X"
|
| 4 |
+
APP_VERSION="2.1.0"
|
| 5 |
+
DEBUG=true
|
| 6 |
+
SECRET_KEY=replace-with-a-random-secret
|
| 7 |
+
HOST=0.0.0.0
|
| 8 |
+
PORT=8000
|
| 9 |
+
WORKERS=4
|
| 10 |
+
|
| 11 |
+
# Supabase (server-side service-role key; never expose it to the browser)
|
| 12 |
+
SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
|
| 13 |
+
SUPABASE_ANON_KEY=your-public-anon-key
|
| 14 |
+
SUPABASE_SERVICE_ROLE_KEY=your-server-only-service-role-key
|
| 15 |
+
ENABLE_DB_PERSISTENCE=true
|
| 16 |
+
LICENSE_ENFORCEMENT=false
|
| 17 |
+
|
| 18 |
+
# Local development defaults
|
| 19 |
+
DATABASE_URL=sqlite+aiosqlite:///./emailval.db
|
| 20 |
+
REDIS_URL=redis://localhost:6379/0
|
| 21 |
+
REDIS_CACHE_TTL=86400
|
| 22 |
+
CELERY_BROKER_URL=redis://localhost:6379/1
|
| 23 |
+
CELERY_RESULT_BACKEND=redis://localhost:6379/2
|
| 24 |
+
|
| 25 |
+
# For Upstash, use TLS and database index /0:
|
| 26 |
+
# REDIS_URL=rediss://default:YOUR_REDIS_TOKEN@YOUR_ENDPOINT:6379/0
|
| 27 |
+
# CELERY_BROKER_URL=rediss://default:YOUR_REDIS_TOKEN@YOUR_ENDPOINT:6379/0
|
| 28 |
+
# CELERY_RESULT_BACKEND=rediss://default:YOUR_REDIS_TOKEN@YOUR_ENDPOINT:6379/0
|
| 29 |
+
# Never disable TLS certificate verification. Upstash Free has one database;
|
| 30 |
+
# prove Celery key-prefix isolation and compatibility before sharing it, or
|
| 31 |
+
# use separate provisioned databases/a suitable paid plan for production jobs.
|
| 32 |
+
# The app does not need the Upstash Developer API key. Keep it out of runtime
|
| 33 |
+
# environment variables; use it only from a restricted operator workflow.
|
| 34 |
+
|
| 35 |
+
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000","http://127.0.0.1:5173"]
|
| 36 |
+
SMTP_TIMEOUT=10
|
| 37 |
+
DNS_TIMEOUT=5
|
| 38 |
+
MAX_BULK_EMAILS=10000
|
| 39 |
+
RATE_LIMIT_PER_MINUTE=60
|
| 40 |
+
USE_PROXIES=false
|
| 41 |
+
|
| 42 |
+
# --- Mailbox verification engine (Layers 5 + 6) — FULLY AUTONOMOUS ---
|
| 43 |
+
# The validator probes recipient MX servers with its own connection-reusing
|
| 44 |
+
# SMTP engine. NO third-party verification API, NO vendor key, NO per-check
|
| 45 |
+
# fee — the former MillionVerifier delegation was removed in v3.0.0.
|
| 46 |
+
#
|
| 47 |
+
# IMPORTANT: port 25 must be open outbound. Most cloud PaaS (Hugging Face
|
| 48 |
+
# Spaces, AWS Lambda, Cloudflare Workers) BLOCK it. If outbound 25 is blocked,
|
| 49 |
+
# the engine still runs but reports every mailbox as smtp_unreachable (honest
|
| 50 |
+
# "risky", refunded) — it will never fake a verdict. Two fixes:
|
| 51 |
+
# 1) run the orchestrator on a VPS with port-25 egress, or
|
| 52 |
+
# 2) keep the orchestrator where it is and point SMTP_WORKER_URL at a
|
| 53 |
+
# port-25-capable host running this same image (spec §3.3 worker role).
|
| 54 |
+
MAILBOX_PROVIDER=auto # auto | local(=autonomous) | remote
|
| 55 |
+
# Remote SMTP probe worker (spec §3.3). Empty = built-in engine on this host.
|
| 56 |
+
# Example: https://smtp-worker.example.com (a deployment of this image)
|
| 57 |
+
SMTP_WORKER_URL=
|
| 58 |
+
# Shared secret required on POST /api/v1/smtp/probe* (X-Worker-Token header).
|
| 59 |
+
# Set the SAME value on the worker and on the orchestrator.
|
| 60 |
+
SMTP_WORKER_TOKEN=
|
| 61 |
+
SMTP_WORKER_TIMEOUT=15
|
| 62 |
+
|
| 63 |
+
# --- DNS-over-HTTPS worker (spec §3.2) ---
|
| 64 |
+
# MX resolution on free public resolvers (Google + Cloudflare) with a
|
| 65 |
+
# dual-resolver consistency check (dns_inconsistent risk signal). Free,
|
| 66 |
+
# unlimited, no auth; legacy dnspython path stays as automatic fallback.
|
| 67 |
+
DOH_ENABLED=true
|
| 68 |
+
DOH_TIMEOUT=5
|
| 69 |
+
|
| 70 |
+
# --- AI Risk Scorer (spec §3.4 — the ONLY metered component) ---
|
| 71 |
+
# Hugging Face Inference API model id (text-classification). Empty disables
|
| 72 |
+
# remote inference: the built-in rule-based heuristic then scores every
|
| 73 |
+
# ambiguous address (catch-all / unknown) at zero marginal cost, and every
|
| 74 |
+
# verdict it decides is marked fallback_used=true. The circuit breaker opens
|
| 75 |
+
# on 429/quota/5xx and the engine degrades gracefully — never blocks.
|
| 76 |
+
AI_SCORER_MODEL=
|
| 77 |
+
AI_SCORER_TIMEOUT=12
|
| 78 |
+
# Inference token. Falls back to ML_HF_TOKEN when empty.
|
| 79 |
+
HF_INFERENCE_TOKEN=
|
| 80 |
+
AI_CIRCUIT_FAIL_THRESHOLD=3
|
| 81 |
+
AI_CIRCUIT_COOLDOWN_SECONDS=300
|
| 82 |
+
|
| 83 |
+
# SMTP identity used in the probe dialogue. Use a real, resolvable hostname with
|
| 84 |
+
# a matching PTR record on a production host — strict receivers reject generic
|
| 85 |
+
# HELO names and that degrades accuracy.
|
| 86 |
+
SMTP_HELO_DOMAIN=validator.local
|
| 87 |
+
SMTP_MAIL_FROM=probe@validator.local
|
| 88 |
+
|
| 89 |
+
# Catch-all is a domain property: cache the verdict for 72h.
|
| 90 |
+
CATCH_ALL_TTL_SECONDS=259200
|
| 91 |
+
# Per-MX-host session cap and overall bulk concurrency.
|
| 92 |
+
MAX_CONNECTIONS_PER_HOST=2
|
| 93 |
+
BULK_CONCURRENCY=20
|
|
@@ -1,466 +1,580 @@
|
|
| 1 |
-
from
|
| 2 |
-
from
|
| 3 |
-
|
| 4 |
-
import
|
| 5 |
-
import
|
| 6 |
-
import
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
#
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
pairs = (
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
)
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
""
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
#
|
| 197 |
-
#
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
#
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
payload["
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
"
|
| 416 |
-
"
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
#
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
"
|
| 464 |
-
"
|
| 465 |
-
|
| 466 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from urllib.parse import urlsplit
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter, HTTPException, Query, Request
|
| 5 |
+
from fastapi.responses import JSONResponse
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
import json
|
| 8 |
+
import time
|
| 9 |
+
import asyncio
|
| 10 |
+
import uuid
|
| 11 |
+
|
| 12 |
+
from ..models import (
|
| 13 |
+
SingleEmailRequest, BulkEmailRequest,
|
| 14 |
+
ValidationResult, BulkValidationResponse,
|
| 15 |
+
TaskStatusResponse
|
| 16 |
+
)
|
| 17 |
+
from ..validator.engine import engine
|
| 18 |
+
from ..utils.cache import get_cache, set_cache
|
| 19 |
+
from ..config import settings
|
| 20 |
+
from ..db import repo
|
| 21 |
+
|
| 22 |
+
# Strong references to in-flight background bulk jobs (the event loop only
|
| 23 |
+
# keeps weak ones, so a task can vanish mid-run and its results are lost), and
|
| 24 |
+
# a cap so a burst of bulk requests cannot spawn unbounded SMTP work.
|
| 25 |
+
_background_tasks: set[asyncio.Task] = set()
|
| 26 |
+
_bulk_semaphore = asyncio.Semaphore(settings.bulk_concurrency)
|
| 27 |
+
|
| 28 |
+
# Celery task handle (module level so the routing is testable/patchable).
|
| 29 |
+
# Import failure degrades to the background-task path; Celery itself is only
|
| 30 |
+
# required when celery_bulk_enabled=True.
|
| 31 |
+
try:
|
| 32 |
+
from ..tasks.celery_tasks import bulk_validate_task # noqa: E402
|
| 33 |
+
except Exception: # pragma: no cover - celery not installed
|
| 34 |
+
bulk_validate_task = None
|
| 35 |
+
|
| 36 |
+
router = APIRouter()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def process_bulk_sync(emails: list, webhook_url: str = None, deep: bool = True) -> dict:
|
| 40 |
+
"""
|
| 41 |
+
Process bulk validation synchronously in chunks.
|
| 42 |
+
Used when Celery is unavailable.
|
| 43 |
+
|
| 44 |
+
`deep` defaults to True so bulk runs the full 7-layer pipeline (catch-all +
|
| 45 |
+
SMTP) exactly like single verification — the product charges 1 credit/email
|
| 46 |
+
and must not silently degrade to a syntax+DNS-only check.
|
| 47 |
+
|
| 48 |
+
Chunks feed the engine's *batched* path: DNS/MX resolves once per domain and
|
| 49 |
+
the mailbox stage takes the whole chunk in one call, so the SMTP engine reuses
|
| 50 |
+
a session per receiving host instead of opening a TCP connection per address.
|
| 51 |
+
"""
|
| 52 |
+
results = []
|
| 53 |
+
total = len(emails)
|
| 54 |
+
chunk_size = settings.bulk_sync_chunk_size # Configurable chunk size
|
| 55 |
+
|
| 56 |
+
for i in range(0, total, chunk_size):
|
| 57 |
+
chunk = emails[i:i + chunk_size]
|
| 58 |
+
try:
|
| 59 |
+
chunk_results = await engine.validate_batch(chunk, deep=deep)
|
| 60 |
+
except Exception as exc: # never lose a chunk to one bad stage
|
| 61 |
+
results.extend(
|
| 62 |
+
{
|
| 63 |
+
"email": email,
|
| 64 |
+
"error": str(exc) or "Unknown error",
|
| 65 |
+
"status": "error",
|
| 66 |
+
}
|
| 67 |
+
for email in chunk
|
| 68 |
+
)
|
| 69 |
+
continue
|
| 70 |
+
|
| 71 |
+
# validate_batch guarantees one result per input, in input order.
|
| 72 |
+
if len(chunk_results) == len(chunk):
|
| 73 |
+
pairs = zip(chunk, chunk_results)
|
| 74 |
+
else:
|
| 75 |
+
# Defensive: never lose or mis-attribute a row.
|
| 76 |
+
by_email = {getattr(r, "email", "").lower(): r for r in chunk_results}
|
| 77 |
+
pairs = ((email, by_email.get(email.lower())) for email in chunk)
|
| 78 |
+
|
| 79 |
+
for email, result in pairs:
|
| 80 |
+
if isinstance(result, ValidationResult):
|
| 81 |
+
results.append(result.model_dump())
|
| 82 |
+
else:
|
| 83 |
+
results.append(
|
| 84 |
+
{
|
| 85 |
+
"email": email,
|
| 86 |
+
"error": "engine returned no result for this address",
|
| 87 |
+
"status": "error",
|
| 88 |
+
}
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# Webhook notification if provided
|
| 92 |
+
if webhook_url:
|
| 93 |
+
import httpx
|
| 94 |
+
try:
|
| 95 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 96 |
+
await client.post(webhook_url, json={"results": results})
|
| 97 |
+
except Exception:
|
| 98 |
+
pass
|
| 99 |
+
|
| 100 |
+
# Run-quality monitor (report item 10): fire the signed system-alert
|
| 101 |
+
# webhook when this run's risky/unknown share crosses the threshold.
|
| 102 |
+
try:
|
| 103 |
+
from ..utils.rate_alerts import maybe_alert_risky_rate
|
| 104 |
+
|
| 105 |
+
await maybe_alert_risky_rate(
|
| 106 |
+
results,
|
| 107 |
+
webhook_url=webhook_url,
|
| 108 |
+
job_id="sync",
|
| 109 |
+
threshold=settings.risky_rate_alert_threshold,
|
| 110 |
+
)
|
| 111 |
+
except Exception:
|
| 112 |
+
pass
|
| 113 |
+
|
| 114 |
+
return results
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ---- Single Email Validation --------------------------------------
|
| 118 |
+
@router.post(
|
| 119 |
+
"/validate/single",
|
| 120 |
+
response_model=ValidationResult,
|
| 121 |
+
summary="Validate a single email address",
|
| 122 |
+
tags=["Validation"]
|
| 123 |
+
)
|
| 124 |
+
async def validate_single(
|
| 125 |
+
request: SingleEmailRequest,
|
| 126 |
+
deep: bool = Query(default=True, description="Enable SMTP verification (slower but more accurate)")
|
| 127 |
+
):
|
| 128 |
+
"""
|
| 129 |
+
Full 7-layer email validation:
|
| 130 |
+
Syntax → DNS → MX → Disposable → Catch-All → SMTP → Score
|
| 131 |
+
"""
|
| 132 |
+
email = request.email
|
| 133 |
+
|
| 134 |
+
# Check cache first
|
| 135 |
+
cache_key = f"val:{email}:{'deep' if deep else 'fast'}"
|
| 136 |
+
cached = await get_cache(cache_key)
|
| 137 |
+
if cached:
|
| 138 |
+
result = ValidationResult(**json.loads(cached))
|
| 139 |
+
return result.model_copy(update={"cache_hit": True})
|
| 140 |
+
|
| 141 |
+
# Run full validation
|
| 142 |
+
result = await engine.validate(email, deep=deep)
|
| 143 |
+
|
| 144 |
+
# Cache the result
|
| 145 |
+
await set_cache(cache_key, result.model_dump_json(), ttl=settings.redis_cache_ttl)
|
| 146 |
+
|
| 147 |
+
# Persist to Supabase (best-effort)
|
| 148 |
+
if settings.enable_db_persistence:
|
| 149 |
+
repo.save_validation_result(result)
|
| 150 |
+
|
| 151 |
+
return result
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ---- Bulk Email Validation ----------------------------------------
|
| 155 |
+
@router.post(
|
| 156 |
+
"/validate/bulk",
|
| 157 |
+
summary="Validate multiple email addresses (async)",
|
| 158 |
+
tags=["Validation"]
|
| 159 |
+
)
|
| 160 |
+
async def validate_bulk(request: BulkEmailRequest):
|
| 161 |
+
"""
|
| 162 |
+
Async bulk validation. Returns a task_id.
|
| 163 |
+
Poll /validate/bulk/status/{task_id} for results.
|
| 164 |
+
|
| 165 |
+
Uses Celery when Redis is available; otherwise falls back to a
|
| 166 |
+
synchronous pass (now supports any batch size).
|
| 167 |
+
"""
|
| 168 |
+
print(f"[BULK] Received bulk validation request for {len(request.emails)} emails")
|
| 169 |
+
|
| 170 |
+
# Only use Celery when the broker is actually reachable — otherwise
|
| 171 |
+
# .delay() would block retrying the Redis connection.
|
| 172 |
+
try:
|
| 173 |
+
from ..utils.cache import redis_available
|
| 174 |
+
use_celery = (settings.celery_bulk_enabled
|
| 175 |
+
and await redis_available())
|
| 176 |
+
print(f"[BULK] Redis available: {use_celery}")
|
| 177 |
+
except Exception as e:
|
| 178 |
+
print(f"[BULK] Redis check failed: {str(e)}")
|
| 179 |
+
use_celery = False
|
| 180 |
+
|
| 181 |
+
if use_celery:
|
| 182 |
+
try:
|
| 183 |
+
task = bulk_validate_task.delay(request.emails, request.webhook_url, request.deep)
|
| 184 |
+
# Record the job in Supabase so it can be tracked across restarts
|
| 185 |
+
if settings.enable_db_persistence:
|
| 186 |
+
repo.create_bulk_job(task.id, request.emails)
|
| 187 |
+
return BulkValidationResponse(
|
| 188 |
+
task_id=task.id,
|
| 189 |
+
status="processing",
|
| 190 |
+
total=len(request.emails),
|
| 191 |
+
message=f"Processing {len(request.emails)} emails. Poll /validate/bulk/status/{task.id}"
|
| 192 |
+
)
|
| 193 |
+
except Exception as exc:
|
| 194 |
+
print(f"[BULK] Celery unavailable ({exc}) — using synchronous fallback.")
|
| 195 |
+
|
| 196 |
+
# Synchronous fallback for any size list — but NON-BLOCKING: the request
|
| 197 |
+
# returns immediately with a task_id, and the results are processed in a
|
| 198 |
+
# background asyncio task. The old blocking behavior held the HTTP request
|
| 199 |
+
# for the entire validation (60-112s on a blocked-egress host), which
|
| 200 |
+
# exceeded the frontend's 45s fetch timeout and made the Results page
|
| 201 |
+
# show nothing.
|
| 202 |
+
task_id = uuid.uuid4().hex
|
| 203 |
+
print(f"[BULK] Processing {len(request.emails)} emails in background (task {task_id})")
|
| 204 |
+
|
| 205 |
+
# Record the job BEFORE processing: without this row, finish_bulk_job is an
|
| 206 |
+
# UPDATE that matches nothing, and in-flight polls hit the "Redis down"
|
| 207 |
+
# branch and 404 — which the frontend treats as terminal.
|
| 208 |
+
if settings.enable_db_persistence:
|
| 209 |
+
try:
|
| 210 |
+
repo.create_bulk_job(task_id, request.emails)
|
| 211 |
+
except Exception as exc:
|
| 212 |
+
print(f"[BULK] create_bulk_job failed: {exc}")
|
| 213 |
+
|
| 214 |
+
async def _run_and_store():
|
| 215 |
+
async with _bulk_semaphore:
|
| 216 |
+
try:
|
| 217 |
+
results = await process_bulk_sync(
|
| 218 |
+
request.emails, request.webhook_url, deep=request.deep
|
| 219 |
+
)
|
| 220 |
+
if settings.enable_db_persistence:
|
| 221 |
+
try:
|
| 222 |
+
repo.finish_bulk_job(task_id, results)
|
| 223 |
+
except Exception as exc:
|
| 224 |
+
print(f"[BULK] finish_bulk_job failed: {exc}")
|
| 225 |
+
await set_cache(
|
| 226 |
+
f"bulk_result:{task_id}",
|
| 227 |
+
json.dumps({"status": "completed", "total": len(request.emails),
|
| 228 |
+
"results": results}),
|
| 229 |
+
ttl=settings.redis_cache_ttl,
|
| 230 |
+
)
|
| 231 |
+
print(f"[BULK] task {task_id} completed: {len(results)} results")
|
| 232 |
+
except Exception as exc:
|
| 233 |
+
print(f"[BULK] task {task_id} failed: {exc}")
|
| 234 |
+
await set_cache(
|
| 235 |
+
f"bulk_result:{task_id}",
|
| 236 |
+
json.dumps({"status": "error", "error": str(exc),
|
| 237 |
+
"results": []}),
|
| 238 |
+
ttl=settings.redis_cache_ttl,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# The loop holds only a WEAK reference to tasks: without a strong ref the
|
| 242 |
+
# job can be garbage-collected mid-run and its results silently lost.
|
| 243 |
+
task = asyncio.create_task(_run_and_store())
|
| 244 |
+
_background_tasks.add(task)
|
| 245 |
+
task.add_done_callback(_background_tasks.discard)
|
| 246 |
+
|
| 247 |
+
return JSONResponse(content={
|
| 248 |
+
"task_id": task_id,
|
| 249 |
+
"status": "processing",
|
| 250 |
+
"total": len(request.emails),
|
| 251 |
+
"message": f"Processing {len(request.emails)} emails. Poll /validate/bulk/status/{task_id}",
|
| 252 |
+
})
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@router.get("/validate/bulk/status/{task_id}", response_model=TaskStatusResponse, tags=["Validation"])
|
| 256 |
+
async def get_bulk_status(task_id: str):
|
| 257 |
+
"""Poll the status of a bulk validation task."""
|
| 258 |
+
from ..utils.cache import redis_available
|
| 259 |
+
|
| 260 |
+
def _row_to_result(row):
|
| 261 |
+
"""Best-effort coercion of a stored/fallback row into a ValidationResult."""
|
| 262 |
+
if row is None or not isinstance(row, dict):
|
| 263 |
+
return None
|
| 264 |
+
if "scoring" not in row and "score" in row:
|
| 265 |
+
row = {
|
| 266 |
+
**row,
|
| 267 |
+
"status": row.get("status") or "unknown",
|
| 268 |
+
"scoring": {
|
| 269 |
+
"score": row.get("score") or 0,
|
| 270 |
+
"risk_level": row.get("risk_level") or "low",
|
| 271 |
+
},
|
| 272 |
+
}
|
| 273 |
+
try:
|
| 274 |
+
return ValidationResult(**row)
|
| 275 |
+
except Exception:
|
| 276 |
+
return None
|
| 277 |
+
|
| 278 |
+
def _normalize_results(raw):
|
| 279 |
+
rows = raw if isinstance(raw, list) else []
|
| 280 |
+
results = [r for r in (_row_to_result(row) for row in rows) if r is not None]
|
| 281 |
+
return results or None # None when absent → serialized as "results": null
|
| 282 |
+
|
| 283 |
+
# Background-processed bulk results (the non-Celery async path) are stored
|
| 284 |
+
# in the shared cache — check it FIRST so polling works for every task id.
|
| 285 |
+
stored = await get_cache(f"bulk_result:{task_id}")
|
| 286 |
+
if stored:
|
| 287 |
+
try:
|
| 288 |
+
data = json.loads(stored)
|
| 289 |
+
return TaskStatusResponse(
|
| 290 |
+
task_id=task_id,
|
| 291 |
+
status=data.get("status", "completed"),
|
| 292 |
+
progress=data.get("total"),
|
| 293 |
+
total=data.get("total"),
|
| 294 |
+
results=_normalize_results(data.get("results")),
|
| 295 |
+
)
|
| 296 |
+
except Exception:
|
| 297 |
+
pass # corrupt payload -> fall through to the other backends
|
| 298 |
+
|
| 299 |
+
if task_id == "sync":
|
| 300 |
+
# Sync-fallback results are persisted next to the "sync" job row; read
|
| 301 |
+
# them back so polling keeps working after the original response.
|
| 302 |
+
if settings.enable_db_persistence:
|
| 303 |
+
job = repo.get_bulk_job(task_id)
|
| 304 |
+
if job:
|
| 305 |
+
return TaskStatusResponse(
|
| 306 |
+
task_id=task_id,
|
| 307 |
+
status=job.get("status", "completed") or "completed",
|
| 308 |
+
progress=job.get("processed"),
|
| 309 |
+
total=job.get("total"),
|
| 310 |
+
results=_normalize_results(job.get("results")),
|
| 311 |
+
)
|
| 312 |
+
return TaskStatusResponse(task_id=task_id, status="completed")
|
| 313 |
+
if not await redis_available():
|
| 314 |
+
# Redis down → fall back to Supabase job tracking (never hang on the backend)
|
| 315 |
+
if settings.enable_db_persistence:
|
| 316 |
+
job = repo.get_bulk_job(task_id)
|
| 317 |
+
if job:
|
| 318 |
+
return TaskStatusResponse(
|
| 319 |
+
task_id=task_id,
|
| 320 |
+
status=job.get("status", "unknown"),
|
| 321 |
+
progress=job.get("processed"),
|
| 322 |
+
total=job.get("total"),
|
| 323 |
+
results=_normalize_results(job.get("results"))
|
| 324 |
+
)
|
| 325 |
+
# An in-flight background job has no cached result yet. Report
|
| 326 |
+
# "processing" instead of 404 — the frontend treats 404 as terminal and
|
| 327 |
+
# would abandon a job that is still running.
|
| 328 |
+
if any(not t.done() for t in _background_tasks):
|
| 329 |
+
return TaskStatusResponse(task_id=task_id, status="processing")
|
| 330 |
+
raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
|
| 331 |
+
|
| 332 |
+
try:
|
| 333 |
+
from celery.result import AsyncResult
|
| 334 |
+
result = AsyncResult(task_id)
|
| 335 |
+
payload = {
|
| 336 |
+
"task_id": task_id,
|
| 337 |
+
"status": result.status.lower(),
|
| 338 |
+
}
|
| 339 |
+
# Surface live progress while the task is still running. Celery's
|
| 340 |
+
# PROGRESS state stores {"current": n, "total": n} in result.info.
|
| 341 |
+
if result.status == "PROGRESS" and isinstance(result.info, dict):
|
| 342 |
+
payload["progress"] = result.info.get("current")
|
| 343 |
+
payload["total"] = result.info.get("total")
|
| 344 |
+
if result.ready():
|
| 345 |
+
raw = result.result if isinstance(result.result, list) else []
|
| 346 |
+
payload["results"] = _normalize_results(raw)
|
| 347 |
+
# Persist finished job so it survives worker restarts
|
| 348 |
+
if settings.enable_db_persistence:
|
| 349 |
+
repo.finish_bulk_job(task_id, raw)
|
| 350 |
+
return TaskStatusResponse(**payload)
|
| 351 |
+
except Exception as e:
|
| 352 |
+
# Fallback: read job progress from Supabase
|
| 353 |
+
if settings.enable_db_persistence:
|
| 354 |
+
job = repo.get_bulk_job(task_id)
|
| 355 |
+
if job:
|
| 356 |
+
return TaskStatusResponse(
|
| 357 |
+
task_id=task_id,
|
| 358 |
+
status=job.get("status", "unknown"),
|
| 359 |
+
progress=job.get("processed"),
|
| 360 |
+
total=job.get("total"),
|
| 361 |
+
results=_normalize_results(job.get("results"))
|
| 362 |
+
)
|
| 363 |
+
raise HTTPException(status_code=404, detail=f"Task not found: {str(e)}")
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
# ---- Domain Info ---------------------------------------------------
|
| 367 |
+
@router.get("/domain/{domain}", summary="Get full DNS/authentication info for a domain", tags=["Domain"])
|
| 368 |
+
async def get_domain_info(domain: str):
|
| 369 |
+
"""Get MX, SPF, DMARC, DKIM records for a domain."""
|
| 370 |
+
from ..validator.dns_check import check_dns
|
| 371 |
+
result = await check_dns(domain, timeout=settings.dns_timeout)
|
| 372 |
+
if settings.enable_db_persistence:
|
| 373 |
+
repo.save_domain_record(domain, result)
|
| 374 |
+
return result
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
# ---- Quick Check (Syntax + DNS only, no SMTP) ---------------------
|
| 378 |
+
@router.get("/validate/quick/{email}", summary="Quick syntax + DNS check (no SMTP)", tags=["Validation"])
|
| 379 |
+
async def quick_validate(email: str):
|
| 380 |
+
"""Syntax + DNS only. Fast. No SMTP probe."""
|
| 381 |
+
result = await engine.validate(email, deep=False)
|
| 382 |
+
return result
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
# ---- Health Check --------------------------------------------------
|
| 386 |
+
@router.get("/health", tags=["System"])
|
| 387 |
+
async def health():
|
| 388 |
+
db = await repo.db_status()
|
| 389 |
+
|
| 390 |
+
# Check Redis and Celery status
|
| 391 |
+
redis_status = False
|
| 392 |
+
celery_status = False
|
| 393 |
+
try:
|
| 394 |
+
from ..utils.cache import redis_available
|
| 395 |
+
redis_status = await redis_available()
|
| 396 |
+
except Exception:
|
| 397 |
+
pass
|
| 398 |
+
|
| 399 |
+
if redis_status:
|
| 400 |
+
try:
|
| 401 |
+
from celery import Celery
|
| 402 |
+
test_celery = Celery(
|
| 403 |
+
"health_check",
|
| 404 |
+
broker=settings.celery_broker_url,
|
| 405 |
+
backend=settings.celery_result_backend
|
| 406 |
+
)
|
| 407 |
+
# Try to inspect workers
|
| 408 |
+
inspect = test_celery.control.inspect(timeout=2)
|
| 409 |
+
celery_status = inspect.stats() is not None
|
| 410 |
+
except Exception:
|
| 411 |
+
pass
|
| 412 |
+
|
| 413 |
+
# Autonomous-engine worker summary (spec §3): never fatal, best-effort.
|
| 414 |
+
workers = {
|
| 415 |
+
"dns": {"doh_enabled": settings.doh_enabled},
|
| 416 |
+
"smtp": {
|
| 417 |
+
"provider": engine.mailbox.name,
|
| 418 |
+
"worker_configured": bool(settings.smtp_worker_url),
|
| 419 |
+
},
|
| 420 |
+
"ai_scorer": {
|
| 421 |
+
"configured": bool(settings.ai_scorer_model and
|
| 422 |
+
(settings.hf_inference_token or settings.ml_hf_token)),
|
| 423 |
+
},
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
return {
|
| 427 |
+
"status": "healthy",
|
| 428 |
+
"app": settings.app_name,
|
| 429 |
+
"version": settings.app_version,
|
| 430 |
+
"database": db,
|
| 431 |
+
"redis": {"available": redis_status},
|
| 432 |
+
"celery": {"available": celery_status},
|
| 433 |
+
"workers": workers,
|
| 434 |
+
"timestamp": time.time()
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
# ---- Database Status ------------------------------------------------
|
| 439 |
+
@router.get("/db/status", tags=["System"], response_model=dict)
|
| 440 |
+
async def db_status_endpoint():
|
| 441 |
+
"""Supabase connectivity + schema readiness."""
|
| 442 |
+
return await repo.db_status()
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
# ---- ML advisory diagnostics ----------------------------------------
|
| 446 |
+
@router.get("/ml/status", tags=["System"])
|
| 447 |
+
async def ml_status():
|
| 448 |
+
"""Operability of the advisory DistilBERT layer (flag / repo / load state)."""
|
| 449 |
+
from ..config import settings
|
| 450 |
+
from ..ml.runtime import get_model
|
| 451 |
+
|
| 452 |
+
model = get_model()
|
| 453 |
+
sample = None
|
| 454 |
+
if model is not None and model.loaded:
|
| 455 |
+
try:
|
| 456 |
+
sample = model.predict(["john.smith [SEP] stripe.com"])
|
| 457 |
+
except Exception as exc: # pragma: no cover - defensive
|
| 458 |
+
sample = [f"predict raised: {exc}"]
|
| 459 |
+
return {
|
| 460 |
+
"enabled": settings.ml_disposable_enabled,
|
| 461 |
+
"repo": settings.ml_model_repo,
|
| 462 |
+
"loaded": bool(model is not None and model.loaded),
|
| 463 |
+
"last_error": getattr(model, "last_error", None) if model else None,
|
| 464 |
+
"predict_sample": sample,
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
# ---- History --------------------------------------------------------
|
| 469 |
+
@router.get("/history", tags=["Validation"])
|
| 470 |
+
async def get_history(limit: int = Query(default=50, ge=1, le=500)):
|
| 471 |
+
"""Most recent validations persisted in Supabase (newest first)."""
|
| 472 |
+
return repo.get_validation_history(limit=limit)
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
# ---- Stats ---------------------------------------------------------
|
| 476 |
+
@router.get("/stats", tags=["System"])
|
| 477 |
+
async def stats():
|
| 478 |
+
from ..validator.proxy_engine import proxy_pool
|
| 479 |
+
db_stats = repo.get_stats()
|
| 480 |
+
return {
|
| 481 |
+
"proxy_pool": proxy_pool.stats,
|
| 482 |
+
"database": db_stats,
|
| 483 |
+
"version": settings.app_version
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
# ---- Autonomous engine status (spec §3: four-worker visibility) -----
|
| 488 |
+
@router.get("/engine/status", tags=["System"])
|
| 489 |
+
async def engine_status():
|
| 490 |
+
"""Live state of every autonomous-engine worker (no PII)."""
|
| 491 |
+
from ..validator.egress import check_port25_egress
|
| 492 |
+
|
| 493 |
+
egress = await asyncio.to_thread(check_port25_egress)
|
| 494 |
+
scorer_status = engine.scorer.status()
|
| 495 |
+
worker_url = settings.smtp_worker_url
|
| 496 |
+
return {
|
| 497 |
+
"version": settings.app_version,
|
| 498 |
+
"dns": {
|
| 499 |
+
"doh_enabled": settings.doh_enabled,
|
| 500 |
+
"resolvers": ["dns.google", "cloudflare-dns.com"],
|
| 501 |
+
"dual_consistency": settings.doh_enabled,
|
| 502 |
+
},
|
| 503 |
+
"smtp": {
|
| 504 |
+
"provider": engine.mailbox.name,
|
| 505 |
+
"helo_domain": settings.smtp_helo_domain,
|
| 506 |
+
"remote_worker": (
|
| 507 |
+
{"configured": True, "host": urlsplit(worker_url).hostname}
|
| 508 |
+
if worker_url
|
| 509 |
+
else {"configured": False}
|
| 510 |
+
),
|
| 511 |
+
"egress_port25": egress.as_dict(),
|
| 512 |
+
},
|
| 513 |
+
"ai_scorer": scorer_status,
|
| 514 |
+
"catch_all_ttl_seconds": settings.catch_all_ttl_seconds,
|
| 515 |
+
"dns_cache_ttl_seconds": settings.dns_cache_ttl_seconds,
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
# ---- Inference burn-rate instrumentation (spec Phase 6) --------------
|
| 520 |
+
@router.get("/metrics/inference", tags=["System"])
|
| 521 |
+
async def inference_metrics():
|
| 522 |
+
"""AI-scorer burn rate against the <5% target (spec Phase 6)."""
|
| 523 |
+
return engine.scorer.metrics.as_dict()
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
# ---- SMTP probe worker endpoints (spec §3.3) ------------------------
|
| 527 |
+
# Any deployment of this image can serve as the port-25 SMTP worker for an
|
| 528 |
+
# orchestrator whose own egress is blocked. Guarded by a shared secret
|
| 529 |
+
# (X-Worker-Token) when SMTP_WORKER_TOKEN is configured on either side.
|
| 530 |
+
async def _require_worker_token(request: Request) -> None:
|
| 531 |
+
expected = settings.smtp_worker_token
|
| 532 |
+
if not expected:
|
| 533 |
+
return
|
| 534 |
+
supplied = request.headers.get("x-worker-token", "")
|
| 535 |
+
if supplied != expected:
|
| 536 |
+
raise HTTPException(status_code=401, detail="worker token invalid")
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
class WorkerProbeRequest(BaseModel):
|
| 540 |
+
email: Optional[str] = None
|
| 541 |
+
domain: Optional[str] = None
|
| 542 |
+
mx_host: str
|
| 543 |
+
probe: str = "mailbox" # "mailbox" | "catch_all"
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
class WorkerBatchRequest(BaseModel):
|
| 547 |
+
pairs: List[List[str]]
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
@router.post("/smtp/probe", tags=["Worker"])
|
| 551 |
+
async def smtp_probe(request: Request, body: WorkerProbeRequest):
|
| 552 |
+
"""One mailbox or catch-all probe from THIS host's port-25 egress."""
|
| 553 |
+
await _require_worker_token(request)
|
| 554 |
+
from ..validator.mailbox_provider import AutonomousSmtpProvider
|
| 555 |
+
|
| 556 |
+
worker = AutonomousSmtpProvider()
|
| 557 |
+
if body.probe == "catch_all":
|
| 558 |
+
if not body.domain:
|
| 559 |
+
raise HTTPException(status_code=422, detail="domain required for catch_all probe")
|
| 560 |
+
is_ca, confidence = await worker.catch_all(body.domain, body.mx_host)
|
| 561 |
+
return {"is_catch_all": is_ca, "confidence": confidence}
|
| 562 |
+
if not body.email:
|
| 563 |
+
raise HTTPException(status_code=422, detail="email required for mailbox probe")
|
| 564 |
+
result = await worker.verify(body.email, body.mx_host)
|
| 565 |
+
return json.loads(result.model_dump_json())
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
@router.post("/smtp/probe/batch", tags=["Worker"])
|
| 569 |
+
async def smtp_probe_batch(request: Request, body: WorkerBatchRequest):
|
| 570 |
+
"""Batched mailbox probes (one session per receiving MX host)."""
|
| 571 |
+
await _require_worker_token(request)
|
| 572 |
+
from ..validator.mailbox_provider import AutonomousSmtpProvider
|
| 573 |
+
|
| 574 |
+
worker = AutonomousSmtpProvider()
|
| 575 |
+
pairs = [(row[0], row[1]) for row in body.pairs if len(row) >= 2]
|
| 576 |
+
results, catch_all = await worker.verify_many(pairs)
|
| 577 |
+
return {
|
| 578 |
+
"results": {e: json.loads(r.model_dump_json()) for e, r in results.items()},
|
| 579 |
+
"catch_all": catch_all,
|
| 580 |
+
}
|
|
@@ -1,177 +1,189 @@
|
|
| 1 |
-
"""Supabase JWT verification + license gating for the paid API.
|
| 2 |
-
|
| 3 |
-
The central Supabase project issues the session JWT; this backend verifies
|
| 4 |
-
it against the project's PUBLIC JWKS endpoint (no shared database needed).
|
| 5 |
-
License rows are read from the central project via its service-role REST
|
| 6 |
-
API (server-side only).
|
| 7 |
-
|
| 8 |
-
Enforcement is OFF by default (LICENSE_ENFORCEMENT != "true") so existing
|
| 9 |
-
open/demo deployments keep working. Turn it on at go-live:
|
| 10 |
-
|
| 11 |
-
SUPABASE_URL=https://ciytvulujiqemqljablq.supabase.co
|
| 12 |
-
SUPABASE_SERVICE_ROLE_KEY=<service role key, server only>
|
| 13 |
-
LICENSE_ENFORCEMENT=true
|
| 14 |
-
|
| 15 |
-
Frontend equivalent lives in the FortyDN website repo (src/lib/productApi.ts).
|
| 16 |
-
Follows docs/AUTH_SETUP.md section 3 (Python snippet).
|
| 17 |
-
"""
|
| 18 |
-
|
| 19 |
-
import logging
|
| 20 |
-
from typing import Optional
|
| 21 |
-
|
| 22 |
-
import httpx
|
| 23 |
-
from fastapi import Request
|
| 24 |
-
from fastapi.responses import JSONResponse
|
| 25 |
-
from jose import jwt as jose_jwt
|
| 26 |
-
from jose import jwk as jose_jwk
|
| 27 |
-
from jose.exceptions import JWTError
|
| 28 |
-
|
| 29 |
-
from .config import settings
|
| 30 |
-
|
| 31 |
-
logger = logging.getLogger("emailvalidator.auth")
|
| 32 |
-
|
| 33 |
-
CENTRAL_SUPABASE_URL = (
|
| 34 |
-
settings.supabase_url or "https://ciytvulujiqemqljablq.supabase.co"
|
| 35 |
-
)
|
| 36 |
-
JWKS_URL = f"{CENTRAL_SUPABASE_URL}/auth/v1/.well-known/jwks.json"
|
| 37 |
-
|
| 38 |
-
# Modul-X is unlocked directly, via Validate+Send, or via Full Bridge.
|
| 39 |
-
MODULX_GRANTS = frozenset(
|
| 40 |
-
{
|
| 41 |
-
"modul-x",
|
| 42 |
-
"combo-validate-send",
|
| 43 |
-
"combo-full-bridge",
|
| 44 |
-
}
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
_jwks_cache: Optional[dict] = None
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def enforcement_on() -> bool:
|
| 51 |
-
return (settings.license_enforcement or "").lower() == "true"
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
async def _get_jwks() -> dict:
|
| 55 |
-
"""Fetch (and cache) the Supabase JWKS document."""
|
| 56 |
-
global _jwks_cache
|
| 57 |
-
if _jwks_cache is None:
|
| 58 |
-
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 59 |
-
res = await client.get(JWKS_URL)
|
| 60 |
-
res.raise_for_status()
|
| 61 |
-
_jwks_cache = res.json()
|
| 62 |
-
return _jwks_cache # type: ignore[return-value]
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def _bearer_token(request: Request) -> Optional[str]:
|
| 66 |
-
header = request.headers.get("authorization", "")
|
| 67 |
-
if not header.startswith("Bearer "):
|
| 68 |
-
return None
|
| 69 |
-
token = header[7:].strip()
|
| 70 |
-
return token if token else None
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
async def verify_supabase_jwt(token: str) -> dict:
|
| 74 |
-
"""Verify the Supabase session JWT via JWKS. Returns claims (sub = user_id)."""
|
| 75 |
-
jwks = await _get_jwks()
|
| 76 |
-
# Supabase rotates keys; try each until one verifies.
|
| 77 |
-
last_error: Optional[Exception] = None
|
| 78 |
-
for key_data in jwks.get("keys", []):
|
| 79 |
-
try:
|
| 80 |
-
key = jose_jwk.construct(key_data)
|
| 81 |
-
claims = jose_jwt.decode(
|
| 82 |
-
token,
|
| 83 |
-
key,
|
| 84 |
-
algorithms=["ES256", "RS256"],
|
| 85 |
-
audience="authenticated",
|
| 86 |
-
)
|
| 87 |
-
if not claims.get("sub"):
|
| 88 |
-
raise JWTError("JWT has no sub claim")
|
| 89 |
-
return claims
|
| 90 |
-
except JWTError as exc:
|
| 91 |
-
last_error = exc
|
| 92 |
-
continue
|
| 93 |
-
raise JWTError(f"JWT verification failed: {last_error}")
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
async def has_modulx_license(user_id: str) -> bool:
|
| 97 |
-
"""Check for an active Modul-X license via the central service-role API."""
|
| 98 |
-
service_key = settings.supabase_service_role_key
|
| 99 |
-
if not service_key:
|
| 100 |
-
logger.warning("[auth] SUPABASE_SERVICE_ROLE_KEY not set - cannot check licenses.")
|
| 101 |
-
return False
|
| 102 |
-
params = {
|
| 103 |
-
"user_id": f"eq.{user_id}",
|
| 104 |
-
"status": "eq.active",
|
| 105 |
-
"select": "product",
|
| 106 |
-
}
|
| 107 |
-
try:
|
| 108 |
-
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 109 |
-
res = await client.get(
|
| 110 |
-
f"{CENTRAL_SUPABASE_URL}/rest/v1/licenses",
|
| 111 |
-
params=params,
|
| 112 |
-
headers={
|
| 113 |
-
"apikey": service_key,
|
| 114 |
-
"Authorization": f"Bearer {service_key}",
|
| 115 |
-
},
|
| 116 |
-
)
|
| 117 |
-
if res.status_code != 200:
|
| 118 |
-
# Missing table (migrations not applied yet) => fail closed with a hint.
|
| 119 |
-
logger.warning(
|
| 120 |
-
"[auth] license lookup HTTP %s: %s",
|
| 121 |
-
res.status_code,
|
| 122 |
-
res.text[:200],
|
| 123 |
-
)
|
| 124 |
-
return False
|
| 125 |
-
rows = res.json()
|
| 126 |
-
return isinstance(rows, list) and any(
|
| 127 |
-
isinstance(r, dict) and r.get("product") in MODULX_GRANTS for r in rows
|
| 128 |
-
)
|
| 129 |
-
except Exception as exc:
|
| 130 |
-
logger.warning("[auth] license lookup error: %s", exc)
|
| 131 |
-
return False
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
# Paths that stay open (health probes, docs, root)
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
"
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
if
|
| 153 |
-
return
|
| 154 |
-
path
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Supabase JWT verification + license gating for the paid API.
|
| 2 |
+
|
| 3 |
+
The central Supabase project issues the session JWT; this backend verifies
|
| 4 |
+
it against the project's PUBLIC JWKS endpoint (no shared database needed).
|
| 5 |
+
License rows are read from the central project via its service-role REST
|
| 6 |
+
API (server-side only).
|
| 7 |
+
|
| 8 |
+
Enforcement is OFF by default (LICENSE_ENFORCEMENT != "true") so existing
|
| 9 |
+
open/demo deployments keep working. Turn it on at go-live:
|
| 10 |
+
|
| 11 |
+
SUPABASE_URL=https://ciytvulujiqemqljablq.supabase.co
|
| 12 |
+
SUPABASE_SERVICE_ROLE_KEY=<service role key, server only>
|
| 13 |
+
LICENSE_ENFORCEMENT=true
|
| 14 |
+
|
| 15 |
+
Frontend equivalent lives in the FortyDN website repo (src/lib/productApi.ts).
|
| 16 |
+
Follows docs/AUTH_SETUP.md section 3 (Python snippet).
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
from typing import Optional
|
| 21 |
+
|
| 22 |
+
import httpx
|
| 23 |
+
from fastapi import Request
|
| 24 |
+
from fastapi.responses import JSONResponse
|
| 25 |
+
from jose import jwt as jose_jwt
|
| 26 |
+
from jose import jwk as jose_jwk
|
| 27 |
+
from jose.exceptions import JWTError
|
| 28 |
+
|
| 29 |
+
from .config import settings
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger("emailvalidator.auth")
|
| 32 |
+
|
| 33 |
+
CENTRAL_SUPABASE_URL = (
|
| 34 |
+
settings.supabase_url or "https://ciytvulujiqemqljablq.supabase.co"
|
| 35 |
+
)
|
| 36 |
+
JWKS_URL = f"{CENTRAL_SUPABASE_URL}/auth/v1/.well-known/jwks.json"
|
| 37 |
+
|
| 38 |
+
# Modul-X is unlocked directly, via Validate+Send, or via Full Bridge.
|
| 39 |
+
MODULX_GRANTS = frozenset(
|
| 40 |
+
{
|
| 41 |
+
"modul-x",
|
| 42 |
+
"combo-validate-send",
|
| 43 |
+
"combo-full-bridge",
|
| 44 |
+
}
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
_jwks_cache: Optional[dict] = None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def enforcement_on() -> bool:
|
| 51 |
+
return (settings.license_enforcement or "").lower() == "true"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def _get_jwks() -> dict:
|
| 55 |
+
"""Fetch (and cache) the Supabase JWKS document."""
|
| 56 |
+
global _jwks_cache
|
| 57 |
+
if _jwks_cache is None:
|
| 58 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 59 |
+
res = await client.get(JWKS_URL)
|
| 60 |
+
res.raise_for_status()
|
| 61 |
+
_jwks_cache = res.json()
|
| 62 |
+
return _jwks_cache # type: ignore[return-value]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _bearer_token(request: Request) -> Optional[str]:
|
| 66 |
+
header = request.headers.get("authorization", "")
|
| 67 |
+
if not header.startswith("Bearer "):
|
| 68 |
+
return None
|
| 69 |
+
token = header[7:].strip()
|
| 70 |
+
return token if token else None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
async def verify_supabase_jwt(token: str) -> dict:
|
| 74 |
+
"""Verify the Supabase session JWT via JWKS. Returns claims (sub = user_id)."""
|
| 75 |
+
jwks = await _get_jwks()
|
| 76 |
+
# Supabase rotates keys; try each until one verifies.
|
| 77 |
+
last_error: Optional[Exception] = None
|
| 78 |
+
for key_data in jwks.get("keys", []):
|
| 79 |
+
try:
|
| 80 |
+
key = jose_jwk.construct(key_data)
|
| 81 |
+
claims = jose_jwt.decode(
|
| 82 |
+
token,
|
| 83 |
+
key,
|
| 84 |
+
algorithms=["ES256", "RS256"],
|
| 85 |
+
audience="authenticated",
|
| 86 |
+
)
|
| 87 |
+
if not claims.get("sub"):
|
| 88 |
+
raise JWTError("JWT has no sub claim")
|
| 89 |
+
return claims
|
| 90 |
+
except JWTError as exc:
|
| 91 |
+
last_error = exc
|
| 92 |
+
continue
|
| 93 |
+
raise JWTError(f"JWT verification failed: {last_error}")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
async def has_modulx_license(user_id: str) -> bool:
|
| 97 |
+
"""Check for an active Modul-X license via the central service-role API."""
|
| 98 |
+
service_key = settings.supabase_service_role_key
|
| 99 |
+
if not service_key:
|
| 100 |
+
logger.warning("[auth] SUPABASE_SERVICE_ROLE_KEY not set - cannot check licenses.")
|
| 101 |
+
return False
|
| 102 |
+
params = {
|
| 103 |
+
"user_id": f"eq.{user_id}",
|
| 104 |
+
"status": "eq.active",
|
| 105 |
+
"select": "product",
|
| 106 |
+
}
|
| 107 |
+
try:
|
| 108 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 109 |
+
res = await client.get(
|
| 110 |
+
f"{CENTRAL_SUPABASE_URL}/rest/v1/licenses",
|
| 111 |
+
params=params,
|
| 112 |
+
headers={
|
| 113 |
+
"apikey": service_key,
|
| 114 |
+
"Authorization": f"Bearer {service_key}",
|
| 115 |
+
},
|
| 116 |
+
)
|
| 117 |
+
if res.status_code != 200:
|
| 118 |
+
# Missing table (migrations not applied yet) => fail closed with a hint.
|
| 119 |
+
logger.warning(
|
| 120 |
+
"[auth] license lookup HTTP %s: %s",
|
| 121 |
+
res.status_code,
|
| 122 |
+
res.text[:200],
|
| 123 |
+
)
|
| 124 |
+
return False
|
| 125 |
+
rows = res.json()
|
| 126 |
+
return isinstance(rows, list) and any(
|
| 127 |
+
isinstance(r, dict) and r.get("product") in MODULX_GRANTS for r in rows
|
| 128 |
+
)
|
| 129 |
+
except Exception as exc:
|
| 130 |
+
logger.warning("[auth] license lookup error: %s", exc)
|
| 131 |
+
return False
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# Paths that stay open (health probes, docs, root) plus the autonomous-engine
|
| 135 |
+
# ops surfaces: /engine/status + /metrics/inference are aggregate diagnostics
|
| 136 |
+
# (no PII), and /smtp/probe* enforce their own shared-secret guard
|
| 137 |
+
# (X-Worker-Token) — license JWTs do not apply to machine-to-machine probes.
|
| 138 |
+
_OPEN_PREFIXES = (
|
| 139 |
+
"/api/v1/health",
|
| 140 |
+
"/api/health",
|
| 141 |
+
"/api/v1/engine/status",
|
| 142 |
+
"/api/v1/metrics",
|
| 143 |
+
"/api/v1/smtp/probe",
|
| 144 |
+
"/docs",
|
| 145 |
+
"/redoc",
|
| 146 |
+
"/openapi.json",
|
| 147 |
+
)
|
| 148 |
+
_OPEN_EXACT = frozenset({"/", "/api/v1/health", "/api/health"})
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _is_open_path(path: str) -> bool:
|
| 152 |
+
if path in _OPEN_EXACT:
|
| 153 |
+
return True
|
| 154 |
+
return any(path.startswith(p) and path != p for p in _OPEN_PREFIXES)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
async def license_middleware(request: Request, call_next):
|
| 158 |
+
"""FastAPI HTTP middleware: JWT + license gate for paid routes.
|
| 159 |
+
|
| 160 |
+
Mirrors the Express pattern (Modul-S/E): /api/v1/health stays open,
|
| 161 |
+
everything else under /api requires a valid JWT + active license.
|
| 162 |
+
No-op while LICENSE_ENFORCEMENT != "true" (demo mode).
|
| 163 |
+
"""
|
| 164 |
+
if not enforcement_on():
|
| 165 |
+
return await call_next(request)
|
| 166 |
+
path = request.url.path
|
| 167 |
+
if _is_open_path(path) or not path.startswith("/api"):
|
| 168 |
+
return await call_next(request)
|
| 169 |
+
token = _bearer_token(request)
|
| 170 |
+
if not token:
|
| 171 |
+
return JSONResponse(
|
| 172 |
+
status_code=401,
|
| 173 |
+
content={"detail": "Missing bearer token - sign in first."},
|
| 174 |
+
)
|
| 175 |
+
try:
|
| 176 |
+
claims = await verify_supabase_jwt(token)
|
| 177 |
+
except JWTError:
|
| 178 |
+
return JSONResponse(
|
| 179 |
+
status_code=401,
|
| 180 |
+
content={"detail": "Invalid or expired token."},
|
| 181 |
+
)
|
| 182 |
+
user_id = claims.get("sub", "")
|
| 183 |
+
if not user_id or not await has_modulx_license(user_id):
|
| 184 |
+
return JSONResponse(
|
| 185 |
+
status_code=402,
|
| 186 |
+
content={"detail": "No active Modul-X license - see /pricing."},
|
| 187 |
+
)
|
| 188 |
+
request.state.auth = {"user_id": user_id, "email": claims.get("email")}
|
| 189 |
+
return await call_next(request)
|
|
@@ -1,169 +1,190 @@
|
|
| 1 |
-
from urllib.parse import urlsplit
|
| 2 |
-
|
| 3 |
-
from pydantic import model_validator
|
| 4 |
-
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
-
from typing import List
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
def _is_https_origin(value: str) -> bool:
|
| 9 |
-
try:
|
| 10 |
-
parsed = urlsplit(value)
|
| 11 |
-
_ = parsed.port
|
| 12 |
-
except ValueError:
|
| 13 |
-
return False
|
| 14 |
-
return (
|
| 15 |
-
parsed.scheme == "https"
|
| 16 |
-
and bool(parsed.hostname)
|
| 17 |
-
and parsed.username is None
|
| 18 |
-
and parsed.password is None
|
| 19 |
-
and parsed.path in {"", "/"}
|
| 20 |
-
and not parsed.query
|
| 21 |
-
and not parsed.fragment
|
| 22 |
-
)
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
class Settings(BaseSettings):
|
| 26 |
-
model_config = SettingsConfigDict(
|
| 27 |
-
env_file=".env",
|
| 28 |
-
env_file_encoding="utf-8",
|
| 29 |
-
case_sensitive=False,
|
| 30 |
-
extra="ignore",
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
# App
|
| 34 |
-
app_environment: str = "development"
|
| 35 |
-
app_name: str = "BRIDGE Modul - X"
|
| 36 |
-
app_version: str = "
|
| 37 |
-
debug: bool = False
|
| 38 |
-
secret_key: str = "change-me-in-production"
|
| 39 |
-
|
| 40 |
-
# Server
|
| 41 |
-
host: str = "0.0.0.0"
|
| 42 |
-
port: int = 8000
|
| 43 |
-
workers: int = 4
|
| 44 |
-
|
| 45 |
-
# Database
|
| 46 |
-
database_url: str = "sqlite+aiosqlite:///./emailval.db"
|
| 47 |
-
|
| 48 |
-
# Redis
|
| 49 |
-
redis_url: str = "redis://localhost:6379/0"
|
| 50 |
-
redis_cache_ttl: int = 86400 # 24 hours
|
| 51 |
-
|
| 52 |
-
# CORS
|
| 53 |
-
cors_origins: List[str] = [
|
| 54 |
-
"http://localhost:5173",
|
| 55 |
-
"http://localhost:3000",
|
| 56 |
-
"http://127.0.0.1:5173",
|
| 57 |
-
]
|
| 58 |
-
|
| 59 |
-
# Validation
|
| 60 |
-
smtp_timeout: int = 10
|
| 61 |
-
dns_timeout: int = 5
|
| 62 |
-
# Public resolvers used instead of the host's own resolver config. A stale
|
| 63 |
-
# gateway list (dead hotspot IPs) makes every lookup burn the full timeout
|
| 64 |
-
# and fail — see dns_check.build_resolver.
|
| 65 |
-
dns_nameservers: list[str] = ["1.1.1.1", "8.8.8.8"]
|
| 66 |
-
# Bulk validation only routes through Celery on hosts that actually run a
|
| 67 |
-
# worker. The HF Space runs uvicorn alone (Dockerfile CMD), so a reachable
|
| 68 |
-
# Redis must not by itself select the Celery branch — jobs would sit in
|
| 69 |
-
# the queue forever. Opt in on the VPS deployment.
|
| 70 |
-
celery_bulk_enabled: bool = False
|
| 71 |
-
max_bulk_emails: int = 10000
|
| 72 |
-
rate_limit_per_minute: int = 60
|
| 73 |
-
bulk_sync_chunk_size: int = 50 # Process this many emails at a time in sync mode
|
| 74 |
-
|
| 75 |
-
# Proxy
|
| 76 |
-
use_proxies: bool = False
|
| 77 |
-
proxy_list: List[str] = []
|
| 78 |
-
|
| 79 |
-
# Mailbox verification provider (Layer 5 catch-all + Layer 6 SMTP).
|
| 80 |
-
#
|
| 81 |
-
#
|
| 82 |
-
#
|
| 83 |
-
#
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
#
|
| 87 |
-
#
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
#
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
#
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
#
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
#
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
#
|
| 113 |
-
#
|
| 114 |
-
#
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
#
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from urllib.parse import urlsplit
|
| 2 |
+
|
| 3 |
+
from pydantic import model_validator
|
| 4 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
+
from typing import List
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _is_https_origin(value: str) -> bool:
|
| 9 |
+
try:
|
| 10 |
+
parsed = urlsplit(value)
|
| 11 |
+
_ = parsed.port
|
| 12 |
+
except ValueError:
|
| 13 |
+
return False
|
| 14 |
+
return (
|
| 15 |
+
parsed.scheme == "https"
|
| 16 |
+
and bool(parsed.hostname)
|
| 17 |
+
and parsed.username is None
|
| 18 |
+
and parsed.password is None
|
| 19 |
+
and parsed.path in {"", "/"}
|
| 20 |
+
and not parsed.query
|
| 21 |
+
and not parsed.fragment
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Settings(BaseSettings):
|
| 26 |
+
model_config = SettingsConfigDict(
|
| 27 |
+
env_file=".env",
|
| 28 |
+
env_file_encoding="utf-8",
|
| 29 |
+
case_sensitive=False,
|
| 30 |
+
extra="ignore",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# App
|
| 34 |
+
app_environment: str = "development"
|
| 35 |
+
app_name: str = "BRIDGE Modul - X"
|
| 36 |
+
app_version: str = "3.0.0"
|
| 37 |
+
debug: bool = False
|
| 38 |
+
secret_key: str = "change-me-in-production"
|
| 39 |
+
|
| 40 |
+
# Server
|
| 41 |
+
host: str = "0.0.0.0"
|
| 42 |
+
port: int = 8000
|
| 43 |
+
workers: int = 4
|
| 44 |
+
|
| 45 |
+
# Database
|
| 46 |
+
database_url: str = "sqlite+aiosqlite:///./emailval.db"
|
| 47 |
+
|
| 48 |
+
# Redis
|
| 49 |
+
redis_url: str = "redis://localhost:6379/0"
|
| 50 |
+
redis_cache_ttl: int = 86400 # 24 hours
|
| 51 |
+
|
| 52 |
+
# CORS
|
| 53 |
+
cors_origins: List[str] = [
|
| 54 |
+
"http://localhost:5173",
|
| 55 |
+
"http://localhost:3000",
|
| 56 |
+
"http://127.0.0.1:5173",
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
# Validation
|
| 60 |
+
smtp_timeout: int = 10
|
| 61 |
+
dns_timeout: int = 5
|
| 62 |
+
# Public resolvers used instead of the host's own resolver config. A stale
|
| 63 |
+
# gateway list (dead hotspot IPs) makes every lookup burn the full timeout
|
| 64 |
+
# and fail — see dns_check.build_resolver.
|
| 65 |
+
dns_nameservers: list[str] = ["1.1.1.1", "8.8.8.8"]
|
| 66 |
+
# Bulk validation only routes through Celery on hosts that actually run a
|
| 67 |
+
# worker. The HF Space runs uvicorn alone (Dockerfile CMD), so a reachable
|
| 68 |
+
# Redis must not by itself select the Celery branch — jobs would sit in
|
| 69 |
+
# the queue forever. Opt in on the VPS deployment.
|
| 70 |
+
celery_bulk_enabled: bool = False
|
| 71 |
+
max_bulk_emails: int = 10000
|
| 72 |
+
rate_limit_per_minute: int = 60
|
| 73 |
+
bulk_sync_chunk_size: int = 50 # Process this many emails at a time in sync mode
|
| 74 |
+
|
| 75 |
+
# Proxy
|
| 76 |
+
use_proxies: bool = False
|
| 77 |
+
proxy_list: List[str] = []
|
| 78 |
+
|
| 79 |
+
# Mailbox verification provider (Layer 5 catch-all + Layer 6 SMTP).
|
| 80 |
+
# The engine is FULLY AUTONOMOUS: mailbox verdicts come from its own SMTP
|
| 81 |
+
# probing engine — never from a third-party verification API. "local"
|
| 82 |
+
# forces the built-in engine; "remote" forces the remote SMTP worker;
|
| 83 |
+
# "auto" prefers the remote worker when SMTP_WORKER_URL is set and falls
|
| 84 |
+
# back to the built-in engine otherwise.
|
| 85 |
+
mailbox_provider: str = "auto" # auto | local | remote
|
| 86 |
+
# Public HELO/MX identity used in the SMTP dialogue. A real, resolvable
|
| 87 |
+
# hostname with matching PTR improves acceptance by strict receivers.
|
| 88 |
+
smtp_helo_domain: str = "validator.local"
|
| 89 |
+
smtp_mail_from: str = "probe@validator.local"
|
| 90 |
+
# Remote SMTP probe worker (spec §3.3): orchestrator and SMTP worker are
|
| 91 |
+
# separate deployments. Point this at a port-25-capable host running this
|
| 92 |
+
# same image (its POST /api/v1/smtp/probe endpoints) and every mailbox
|
| 93 |
+
# verdict flows through it. Shared secret via SMTP_WORKER_TOKEN.
|
| 94 |
+
smtp_worker_url: str = ""
|
| 95 |
+
smtp_worker_token: str = ""
|
| 96 |
+
smtp_worker_timeout: int = 15
|
| 97 |
+
# Circuit breaker: after this many consecutive direct-SMTP connection
|
| 98 |
+
# failures (blocked egress), fail fast for `smtp_cooldown_seconds` instead
|
| 99 |
+
# of burning the full timeout on every address.
|
| 100 |
+
smtp_fail_threshold: int = 3
|
| 101 |
+
smtp_cooldown_seconds: int = 300
|
| 102 |
+
# Catch-all is a domain property — cache the verdict for 72h so a bulk list
|
| 103 |
+
# does not re-probe the same domain for every address.
|
| 104 |
+
catch_all_ttl_seconds: int = 72 * 3600
|
| 105 |
+
# DNS/MX answers change slowly; a definitive mailbox verdict is stable for
|
| 106 |
+
# about a day. Transient failures are never cached regardless of TTL.
|
| 107 |
+
dns_cache_ttl_seconds: int = 24 * 3600
|
| 108 |
+
# Per-MX-host SMTP session cap and global concurrency for bulk runs.
|
| 109 |
+
max_connections_per_host: int = 2
|
| 110 |
+
bulk_concurrency: int = 20
|
| 111 |
+
|
| 112 |
+
# --- DNS-over-HTTPS worker (spec §3.2) ---
|
| 113 |
+
# MX resolution over free public DoH resolvers (Google + Cloudflare) with a
|
| 114 |
+
# dual-resolver consistency check. Legacy dnspython stays as fallback.
|
| 115 |
+
doh_enabled: bool = True
|
| 116 |
+
doh_timeout: int = 5
|
| 117 |
+
|
| 118 |
+
# --- AI Risk Scorer (spec §3.4 — the ONLY metered component) ---
|
| 119 |
+
# Hugging Face Inference API text-classification model id. Empty disables
|
| 120 |
+
# remote inference entirely (the rule-based heuristic then scores every
|
| 121 |
+
# ambiguous address at zero marginal cost). Configured, never hard-coded.
|
| 122 |
+
ai_scorer_model: str = ""
|
| 123 |
+
ai_scorer_timeout: int = 12
|
| 124 |
+
# Zero-shot mode: candidate labels ARE the risk taxonomy — works with any
|
| 125 |
+
# served NLI model (e.g. facebook/bart-large-mnli) on the Inference API.
|
| 126 |
+
ai_scorer_zero_shot: bool = False
|
| 127 |
+
hf_inference_token: str = "" # falls back to ml_hf_token when empty
|
| 128 |
+
ai_circuit_fail_threshold: int = 3
|
| 129 |
+
ai_circuit_cooldown_seconds: int = 300
|
| 130 |
+
|
| 131 |
+
# --- DistilBERT email-risk signal (advisory, unknown domains only) ---
|
| 132 |
+
# OFF by default: with the flag off the engine's behaviour is identical to
|
| 133 |
+
# the pre-ML build. The model is a private HF repo trained on the engine's
|
| 134 |
+
# own blocklists (see app/ml/). Promotion from advisory to blocking requires
|
| 135 |
+
# measured DISPOSABLE precision >= 0.98 (app/ml/train.py reports it).
|
| 136 |
+
ml_disposable_enabled: bool = False
|
| 137 |
+
ml_model_repo: str = ""
|
| 138 |
+
ml_batch_size: int = 64
|
| 139 |
+
ml_score_penalty: int = 5
|
| 140 |
+
ml_hf_token: str = ""
|
| 141 |
+
|
| 142 |
+
# Run-quality monitor (report item 10): alert when >= this share of a bulk
|
| 143 |
+
# run comes back risky/unknown/error — the signature of a systemic failure
|
| 144 |
+
# (dead provider, blocked egress, engine regression). 0 disables it.
|
| 145 |
+
risky_rate_alert_threshold: float = 0.5
|
| 146 |
+
|
| 147 |
+
# Celery
|
| 148 |
+
celery_broker_url: str = "redis://localhost:6379/1"
|
| 149 |
+
celery_result_backend: str = "redis://localhost:6379/2"
|
| 150 |
+
|
| 151 |
+
# Supabase (Postgres + PostgREST)
|
| 152 |
+
supabase_url: str = ""
|
| 153 |
+
supabase_anon_key: str = ""
|
| 154 |
+
supabase_service_role_key: str = ""
|
| 155 |
+
enable_db_persistence: bool = True
|
| 156 |
+
|
| 157 |
+
# Central billing gate: "true" enforces Supabase JWT + active license
|
| 158 |
+
# (modul-x directly, via combo-validate-send, or via combo-full-bridge)
|
| 159 |
+
# on every paid API route. Anything else = open demo mode.
|
| 160 |
+
license_enforcement: str = ""
|
| 161 |
+
|
| 162 |
+
@model_validator(mode="after")
|
| 163 |
+
def validate_production_settings(self) -> "Settings":
|
| 164 |
+
if self.app_environment.strip().casefold() != "production":
|
| 165 |
+
return self
|
| 166 |
+
|
| 167 |
+
problems = []
|
| 168 |
+
if self.debug:
|
| 169 |
+
problems.append("DEBUG must be false")
|
| 170 |
+
if self.license_enforcement.strip().casefold() != "true":
|
| 171 |
+
problems.append("LICENSE_ENFORCEMENT must be true")
|
| 172 |
+
if not self.supabase_configured:
|
| 173 |
+
problems.append("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required")
|
| 174 |
+
elif not _is_https_origin(self.supabase_url):
|
| 175 |
+
problems.append("SUPABASE_URL must be an HTTPS URL")
|
| 176 |
+
if not self.cors_origins or not all(
|
| 177 |
+
_is_https_origin(origin) for origin in self.cors_origins
|
| 178 |
+
):
|
| 179 |
+
problems.append("CORS_ORIGINS must contain only HTTPS origins")
|
| 180 |
+
|
| 181 |
+
if problems:
|
| 182 |
+
raise ValueError("Unsafe production configuration: " + "; ".join(problems))
|
| 183 |
+
return self
|
| 184 |
+
|
| 185 |
+
@property
|
| 186 |
+
def supabase_configured(self) -> bool:
|
| 187 |
+
return bool(self.supabase_url and self.supabase_service_role_key)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
settings = Settings()
|
|
@@ -1,186 +1,186 @@
|
|
| 1 |
-
from fastapi import FastAPI, Request
|
| 2 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
-
from fastapi.responses import JSONResponse
|
| 4 |
-
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 5 |
-
from slowapi.util import get_remote_address
|
| 6 |
-
from slowapi.errors import RateLimitExceeded
|
| 7 |
-
from contextlib import asynccontextmanager
|
| 8 |
-
import asyncio
|
| 9 |
-
import time
|
| 10 |
-
|
| 11 |
-
from .config import settings
|
| 12 |
-
from .api.routes import router
|
| 13 |
-
from .auth import license_middleware
|
| 14 |
-
from .utils.logger import setup_logging
|
| 15 |
-
from .db import repo
|
| 16 |
-
|
| 17 |
-
# Setup structured logging
|
| 18 |
-
setup_logging()
|
| 19 |
-
|
| 20 |
-
# Rate limiter
|
| 21 |
-
limiter = Limiter(key_func=get_remote_address)
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
@asynccontextmanager
|
| 25 |
-
async def lifespan(app: FastAPI):
|
| 26 |
-
"""Startup and shutdown events."""
|
| 27 |
-
print(f"BRIDGE Modul - X v{settings.app_version} starting up...")
|
| 28 |
-
# Verify Supabase connectivity at boot (best-effort, never fatal)
|
| 29 |
-
if settings.supabase_configured:
|
| 30 |
-
try:
|
| 31 |
-
status = await repo.db_status()
|
| 32 |
-
if status.get("reachable"):
|
| 33 |
-
print(f"[DB] Supabase reachable — tables_ready={status.get('tables_ready')}")
|
| 34 |
-
if not status.get("tables_ready"):
|
| 35 |
-
print("[DB] WARNING: run supabase/schema.sql in the Supabase SQL editor "
|
| 36 |
-
"to create the tables.")
|
| 37 |
-
else:
|
| 38 |
-
print(f"[DB] WARNING: Supabase not reachable — {status.get('detail')}")
|
| 39 |
-
except Exception as exc: # pragma: no cover - defensive
|
| 40 |
-
print(f"[DB] WARNING: DB check failed — {exc}")
|
| 41 |
-
else:
|
| 42 |
-
print("[DB] Supabase not configured — persistence disabled (see .env.example)")
|
| 43 |
-
|
| 44 |
-
# Port-25 egress preflight: mailbox verification cannot work without it, so
|
| 45 |
-
# report it at boot instead of letting operators discover it from a wall of
|
| 46 |
-
# "smtp_unreachable" results. Best-effort and never fatal.
|
| 47 |
-
try:
|
| 48 |
-
from .validator.egress import preflight_or_warn
|
| 49 |
-
|
| 50 |
-
await asyncio.to_thread(preflight_or_warn)
|
| 51 |
-
except Exception as exc: # pragma: no cover - defensive
|
| 52 |
-
print(f"[EGRESS] preflight skipped — {exc}")
|
| 53 |
-
|
| 54 |
-
# Greylist retry-queue drain loop (in-process — the Space runs no Celery
|
| 55 |
-
# beat, so without this the queue would fill forever and greylisted
|
| 56 |
-
# mailboxes would stay permanently risky). Fail-open, never fatal.
|
| 57 |
-
drain_task = None
|
| 58 |
-
try:
|
| 59 |
-
from .tasks.drain_loop import DrainLoop
|
| 60 |
-
from .validator.engine import engine as _engine
|
| 61 |
-
|
| 62 |
-
async def _prober(email: str, mx_host: str):
|
| 63 |
-
return await _engine.mailbox.verify(email, mx_host)
|
| 64 |
-
|
| 65 |
-
loop = DrainLoop(prober=_prober, interval_seconds=300)
|
| 66 |
-
drain_task = asyncio.create_task(loop.start())
|
| 67 |
-
print("[RETRY] drain loop started (every 300s)")
|
| 68 |
-
except Exception as exc: # pragma: no cover - defensive
|
| 69 |
-
print(f"[RETRY] drain loop skipped — {exc}")
|
| 70 |
-
|
| 71 |
-
# Warm the advisory model at startup: the first request otherwise pays the
|
| 72 |
-
# torch + checkpoint load (measured 42.6s cold start) even though inference
|
| 73 |
-
# now runs off the event loop. Fail-open and never fatal.
|
| 74 |
-
try:
|
| 75 |
-
from .ml.runtime import get_model_async
|
| 76 |
-
|
| 77 |
-
model = await get_model_async()
|
| 78 |
-
if model is not None and getattr(model, "loaded", False):
|
| 79 |
-
print("[ML] advisory model preloaded")
|
| 80 |
-
else:
|
| 81 |
-
print("[ML] advisory model unavailable - deterministic mode")
|
| 82 |
-
except Exception as exc: # pragma: no cover - defensive
|
| 83 |
-
print(f"[ML] preload skipped — {exc}")
|
| 84 |
-
|
| 85 |
-
yield
|
| 86 |
-
|
| 87 |
-
if drain_task is not None:
|
| 88 |
-
drain_task.cancel()
|
| 89 |
-
print("BRIDGE Modul - X shutting down...")
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
# Create FastAPI app
|
| 93 |
-
app = FastAPI(
|
| 94 |
-
title=settings.app_name,
|
| 95 |
-
description="""
|
| 96 |
-
BRIDGE Modul - X — 7-Layer Email Validation Engine.
|
| 97 |
-
|
| 98 |
-
Features:
|
| 99 |
-
- Layer 1 — RFC 5321/5322 Syntax Validation
|
| 100 |
-
- Layer 2 — DNS Domain Existence Check
|
| 101 |
-
- Layer 3 — MX Record Verification
|
| 102 |
-
- Layer 4 — Disposable/Role-Based/Free Provider Detection
|
| 103 |
-
- Layer 5 — Catch-All Domain Detection
|
| 104 |
-
- Layer 6 — SMTP Mailbox Verification (with proxy support)
|
| 105 |
-
- Layer 7 — Deliverability Scoring & Risk Assessment
|
| 106 |
-
|
| 107 |
-
Provider support: Gmail, Outlook, Yahoo, iCloud, ProtonMail, Zoho, 100K+ domains
|
| 108 |
-
""",
|
| 109 |
-
version=settings.app_version,
|
| 110 |
-
docs_url="/docs",
|
| 111 |
-
redoc_url="/redoc",
|
| 112 |
-
lifespan=lifespan
|
| 113 |
-
)
|
| 114 |
-
|
| 115 |
-
# ---- Middleware -----------------------------------------------------
|
| 116 |
-
app.state.limiter = limiter
|
| 117 |
-
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 118 |
-
|
| 119 |
-
app.add_middleware(
|
| 120 |
-
CORSMiddleware,
|
| 121 |
-
allow_origins=settings.cors_origins,
|
| 122 |
-
allow_credentials=True,
|
| 123 |
-
allow_methods=["*"],
|
| 124 |
-
allow_headers=["*"],
|
| 125 |
-
expose_headers=["X-Process-Time"]
|
| 126 |
-
)
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
@app.middleware("http")
|
| 130 |
-
async def add_process_time_header(request: Request, call_next):
|
| 131 |
-
start_time = time.monotonic()
|
| 132 |
-
response = await call_next(request)
|
| 133 |
-
process_time = (time.monotonic() - start_time) * 1000
|
| 134 |
-
response.headers["X-Process-Time"] = f"{process_time:.2f}ms"
|
| 135 |
-
return response
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
# Central Supabase JWT + license gate for paid API routes.
|
| 139 |
-
# /api/v1/health and /api/health stay open (HF + website productApi probe).
|
| 140 |
-
# No-op while LICENSE_ENFORCEMENT != "true" (demo mode). See app/auth.py.
|
| 141 |
-
@app.middleware("http")
|
| 142 |
-
async def license_gate(request: Request, call_next):
|
| 143 |
-
return await license_middleware(request, call_next)
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
# Global error handler
|
| 147 |
-
@app.exception_handler(Exception)
|
| 148 |
-
async def global_exception_handler(request: Request, exc: Exception):
|
| 149 |
-
return JSONResponse(
|
| 150 |
-
status_code=500,
|
| 151 |
-
content={
|
| 152 |
-
"error": "Internal server error",
|
| 153 |
-
"detail": str(exc) if settings.debug else "Contact support"
|
| 154 |
-
}
|
| 155 |
-
)
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
# ---- Include Routes -------------------------------------------------
|
| 159 |
-
app.include_router(router, prefix="/api/v1")
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
# ---- Health alias for the website productApi probe -------------------
|
| 163 |
-
# productApi tries GET <backend>/api/health then /health. The versioned
|
| 164 |
-
# route lives at /api/v1/health; this alias keeps the standard probe path
|
| 165 |
-
# working (stays open via the license_gate allow-list in app/auth.py).
|
| 166 |
-
@app.get("/api/health", tags=["System"])
|
| 167 |
-
async def api_health_alias():
|
| 168 |
-
import time as _time
|
| 169 |
-
|
| 170 |
-
return {
|
| 171 |
-
"status": "healthy",
|
| 172 |
-
"app": settings.app_name,
|
| 173 |
-
"version": settings.app_version,
|
| 174 |
-
"timestamp": _time.time(),
|
| 175 |
-
}
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
# ---- Root -----------------------------------------------------------
|
| 179 |
-
@app.get("/")
|
| 180 |
-
async def root():
|
| 181 |
-
return {
|
| 182 |
-
"app": settings.app_name,
|
| 183 |
-
"version": settings.app_version,
|
| 184 |
-
"docs": "/docs",
|
| 185 |
-
"health": "/api/v1/health"
|
| 186 |
-
}
|
|
|
|
| 1 |
+
from fastapi import FastAPI, Request
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.responses import JSONResponse
|
| 4 |
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 5 |
+
from slowapi.util import get_remote_address
|
| 6 |
+
from slowapi.errors import RateLimitExceeded
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
import asyncio
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
from .config import settings
|
| 12 |
+
from .api.routes import router
|
| 13 |
+
from .auth import license_middleware
|
| 14 |
+
from .utils.logger import setup_logging
|
| 15 |
+
from .db import repo
|
| 16 |
+
|
| 17 |
+
# Setup structured logging
|
| 18 |
+
setup_logging()
|
| 19 |
+
|
| 20 |
+
# Rate limiter
|
| 21 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@asynccontextmanager
|
| 25 |
+
async def lifespan(app: FastAPI):
|
| 26 |
+
"""Startup and shutdown events."""
|
| 27 |
+
print(f"BRIDGE Modul - X v{settings.app_version} starting up...")
|
| 28 |
+
# Verify Supabase connectivity at boot (best-effort, never fatal)
|
| 29 |
+
if settings.supabase_configured:
|
| 30 |
+
try:
|
| 31 |
+
status = await repo.db_status()
|
| 32 |
+
if status.get("reachable"):
|
| 33 |
+
print(f"[DB] Supabase reachable — tables_ready={status.get('tables_ready')}")
|
| 34 |
+
if not status.get("tables_ready"):
|
| 35 |
+
print("[DB] WARNING: run supabase/schema.sql in the Supabase SQL editor "
|
| 36 |
+
"to create the tables.")
|
| 37 |
+
else:
|
| 38 |
+
print(f"[DB] WARNING: Supabase not reachable — {status.get('detail')}")
|
| 39 |
+
except Exception as exc: # pragma: no cover - defensive
|
| 40 |
+
print(f"[DB] WARNING: DB check failed — {exc}")
|
| 41 |
+
else:
|
| 42 |
+
print("[DB] Supabase not configured — persistence disabled (see .env.example)")
|
| 43 |
+
|
| 44 |
+
# Port-25 egress preflight: mailbox verification cannot work without it, so
|
| 45 |
+
# report it at boot instead of letting operators discover it from a wall of
|
| 46 |
+
# "smtp_unreachable" results. Best-effort and never fatal.
|
| 47 |
+
try:
|
| 48 |
+
from .validator.egress import preflight_or_warn
|
| 49 |
+
|
| 50 |
+
await asyncio.to_thread(preflight_or_warn)
|
| 51 |
+
except Exception as exc: # pragma: no cover - defensive
|
| 52 |
+
print(f"[EGRESS] preflight skipped — {exc}")
|
| 53 |
+
|
| 54 |
+
# Greylist retry-queue drain loop (in-process — the Space runs no Celery
|
| 55 |
+
# beat, so without this the queue would fill forever and greylisted
|
| 56 |
+
# mailboxes would stay permanently risky). Fail-open, never fatal.
|
| 57 |
+
drain_task = None
|
| 58 |
+
try:
|
| 59 |
+
from .tasks.drain_loop import DrainLoop
|
| 60 |
+
from .validator.engine import engine as _engine
|
| 61 |
+
|
| 62 |
+
async def _prober(email: str, mx_host: str):
|
| 63 |
+
return await _engine.mailbox.verify(email, mx_host)
|
| 64 |
+
|
| 65 |
+
loop = DrainLoop(prober=_prober, interval_seconds=300)
|
| 66 |
+
drain_task = asyncio.create_task(loop.start())
|
| 67 |
+
print("[RETRY] drain loop started (every 300s)")
|
| 68 |
+
except Exception as exc: # pragma: no cover - defensive
|
| 69 |
+
print(f"[RETRY] drain loop skipped — {exc}")
|
| 70 |
+
|
| 71 |
+
# Warm the advisory model at startup: the first request otherwise pays the
|
| 72 |
+
# torch + checkpoint load (measured 42.6s cold start) even though inference
|
| 73 |
+
# now runs off the event loop. Fail-open and never fatal.
|
| 74 |
+
try:
|
| 75 |
+
from .ml.runtime import get_model_async
|
| 76 |
+
|
| 77 |
+
model = await get_model_async()
|
| 78 |
+
if model is not None and getattr(model, "loaded", False):
|
| 79 |
+
print("[ML] advisory model preloaded")
|
| 80 |
+
else:
|
| 81 |
+
print("[ML] advisory model unavailable - deterministic mode")
|
| 82 |
+
except Exception as exc: # pragma: no cover - defensive
|
| 83 |
+
print(f"[ML] preload skipped — {exc}")
|
| 84 |
+
|
| 85 |
+
yield
|
| 86 |
+
|
| 87 |
+
if drain_task is not None:
|
| 88 |
+
drain_task.cancel()
|
| 89 |
+
print("BRIDGE Modul - X shutting down...")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# Create FastAPI app
|
| 93 |
+
app = FastAPI(
|
| 94 |
+
title=settings.app_name,
|
| 95 |
+
description="""
|
| 96 |
+
BRIDGE Modul - X — 7-Layer Email Validation Engine.
|
| 97 |
+
|
| 98 |
+
Features:
|
| 99 |
+
- Layer 1 — RFC 5321/5322 Syntax Validation
|
| 100 |
+
- Layer 2 — DNS Domain Existence Check
|
| 101 |
+
- Layer 3 — MX Record Verification
|
| 102 |
+
- Layer 4 — Disposable/Role-Based/Free Provider Detection
|
| 103 |
+
- Layer 5 — Catch-All Domain Detection
|
| 104 |
+
- Layer 6 — SMTP Mailbox Verification (with proxy support)
|
| 105 |
+
- Layer 7 — Deliverability Scoring & Risk Assessment
|
| 106 |
+
|
| 107 |
+
Provider support: Gmail, Outlook, Yahoo, iCloud, ProtonMail, Zoho, 100K+ domains
|
| 108 |
+
""",
|
| 109 |
+
version=settings.app_version,
|
| 110 |
+
docs_url="/docs",
|
| 111 |
+
redoc_url="/redoc",
|
| 112 |
+
lifespan=lifespan
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
# ---- Middleware -----------------------------------------------------
|
| 116 |
+
app.state.limiter = limiter
|
| 117 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 118 |
+
|
| 119 |
+
app.add_middleware(
|
| 120 |
+
CORSMiddleware,
|
| 121 |
+
allow_origins=settings.cors_origins,
|
| 122 |
+
allow_credentials=True,
|
| 123 |
+
allow_methods=["*"],
|
| 124 |
+
allow_headers=["*"],
|
| 125 |
+
expose_headers=["X-Process-Time"]
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@app.middleware("http")
|
| 130 |
+
async def add_process_time_header(request: Request, call_next):
|
| 131 |
+
start_time = time.monotonic()
|
| 132 |
+
response = await call_next(request)
|
| 133 |
+
process_time = (time.monotonic() - start_time) * 1000
|
| 134 |
+
response.headers["X-Process-Time"] = f"{process_time:.2f}ms"
|
| 135 |
+
return response
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# Central Supabase JWT + license gate for paid API routes.
|
| 139 |
+
# /api/v1/health and /api/health stay open (HF + website productApi probe).
|
| 140 |
+
# No-op while LICENSE_ENFORCEMENT != "true" (demo mode). See app/auth.py.
|
| 141 |
+
@app.middleware("http")
|
| 142 |
+
async def license_gate(request: Request, call_next):
|
| 143 |
+
return await license_middleware(request, call_next)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# Global error handler
|
| 147 |
+
@app.exception_handler(Exception)
|
| 148 |
+
async def global_exception_handler(request: Request, exc: Exception):
|
| 149 |
+
return JSONResponse(
|
| 150 |
+
status_code=500,
|
| 151 |
+
content={
|
| 152 |
+
"error": "Internal server error",
|
| 153 |
+
"detail": str(exc) if settings.debug else "Contact support"
|
| 154 |
+
}
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ---- Include Routes -------------------------------------------------
|
| 159 |
+
app.include_router(router, prefix="/api/v1")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ---- Health alias for the website productApi probe -------------------
|
| 163 |
+
# productApi tries GET <backend>/api/health then /health. The versioned
|
| 164 |
+
# route lives at /api/v1/health; this alias keeps the standard probe path
|
| 165 |
+
# working (stays open via the license_gate allow-list in app/auth.py).
|
| 166 |
+
@app.get("/api/health", tags=["System"])
|
| 167 |
+
async def api_health_alias():
|
| 168 |
+
import time as _time
|
| 169 |
+
|
| 170 |
+
return {
|
| 171 |
+
"status": "healthy",
|
| 172 |
+
"app": settings.app_name,
|
| 173 |
+
"version": settings.app_version,
|
| 174 |
+
"timestamp": _time.time(),
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ---- Root -----------------------------------------------------------
|
| 179 |
+
@app.get("/")
|
| 180 |
+
async def root():
|
| 181 |
+
return {
|
| 182 |
+
"app": settings.app_name,
|
| 183 |
+
"version": settings.app_version,
|
| 184 |
+
"docs": "/docs",
|
| 185 |
+
"health": "/api/v1/health"
|
| 186 |
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AI Risk Scorer — the single metered layer of the autonomous engine.
|
| 2 |
+
|
| 3 |
+
Architecture spec §3.4 / Phase 4 / Phase 6:
|
| 4 |
+
|
| 5 |
+
- Invoked ONLY for addresses the deterministic gauntlet left genuinely
|
| 6 |
+
ambiguous: catch-all domains and unknown/inconclusive SMTP outcomes. Every
|
| 7 |
+
deterministic-first address never reaches this module (target burn rate
|
| 8 |
+
< 5% of verifications, instrumented here).
|
| 9 |
+
- Primary path: Hugging Face Inference API (the PRO account's expanded rate
|
| 10 |
+
limits / inference credits). The model identifier is CONFIGURED, never
|
| 11 |
+
hard-coded (§5.3), and the feature string carries domain + SMTP metadata
|
| 12 |
+
only — never the full address (PII minimization, §7.4).
|
| 13 |
+
- Circuit breaker: consecutive 429 / 5xx / transport failures open the circuit
|
| 14 |
+
for a cooldown; an exhausted-quota or invalid-token signal opens it
|
| 15 |
+
immediately. While open, the scorer falls back to the local rule-based
|
| 16 |
+
heuristic and marks every verdict `fallback_used=True` (scored_fallback) —
|
| 17 |
+
a degraded engine must never starve the pipeline (§2.3).
|
| 18 |
+
- Heuristic fallback: deterministic feature rules (MX platform identity,
|
| 19 |
+
SMTP response class, catch-all flag, local-part entropy/role patterns).
|
| 20 |
+
It returns a probability, not a certainty (§5.2) — the engine never collapses
|
| 21 |
+
a catch-all into "valid" on the strength of a low score alone.
|
| 22 |
+
|
| 23 |
+
All counters live in `metrics` (process-local, exposed via /metrics/inference).
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import asyncio
|
| 28 |
+
import hashlib
|
| 29 |
+
import json
|
| 30 |
+
import re
|
| 31 |
+
import time
|
| 32 |
+
from dataclasses import dataclass, field
|
| 33 |
+
from typing import Any, Optional
|
| 34 |
+
|
| 35 |
+
import httpx
|
| 36 |
+
|
| 37 |
+
from ..config import settings
|
| 38 |
+
|
| 39 |
+
LOW_RISK = "low_risk"
|
| 40 |
+
MEDIUM_RISK = "medium_risk"
|
| 41 |
+
HIGH_RISK = "high_risk"
|
| 42 |
+
|
| 43 |
+
# Spec Phase 6: alert when the economics stop holding.
|
| 44 |
+
BURN_RATE_TARGET = 0.05
|
| 45 |
+
CATCH_ALL_BURN_ALERT = 0.15
|
| 46 |
+
|
| 47 |
+
# Label synonyms accepted from a configured text-classification model.
|
| 48 |
+
_HIGH_LABELS = {"high", "high_risk", "risky", "bad", "toxic", "spam", "disposable", "label_2"}
|
| 49 |
+
_MEDIUM_LABELS = {"medium", "medium_risk", "unknown", "catch_all", "label_1"}
|
| 50 |
+
_LOW_LABELS = {"low", "low_risk", "good", "ham", "safe", "valid", "ok", "label_0"}
|
| 51 |
+
|
| 52 |
+
_ROLE_PATTERN = re.compile(
|
| 53 |
+
r"^(admin|administrator|info|support|sales|contact|help|billing|noreply"
|
| 54 |
+
r"|no-reply|donotreply|webmaster|postmaster|hostmaster|office|team|mail)"
|
| 55 |
+
r"([.+_-].*)?$",
|
| 56 |
+
re.IGNORECASE,
|
| 57 |
+
)
|
| 58 |
+
_NUMERIC_PATTERN = re.compile(r"^\+?\d[\d\-_.]{3,}$")
|
| 59 |
+
_GIBBERISH_PATTERN = re.compile(r"^[a-z0-9]{1,3}[0-9]{4,}$|^[a-z]{1,2}[0-9]{6,}$", re.IGNORECASE)
|
| 60 |
+
|
| 61 |
+
_TRUSTED_MX_TOKENS = (
|
| 62 |
+
"google", "googlemail", "outlook", "hotmail", "microsoft", "protection.outlook",
|
| 63 |
+
"yahoo", "yahoodns", "icloud", "apple", "protonmail", "proton", "zoho",
|
| 64 |
+
"yandex", "mail.ru", "mimecast", "proofpoint", "barracuda", "pphosted",
|
| 65 |
+
"messagelabs", "symantec",
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _label_risk(label: str, confidence: float) -> Optional[float]:
|
| 70 |
+
"""Map a model label to a 0..1 risk score. None = unmappable."""
|
| 71 |
+
l = (label or "").strip().lower()
|
| 72 |
+
if l in _HIGH_LABELS:
|
| 73 |
+
return max(0.7, float(confidence))
|
| 74 |
+
if l in _MEDIUM_LABELS:
|
| 75 |
+
return max(0.4, min(0.69, float(confidence)))
|
| 76 |
+
if l in _LOW_LABELS:
|
| 77 |
+
return max(0.0, min(0.39, 1.0 - float(confidence)))
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _risk_from_payload(data: Any) -> Optional[float]:
|
| 82 |
+
"""Blend mapped label risks from either response shape.
|
| 83 |
+
|
| 84 |
+
text-classification: [[{"label", "score"}, ...]] or [{...}]
|
| 85 |
+
zero-shot: {"labels": [...], "scores": [...]}
|
| 86 |
+
Most-specific label wins with a 0.6/0.4 recency blend.
|
| 87 |
+
"""
|
| 88 |
+
pairs: list[tuple[str, float]] = []
|
| 89 |
+
if isinstance(data, dict) and data.get("labels"):
|
| 90 |
+
labels = data.get("labels") or []
|
| 91 |
+
scores = data.get("scores") or []
|
| 92 |
+
pairs = [
|
| 93 |
+
(str(l), float(s or 0)) for l, s in zip(labels, scores)
|
| 94 |
+
if isinstance(l, str) and isinstance(s, (int, float))
|
| 95 |
+
]
|
| 96 |
+
elif isinstance(data, list):
|
| 97 |
+
rows = data
|
| 98 |
+
flat = rows[0] if rows and isinstance(rows[0], list) else rows
|
| 99 |
+
if not flat or not isinstance(flat, list):
|
| 100 |
+
return None
|
| 101 |
+
for entry in flat:
|
| 102 |
+
if isinstance(entry, dict):
|
| 103 |
+
pairs.append(
|
| 104 |
+
(str(entry.get("label", "")), float(entry.get("score", 0) or 0))
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
weighted_num = 0.0
|
| 108 |
+
weighted_den = 0.0
|
| 109 |
+
for label, confidence in pairs:
|
| 110 |
+
score = _label_risk(label, confidence)
|
| 111 |
+
if score is not None:
|
| 112 |
+
weighted_num += score * confidence
|
| 113 |
+
weighted_den += confidence
|
| 114 |
+
if weighted_den <= 0.0:
|
| 115 |
+
return None
|
| 116 |
+
# Probability-weighted expectation over the mapped risk labels.
|
| 117 |
+
return weighted_num / weighted_den
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def label_for_score(score: float) -> str:
|
| 121 |
+
if score < 0.4:
|
| 122 |
+
return LOW_RISK
|
| 123 |
+
if score < 0.7:
|
| 124 |
+
return MEDIUM_RISK
|
| 125 |
+
return HIGH_RISK
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@dataclass
|
| 129 |
+
class AIScore:
|
| 130 |
+
score: float
|
| 131 |
+
label: str
|
| 132 |
+
source: str # "inference" | "heuristic"
|
| 133 |
+
fallback_used: bool
|
| 134 |
+
detail: str = ""
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@dataclass
|
| 138 |
+
class _CircuitBreaker:
|
| 139 |
+
fail_threshold: int
|
| 140 |
+
cooldown_seconds: float
|
| 141 |
+
consecutive_failures: int = 0
|
| 142 |
+
open_until: float = 0.0
|
| 143 |
+
open_reason: str = ""
|
| 144 |
+
permanently_open: bool = False
|
| 145 |
+
|
| 146 |
+
@property
|
| 147 |
+
def is_open(self) -> bool:
|
| 148 |
+
if self.permanently_open:
|
| 149 |
+
return True
|
| 150 |
+
return time.monotonic() < self.open_until
|
| 151 |
+
|
| 152 |
+
def record_failure(self, reason: str = "") -> None:
|
| 153 |
+
self.consecutive_failures += 1
|
| 154 |
+
self.open_reason = reason
|
| 155 |
+
if self.consecutive_failures >= self.fail_threshold:
|
| 156 |
+
self.open_until = time.monotonic() + self.cooldown_seconds
|
| 157 |
+
|
| 158 |
+
def record_quota_or_auth_failure(self, reason: str) -> None:
|
| 159 |
+
"""429-with-quota / 401 / 403 — retrying sooner cannot help."""
|
| 160 |
+
self.permanently_open = True
|
| 161 |
+
self.open_reason = reason
|
| 162 |
+
self.open_until = float("inf")
|
| 163 |
+
|
| 164 |
+
def record_success(self) -> None:
|
| 165 |
+
self.consecutive_failures = 0
|
| 166 |
+
self.open_until = 0.0
|
| 167 |
+
self.open_reason = ""
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@dataclass
|
| 171 |
+
class ScorerMetrics:
|
| 172 |
+
verifications: int = 0
|
| 173 |
+
inference_calls: int = 0
|
| 174 |
+
heuristic_calls: int = 0
|
| 175 |
+
fallback_events: int = 0
|
| 176 |
+
circuit_opens: int = 0
|
| 177 |
+
last_error: str = ""
|
| 178 |
+
last_latency_ms: float = 0.0
|
| 179 |
+
last_used_at: float = 0.0
|
| 180 |
+
recent_domains: list = field(default_factory=list)
|
| 181 |
+
|
| 182 |
+
def as_dict(self) -> dict:
|
| 183 |
+
burn = (
|
| 184 |
+
self.inference_calls / self.verifications
|
| 185 |
+
if self.verifications
|
| 186 |
+
else 0.0
|
| 187 |
+
)
|
| 188 |
+
return {
|
| 189 |
+
"verifications_scored": self.verifications,
|
| 190 |
+
"inference_calls": self.inference_calls,
|
| 191 |
+
"heuristic_calls": self.heuristic_calls,
|
| 192 |
+
"fallback_events": self.fallback_events,
|
| 193 |
+
"circuit_opens": self.circuit_opens,
|
| 194 |
+
"burn_rate": round(burn, 4),
|
| 195 |
+
"burn_rate_target": BURN_RATE_TARGET,
|
| 196 |
+
"burn_rate_ok": burn <= BURN_RATE_TARGET,
|
| 197 |
+
"burn_alert": burn > CATCH_ALL_BURN_ALERT,
|
| 198 |
+
"last_error": self.last_error,
|
| 199 |
+
"last_latency_ms": round(self.last_latency_ms, 1),
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class AIRiskScorer:
|
| 204 |
+
"""Pattern-based deliverability risk classifier (not an oracle, §5.2)."""
|
| 205 |
+
|
| 206 |
+
def __init__(
|
| 207 |
+
self,
|
| 208 |
+
model: Optional[str] = None,
|
| 209 |
+
token: Optional[str] = None,
|
| 210 |
+
timeout: Optional[float] = None,
|
| 211 |
+
fail_threshold: Optional[int] = None,
|
| 212 |
+
cooldown: Optional[float] = None,
|
| 213 |
+
):
|
| 214 |
+
self.model = model if model is not None else settings.ai_scorer_model
|
| 215 |
+
self.token = (
|
| 216 |
+
token
|
| 217 |
+
if token is not None
|
| 218 |
+
else (settings.hf_inference_token or settings.ml_hf_token)
|
| 219 |
+
)
|
| 220 |
+
self.timeout = timeout if timeout is not None else settings.ai_scorer_timeout
|
| 221 |
+
self.breaker = _CircuitBreaker(
|
| 222 |
+
fail_threshold=(
|
| 223 |
+
fail_threshold
|
| 224 |
+
if fail_threshold is not None
|
| 225 |
+
else settings.ai_circuit_fail_threshold
|
| 226 |
+
),
|
| 227 |
+
cooldown_seconds=(
|
| 228 |
+
cooldown if cooldown is not None else settings.ai_circuit_cooldown_seconds
|
| 229 |
+
),
|
| 230 |
+
)
|
| 231 |
+
self.metrics = ScorerMetrics()
|
| 232 |
+
|
| 233 |
+
# ---- feature string (domain + SMTP metadata only, never the address) ----
|
| 234 |
+
@staticmethod
|
| 235 |
+
def build_features(
|
| 236 |
+
*,
|
| 237 |
+
domain: str,
|
| 238 |
+
mx_host: str | None,
|
| 239 |
+
smtp_class: str,
|
| 240 |
+
catch_all: bool | None,
|
| 241 |
+
local_part: str = "",
|
| 242 |
+
dns_inconsistent: bool = False,
|
| 243 |
+
) -> str:
|
| 244 |
+
local_shape = "normal"
|
| 245 |
+
if local_part:
|
| 246 |
+
if _ROLE_PATTERN.match(local_part):
|
| 247 |
+
local_shape = "role"
|
| 248 |
+
elif _NUMERIC_PATTERN.match(local_part) or _GIBBERISH_PATTERN.match(local_part):
|
| 249 |
+
local_shape = "high_entropy"
|
| 250 |
+
mx_identity = (mx_host or "none").lower()
|
| 251 |
+
trusted = any(t in mx_identity for t in _TRUSTED_MX_TOKENS)
|
| 252 |
+
mx_class = "trusted_platform" if trusted else "other"
|
| 253 |
+
return (
|
| 254 |
+
f"domain={domain} mx={mx_class} smtp={smtp_class} "
|
| 255 |
+
f"catch_all={'yes' if catch_all else ('no' if catch_all is False else 'unknown')} "
|
| 256 |
+
f"local={local_shape} dns_consistent={'no' if dns_inconsistent else 'yes'}"
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
# ---- heuristic fallback (deterministic, always available) ---------------
|
| 260 |
+
def heuristic(self, features: str) -> AIScore:
|
| 261 |
+
risk = 0.35 # ambiguous-but-alive baseline (spec: risky bucket territory)
|
| 262 |
+
if "smtp=4xx" in features:
|
| 263 |
+
risk = 0.52 # greylist / temporary — genuinely undetermined
|
| 264 |
+
elif "smtp=unavailable" in features or "smtp=transport_error" in features:
|
| 265 |
+
risk = 0.48
|
| 266 |
+
elif "smtp=accept" in features:
|
| 267 |
+
risk = 0.42
|
| 268 |
+
if "catch_all=yes" in features:
|
| 269 |
+
risk += 0.12
|
| 270 |
+
if "catch_all=unknown" in features:
|
| 271 |
+
risk += 0.05
|
| 272 |
+
if "mx=trusted_platform" in features:
|
| 273 |
+
risk -= 0.15
|
| 274 |
+
if "local=role" in features:
|
| 275 |
+
risk += 0.05
|
| 276 |
+
if "local=high_entropy" in features:
|
| 277 |
+
risk += 0.08
|
| 278 |
+
if "dns_consistent=no" in features:
|
| 279 |
+
risk += 0.1
|
| 280 |
+
score = round(max(0.0, min(1.0, risk)), 3)
|
| 281 |
+
return AIScore(
|
| 282 |
+
score=score,
|
| 283 |
+
label=label_for_score(score),
|
| 284 |
+
source="heuristic",
|
| 285 |
+
fallback_used=True,
|
| 286 |
+
detail="rule-based fallback (circuit open or inference disabled)",
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
# ---- Hugging Face Inference API ----------------------------------------
|
| 290 |
+
def _inference_configured(self) -> bool:
|
| 291 |
+
return bool(self.model and self.token)
|
| 292 |
+
|
| 293 |
+
async def _infer(self, features: str) -> AIScore:
|
| 294 |
+
"""One HF Inference API call. Raises on any failure (caller decides)."""
|
| 295 |
+
url = f"https://router.huggingface.co/hf-inference/models/{self.model}"
|
| 296 |
+
if settings.ai_scorer_zero_shot:
|
| 297 |
+
# Zero-shot NLI classification: the candidate labels ARE the risk
|
| 298 |
+
# taxonomy, so any served NLI model can score the feature string.
|
| 299 |
+
payload = {
|
| 300 |
+
"inputs": features,
|
| 301 |
+
"parameters": {
|
| 302 |
+
"candidate_labels": ["low_risk", "medium_risk", "high_risk"],
|
| 303 |
+
"multi_label": False,
|
| 304 |
+
},
|
| 305 |
+
}
|
| 306 |
+
else:
|
| 307 |
+
payload = {"inputs": features, "parameters": {"top_k": 3}}
|
| 308 |
+
headers = {"Authorization": f"Bearer {self.token}"}
|
| 309 |
+
started = time.monotonic()
|
| 310 |
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
| 311 |
+
resp = await client.post(url, json=payload, headers=headers)
|
| 312 |
+
self.metrics.last_latency_ms = (time.monotonic() - started) * 1000
|
| 313 |
+
|
| 314 |
+
if resp.status_code in (401, 403):
|
| 315 |
+
raise _QuotaOrAuthError(f"inference auth failed: HTTP {resp.status_code}")
|
| 316 |
+
if resp.status_code in (402,):
|
| 317 |
+
# Payment required: PRO inference credits exhausted. Retrying
|
| 318 |
+
# cannot help until credits reset — open the breaker permanently.
|
| 319 |
+
raise _QuotaOrAuthError(
|
| 320 |
+
f"inference quota exhausted: HTTP 402 {resp.text[:150]}"
|
| 321 |
+
)
|
| 322 |
+
if resp.status_code == 429:
|
| 323 |
+
body = resp.text[:200]
|
| 324 |
+
if "quota" in body.lower() or "credit" in body.lower():
|
| 325 |
+
raise _QuotaOrAuthError(f"inference quota exhausted: {body}")
|
| 326 |
+
raise _TransientInferenceError("rate limited: HTTP 429")
|
| 327 |
+
if resp.status_code >= 400:
|
| 328 |
+
raise _TransientInferenceError(f"inference HTTP {resp.status_code}: {resp.text[:200]}")
|
| 329 |
+
|
| 330 |
+
data = resp.json()
|
| 331 |
+
risk = _risk_from_payload(data)
|
| 332 |
+
if risk is None:
|
| 333 |
+
raise _TransientInferenceError("inference: unmappable labels")
|
| 334 |
+
risk = round(max(0.0, min(1.0, risk)), 3)
|
| 335 |
+
return AIScore(
|
| 336 |
+
score=risk,
|
| 337 |
+
label=label_for_score(risk),
|
| 338 |
+
source="inference",
|
| 339 |
+
fallback_used=False,
|
| 340 |
+
detail=f"model={self.model}",
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
# ---- public API ---------------------------------------------------------
|
| 344 |
+
async def score(self, features: str) -> AIScore:
|
| 345 |
+
"""Score one feature string. Never raises — degrades to the heuristic."""
|
| 346 |
+
self.metrics.verifications += 1
|
| 347 |
+
|
| 348 |
+
if not self._inference_configured() or self.breaker.is_open:
|
| 349 |
+
out = self.heuristic(features)
|
| 350 |
+
self.metrics.heuristic_calls += 1
|
| 351 |
+
if self._inference_configured() and self.breaker.is_open:
|
| 352 |
+
self.metrics.fallback_events += 1
|
| 353 |
+
return out
|
| 354 |
+
|
| 355 |
+
try:
|
| 356 |
+
out = await self._infer(features)
|
| 357 |
+
except _QuotaOrAuthError as exc:
|
| 358 |
+
self.metrics.last_error = str(exc)
|
| 359 |
+
self.breaker.record_quota_or_auth_failure(str(exc))
|
| 360 |
+
self.metrics.circuit_opens += 1
|
| 361 |
+
self.metrics.fallback_events += 1
|
| 362 |
+
self.metrics.heuristic_calls += 1
|
| 363 |
+
return self.heuristic(features)
|
| 364 |
+
except (_TransientInferenceError, Exception) as exc:
|
| 365 |
+
self.metrics.last_error = str(exc)[:300]
|
| 366 |
+
was_open = self.breaker.is_open
|
| 367 |
+
self.breaker.record_failure(str(exc)[:120])
|
| 368 |
+
if not was_open and self.breaker.is_open:
|
| 369 |
+
self.metrics.circuit_opens += 1
|
| 370 |
+
self.metrics.fallback_events += 1
|
| 371 |
+
self.metrics.heuristic_calls += 1
|
| 372 |
+
return self.heuristic(features)
|
| 373 |
+
|
| 374 |
+
self.breaker.record_success()
|
| 375 |
+
self.metrics.inference_calls += 1
|
| 376 |
+
self.metrics.last_used_at = time.time()
|
| 377 |
+
if len(self.metrics.recent_domains) < 50:
|
| 378 |
+
self.metrics.recent_domains.append(
|
| 379 |
+
hashlib.sha1(features.encode()).hexdigest()[:10]
|
| 380 |
+
)
|
| 381 |
+
return out
|
| 382 |
+
|
| 383 |
+
def status(self) -> dict:
|
| 384 |
+
return {
|
| 385 |
+
"enabled": self._inference_configured(),
|
| 386 |
+
"model": self.model or None,
|
| 387 |
+
"circuit": {
|
| 388 |
+
"open": self.breaker.is_open,
|
| 389 |
+
"permanently_open": self.breaker.permanently_open,
|
| 390 |
+
"consecutive_failures": self.breaker.consecutive_failures,
|
| 391 |
+
"open_reason": self.breaker.open_reason,
|
| 392 |
+
"cooldown_seconds": self.breaker.cooldown_seconds,
|
| 393 |
+
},
|
| 394 |
+
"metrics": self.metrics.as_dict(),
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
class _QuotaOrAuthError(Exception):
|
| 399 |
+
pass
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
class _TransientInferenceError(Exception):
|
| 403 |
+
pass
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
# Singleton used by the engine.
|
| 407 |
+
ai_scorer = AIRiskScorer()
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
async def score_many_by_domain(
|
| 411 |
+
scorer: AIRiskScorer, feature_by_domain: dict[str, str]
|
| 412 |
+
) -> dict[str, AIScore]:
|
| 413 |
+
"""Score a batch of domain-level feature strings concurrently."""
|
| 414 |
+
if not feature_by_domain:
|
| 415 |
+
return {}
|
| 416 |
+
keys = list(feature_by_domain.keys())
|
| 417 |
+
scores = await asyncio.gather(
|
| 418 |
+
*(scorer.score(feature_by_domain[k]) for k in keys)
|
| 419 |
+
)
|
| 420 |
+
return dict(zip(keys, scores))
|
|
@@ -1,141 +1,163 @@
|
|
| 1 |
-
from pydantic import BaseModel, field_validator
|
| 2 |
-
from typing import Optional, List, Dict, Any
|
| 3 |
-
from enum import Enum
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
# ---- Enums -----------------------------------------------------------
|
| 7 |
-
class ValidationStatus(str, Enum):
|
| 8 |
-
VALID = "valid"
|
| 9 |
-
INVALID = "invalid"
|
| 10 |
-
RISKY = "risky"
|
| 11 |
-
UNKNOWN = "unknown"
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
class RiskLevel(str, Enum):
|
| 15 |
-
LOW = "low"
|
| 16 |
-
MEDIUM = "medium"
|
| 17 |
-
HIGH = "high"
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
# ---- Layer Results ----------------------------------------------------
|
| 21 |
-
class SyntaxResult(BaseModel):
|
| 22 |
-
passed: bool
|
| 23 |
-
normalized_email: Optional[str] = None
|
| 24 |
-
local_part: Optional[str] = None
|
| 25 |
-
domain: Optional[str] = None
|
| 26 |
-
error: Optional[str] = None
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
class DNSResult(BaseModel):
|
| 30 |
-
domain_exists: bool
|
| 31 |
-
has_mx_records: bool
|
| 32 |
-
mx_records: List[Dict[str, Any]] = []
|
| 33 |
-
a_fallback: bool = False
|
| 34 |
-
has_spf: bool = False
|
| 35 |
-
spf_record: Optional[str] = None
|
| 36 |
-
has_dmarc: bool = False
|
| 37 |
-
dmarc_record: Optional[str] = None
|
| 38 |
-
has_dkim: bool = False
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
#
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
#
|
| 116 |
-
#
|
| 117 |
-
#
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, field_validator
|
| 2 |
+
from typing import Optional, List, Dict, Any
|
| 3 |
+
from enum import Enum
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# ---- Enums -----------------------------------------------------------
|
| 7 |
+
class ValidationStatus(str, Enum):
|
| 8 |
+
VALID = "valid"
|
| 9 |
+
INVALID = "invalid"
|
| 10 |
+
RISKY = "risky"
|
| 11 |
+
UNKNOWN = "unknown"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RiskLevel(str, Enum):
|
| 15 |
+
LOW = "low"
|
| 16 |
+
MEDIUM = "medium"
|
| 17 |
+
HIGH = "high"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ---- Layer Results ----------------------------------------------------
|
| 21 |
+
class SyntaxResult(BaseModel):
|
| 22 |
+
passed: bool
|
| 23 |
+
normalized_email: Optional[str] = None
|
| 24 |
+
local_part: Optional[str] = None
|
| 25 |
+
domain: Optional[str] = None
|
| 26 |
+
error: Optional[str] = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class DNSResult(BaseModel):
|
| 30 |
+
domain_exists: bool
|
| 31 |
+
has_mx_records: bool
|
| 32 |
+
mx_records: List[Dict[str, Any]] = []
|
| 33 |
+
a_fallback: bool = False
|
| 34 |
+
has_spf: bool = False
|
| 35 |
+
spf_record: Optional[str] = None
|
| 36 |
+
has_dmarc: bool = False
|
| 37 |
+
dmarc_record: Optional[str] = None
|
| 38 |
+
has_dkim: bool = False
|
| 39 |
+
# DoH dual-resolver signal (spec §7.3): True when Google and Cloudflare
|
| 40 |
+
# disagree on this domain's MX set — a deterministic risk signal.
|
| 41 |
+
dns_inconsistent: bool = False
|
| 42 |
+
# Which resolver layer produced the answer: "doh:dual" | "doh:primary" |
|
| 43 |
+
# "legacy" — operational provenance for /engine/status triage.
|
| 44 |
+
mx_source: Optional[str] = None
|
| 45 |
+
error: Optional[str] = None
|
| 46 |
+
error_code: Optional[str] = None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class DisposableResult(BaseModel):
|
| 50 |
+
is_disposable: bool
|
| 51 |
+
is_free_provider: bool
|
| 52 |
+
is_role_based: bool
|
| 53 |
+
provider_name: Optional[str] = None
|
| 54 |
+
# Advisory ML signal (DistilBERT, unknown domains only). Never a verdict:
|
| 55 |
+
# the deterministic layer remains the source of truth until the measured
|
| 56 |
+
# precision gate promotes the model (see app/ml/).
|
| 57 |
+
ml_label: Optional[str] = None
|
| 58 |
+
ml_confidence: Optional[float] = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class CatchAllResult(BaseModel):
|
| 62 |
+
# None = the catch-all probe could not run (e.g. port-25 egress blocked on
|
| 63 |
+
# this host). It must NEVER be coerced to False, which would falsely claim
|
| 64 |
+
# "domain rejects unknown recipients".
|
| 65 |
+
is_catch_all: Optional[bool] = None
|
| 66 |
+
confidence: float = 0.0
|
| 67 |
+
error: Optional[str] = None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class SMTPResult(BaseModel):
|
| 71 |
+
verified: Optional[bool] = None
|
| 72 |
+
smtp_code: Optional[int] = None
|
| 73 |
+
smtp_message: Optional[str] = None
|
| 74 |
+
server_banner: Optional[str] = None
|
| 75 |
+
supports_tls: bool = False
|
| 76 |
+
error: Optional[str] = None
|
| 77 |
+
via_proxy: bool = False
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class ScoringResult(BaseModel):
|
| 81 |
+
score: int
|
| 82 |
+
risk_level: RiskLevel
|
| 83 |
+
breakdown: Dict[str, int] = {}
|
| 84 |
+
warnings: List[str] = []
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ---- Main Response ----------------------------------------------------
|
| 88 |
+
class ValidationResult(BaseModel):
|
| 89 |
+
email: str
|
| 90 |
+
status: ValidationStatus
|
| 91 |
+
syntax: SyntaxResult
|
| 92 |
+
dns: Optional[DNSResult] = None
|
| 93 |
+
disposable: Optional[DisposableResult] = None
|
| 94 |
+
catch_all: Optional[CatchAllResult] = None
|
| 95 |
+
smtp: Optional[SMTPResult] = None
|
| 96 |
+
scoring: ScoringResult
|
| 97 |
+
processing_time_ms: float
|
| 98 |
+
validated_at: str
|
| 99 |
+
# Provenance the frontend contract expects (src/lib/verifier.ts classify()).
|
| 100 |
+
# checks_run lists the 7-layer taxonomy keys that actually executed; an
|
| 101 |
+
# unchecked layer must never read as a pass. reason_code is a machine cause
|
| 102 |
+
# for Unknown/risky rows. run_id correlates a single + its bulk chunk.
|
| 103 |
+
checks_run: List[str] = []
|
| 104 |
+
reason_code: Optional[str] = None
|
| 105 |
+
run_id: Optional[str] = None
|
| 106 |
+
validator_version: Optional[str] = None
|
| 107 |
+
# ---- Autonomous-engine normalized verdict (spec Phase 5) ----------------
|
| 108 |
+
# deterministic_verdict: the pre-AI verdict of the deterministic gauntlet —
|
| 109 |
+
# "valid" | "invalid" | "catch_all" | "unknown" | "disposable".
|
| 110 |
+
# ai_risk_score / ai_label: pattern-based risk classification for the
|
| 111 |
+
# ambiguous rows (catch-all / unknown) only; None for deterministic rows.
|
| 112 |
+
# fallback_used: the AI scorer could not reach inference and the local
|
| 113 |
+
# rule-based heuristic decided instead ("scored_fallback" in the spec).
|
| 114 |
+
# cache_hit: served from the domain/mailbox verdict cache within TTL.
|
| 115 |
+
# INVARIANT: a catch_all is NEVER collapsed into "valid", regardless of
|
| 116 |
+
# ai_risk_score — a good score means "likely exists and engages", not
|
| 117 |
+
# "safe to send blindly".
|
| 118 |
+
deterministic_verdict: Optional[str] = None
|
| 119 |
+
ai_risk_score: Optional[float] = None
|
| 120 |
+
ai_label: Optional[str] = None
|
| 121 |
+
fallback_used: bool = False
|
| 122 |
+
cache_hit: bool = False
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class SingleEmailRequest(BaseModel):
|
| 126 |
+
email: str
|
| 127 |
+
|
| 128 |
+
@field_validator("email")
|
| 129 |
+
@classmethod
|
| 130 |
+
def strip_email(cls, v: str) -> str:
|
| 131 |
+
return v.strip().lower()
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class BulkEmailRequest(BaseModel):
|
| 135 |
+
emails: List[str]
|
| 136 |
+
webhook_url: Optional[str] = None
|
| 137 |
+
# Product promise: bulk runs the SAME 7-layer pipeline as single (1 credit
|
| 138 |
+
# per email). deep=False skips catch-all + SMTP, which collapsed every bulk
|
| 139 |
+
# row to "Unknown / insufficient depth" — so it defaults to True and is only
|
| 140 |
+
# flipped off by an explicit caller that accepts the reduced verdict.
|
| 141 |
+
deep: bool = True
|
| 142 |
+
|
| 143 |
+
@field_validator("emails")
|
| 144 |
+
@classmethod
|
| 145 |
+
def limit_emails(cls, v: List[str]) -> List[str]:
|
| 146 |
+
if len(v) > 10000:
|
| 147 |
+
raise ValueError("Max 10,000 emails per bulk request")
|
| 148 |
+
return [e.strip().lower() for e in v]
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class BulkValidationResponse(BaseModel):
|
| 152 |
+
task_id: str
|
| 153 |
+
status: str
|
| 154 |
+
total: int
|
| 155 |
+
message: str
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class TaskStatusResponse(BaseModel):
|
| 159 |
+
task_id: str
|
| 160 |
+
status: str
|
| 161 |
+
progress: Optional[int] = None
|
| 162 |
+
total: Optional[int] = None
|
| 163 |
+
results: Optional[List[ValidationResult]] = None
|
|
@@ -1,173 +1,173 @@
|
|
| 1 |
-
import time
|
| 2 |
-
import asyncio
|
| 3 |
-
from typing import Optional
|
| 4 |
-
from ..config import settings
|
| 5 |
-
|
| 6 |
-
# Redis is optional at import-time so the API runs without it.
|
| 7 |
-
try:
|
| 8 |
-
import redis.asyncio as aioredis
|
| 9 |
-
_HAS_REDIS = True
|
| 10 |
-
except Exception: # pragma: no cover - redis not installed
|
| 11 |
-
aioredis = None
|
| 12 |
-
_HAS_REDIS = False
|
| 13 |
-
|
| 14 |
-
_redis_client: Optional[object] = None
|
| 15 |
-
|
| 16 |
-
# In-memory TTL fallback cache: {key: (value, expires_at)}
|
| 17 |
-
_memory_cache: dict = {}
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
class CircuitBreaker:
|
| 21 |
-
"""Fail fast to the in-memory cache when Redis is slow or down.
|
| 22 |
-
|
| 23 |
-
Without this, `validate_batch` pays Redis' socket timeout (~2s) on every one
|
| 24 |
-
of the ~8 cache calls it makes per row — measured at 10.0s per row on a
|
| 25 |
-
host where the Upstash link is slow, while the engine itself needs ~70ms.
|
| 26 |
-
After `failure_threshold` consecutive slow/failed ops the circuit opens and
|
| 27 |
-
calls skip Redis entirely until `cooldown_seconds` elapse, then probe once.
|
| 28 |
-
"""
|
| 29 |
-
|
| 30 |
-
def __init__(self, failure_threshold: int = 3,
|
| 31 |
-
cooldown_seconds: float = 30.0, slow_threshold: float = 1.0):
|
| 32 |
-
self.failure_threshold = failure_threshold
|
| 33 |
-
self.cooldown_seconds = cooldown_seconds
|
| 34 |
-
self.slow_threshold = slow_threshold
|
| 35 |
-
self._failures = 0
|
| 36 |
-
self._open_until = 0.0
|
| 37 |
-
|
| 38 |
-
def allow(self) -> bool:
|
| 39 |
-
"""True when Redis may be contacted (closed, or cooldown elapsed)."""
|
| 40 |
-
return time.time() >= self._open_until
|
| 41 |
-
|
| 42 |
-
def record(self, success: bool, duration: float) -> None:
|
| 43 |
-
"""Record one op: slow ops count as failures even when they succeed."""
|
| 44 |
-
if success and duration < self.slow_threshold:
|
| 45 |
-
self._failures = 0
|
| 46 |
-
return
|
| 47 |
-
self._failures += 1
|
| 48 |
-
if self._failures >= self.failure_threshold:
|
| 49 |
-
self._open_until = time.time() + self.cooldown_seconds
|
| 50 |
-
self._failures = 0
|
| 51 |
-
|
| 52 |
-
def reset(self) -> None:
|
| 53 |
-
self._failures = 0
|
| 54 |
-
self._open_until = 0.0
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
# Trips when Redis is consistently slow/failing; see CircuitBreaker.
|
| 58 |
-
_circuit = CircuitBreaker()
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
async def get_redis():
|
| 62 |
-
"""Lazily create a shared Redis client (returns None if unavailable).
|
| 63 |
-
|
| 64 |
-
Redis connections must be configured explicitly. Missing or unreachable
|
| 65 |
-
Redis leaves the API on its bounded in-memory cache instead of silently
|
| 66 |
-
provisioning a temporary external database.
|
| 67 |
-
"""
|
| 68 |
-
global _redis_client
|
| 69 |
-
if not _HAS_REDIS:
|
| 70 |
-
return None
|
| 71 |
-
if _redis_client is None:
|
| 72 |
-
url = (settings.redis_url or "").strip()
|
| 73 |
-
if not url:
|
| 74 |
-
return None
|
| 75 |
-
try:
|
| 76 |
-
_redis_client = aioredis.from_url(
|
| 77 |
-
url,
|
| 78 |
-
encoding="utf-8",
|
| 79 |
-
decode_responses=True,
|
| 80 |
-
# The TLS handshake to Upstash measures ~750-850 ms on some
|
| 81 |
-
# networks; a 2 s cap killed the very first PING at exactly
|
| 82 |
-
# the timeout (observed live), which made every cache op pay
|
| 83 |
-
# the full budget and trip the breaker. 5 s connects reliably
|
| 84 |
-
# while the breaker still fails fast on real outages.
|
| 85 |
-
socket_connect_timeout=5,
|
| 86 |
-
socket_timeout=5,
|
| 87 |
-
)
|
| 88 |
-
await _redis_client.ping()
|
| 89 |
-
except Exception:
|
| 90 |
-
_redis_client = None
|
| 91 |
-
return _redis_client
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
def _memory_get(key: str) -> Optional[str]:
|
| 95 |
-
item = _memory_cache.get(key)
|
| 96 |
-
if item is None:
|
| 97 |
-
return None
|
| 98 |
-
value, expires = item
|
| 99 |
-
if time.time() > expires:
|
| 100 |
-
_memory_cache.pop(key, None)
|
| 101 |
-
return None
|
| 102 |
-
return value
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def _memory_set(key: str, value: str, ttl: int):
|
| 106 |
-
_memory_cache[key] = (value, time.time() + ttl)
|
| 107 |
-
# keep cache bounded
|
| 108 |
-
if len(_memory_cache) > 20000:
|
| 109 |
-
now = time.time()
|
| 110 |
-
for k in [k for k, (_, exp) in _memory_cache.items() if exp < now]:
|
| 111 |
-
_memory_cache.pop(k, None)
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
async def get_cache(key: str) -> Optional[str]:
|
| 115 |
-
if _circuit.allow():
|
| 116 |
-
t0 = time.perf_counter()
|
| 117 |
-
try:
|
| 118 |
-
client = await get_redis()
|
| 119 |
-
if client is not None:
|
| 120 |
-
value = await client.get(key)
|
| 121 |
-
_circuit.record(success=True, duration=time.perf_counter() - t0)
|
| 122 |
-
return value
|
| 123 |
-
# Redis is configured but get_redis() could not establish a client
|
| 124 |
-
# (connect/ping failed). Without this the 2s timeout is paid on
|
| 125 |
-
# EVERY call and the breaker never trips.
|
| 126 |
-
if (settings.redis_url or "").strip():
|
| 127 |
-
_circuit.record(success=False,
|
| 128 |
-
duration=time.perf_counter() - t0)
|
| 129 |
-
except Exception:
|
| 130 |
-
_circuit.record(success=False, duration=time.perf_counter() - t0)
|
| 131 |
-
return _memory_get(key)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
async def set_cache(key: str, value: str, ttl: int = 86400):
|
| 135 |
-
if _circuit.allow():
|
| 136 |
-
t0 = time.perf_counter()
|
| 137 |
-
try:
|
| 138 |
-
client = await get_redis()
|
| 139 |
-
if client is not None:
|
| 140 |
-
await client.setex(key, ttl, value)
|
| 141 |
-
_circuit.record(success=True, duration=time.perf_counter() - t0)
|
| 142 |
-
return
|
| 143 |
-
if (settings.redis_url or "").strip():
|
| 144 |
-
_circuit.record(success=False,
|
| 145 |
-
duration=time.perf_counter() - t0)
|
| 146 |
-
except Exception:
|
| 147 |
-
_circuit.record(success=False, duration=time.perf_counter() - t0)
|
| 148 |
-
_memory_set(key, value, ttl)
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
async def redis_available(timeout: float = 4.0) -> bool:
|
| 152 |
-
"""
|
| 153 |
-
Quick non-blocking check: can we talk to Redis?
|
| 154 |
-
|
| 155 |
-
Used to gate Celery .delay() calls so a missing Redis broker fails
|
| 156 |
-
fast instead of entering Celery's infinite reconnect/retry loop.
|
| 157 |
-
"""
|
| 158 |
-
if not _HAS_REDIS:
|
| 159 |
-
return False
|
| 160 |
-
try:
|
| 161 |
-
client = await asyncio.wait_for(
|
| 162 |
-
get_redis(), timeout=timeout
|
| 163 |
-
)
|
| 164 |
-
if client is None:
|
| 165 |
-
return False
|
| 166 |
-
await asyncio.wait_for(client.ping(), timeout=timeout)
|
| 167 |
-
return True
|
| 168 |
-
except asyncio.TimeoutError:
|
| 169 |
-
print("[REDIS] Connection timeout - Redis may not be running")
|
| 170 |
-
return False
|
| 171 |
-
except Exception as e:
|
| 172 |
-
print(f"[REDIS] Connection failed: {str(e)}")
|
| 173 |
-
return False
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import asyncio
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from ..config import settings
|
| 5 |
+
|
| 6 |
+
# Redis is optional at import-time so the API runs without it.
|
| 7 |
+
try:
|
| 8 |
+
import redis.asyncio as aioredis
|
| 9 |
+
_HAS_REDIS = True
|
| 10 |
+
except Exception: # pragma: no cover - redis not installed
|
| 11 |
+
aioredis = None
|
| 12 |
+
_HAS_REDIS = False
|
| 13 |
+
|
| 14 |
+
_redis_client: Optional[object] = None
|
| 15 |
+
|
| 16 |
+
# In-memory TTL fallback cache: {key: (value, expires_at)}
|
| 17 |
+
_memory_cache: dict = {}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class CircuitBreaker:
|
| 21 |
+
"""Fail fast to the in-memory cache when Redis is slow or down.
|
| 22 |
+
|
| 23 |
+
Without this, `validate_batch` pays Redis' socket timeout (~2s) on every one
|
| 24 |
+
of the ~8 cache calls it makes per row — measured at 10.0s per row on a
|
| 25 |
+
host where the Upstash link is slow, while the engine itself needs ~70ms.
|
| 26 |
+
After `failure_threshold` consecutive slow/failed ops the circuit opens and
|
| 27 |
+
calls skip Redis entirely until `cooldown_seconds` elapse, then probe once.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
def __init__(self, failure_threshold: int = 3,
|
| 31 |
+
cooldown_seconds: float = 30.0, slow_threshold: float = 1.0):
|
| 32 |
+
self.failure_threshold = failure_threshold
|
| 33 |
+
self.cooldown_seconds = cooldown_seconds
|
| 34 |
+
self.slow_threshold = slow_threshold
|
| 35 |
+
self._failures = 0
|
| 36 |
+
self._open_until = 0.0
|
| 37 |
+
|
| 38 |
+
def allow(self) -> bool:
|
| 39 |
+
"""True when Redis may be contacted (closed, or cooldown elapsed)."""
|
| 40 |
+
return time.time() >= self._open_until
|
| 41 |
+
|
| 42 |
+
def record(self, success: bool, duration: float) -> None:
|
| 43 |
+
"""Record one op: slow ops count as failures even when they succeed."""
|
| 44 |
+
if success and duration < self.slow_threshold:
|
| 45 |
+
self._failures = 0
|
| 46 |
+
return
|
| 47 |
+
self._failures += 1
|
| 48 |
+
if self._failures >= self.failure_threshold:
|
| 49 |
+
self._open_until = time.time() + self.cooldown_seconds
|
| 50 |
+
self._failures = 0
|
| 51 |
+
|
| 52 |
+
def reset(self) -> None:
|
| 53 |
+
self._failures = 0
|
| 54 |
+
self._open_until = 0.0
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# Trips when Redis is consistently slow/failing; see CircuitBreaker.
|
| 58 |
+
_circuit = CircuitBreaker()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def get_redis():
|
| 62 |
+
"""Lazily create a shared Redis client (returns None if unavailable).
|
| 63 |
+
|
| 64 |
+
Redis connections must be configured explicitly. Missing or unreachable
|
| 65 |
+
Redis leaves the API on its bounded in-memory cache instead of silently
|
| 66 |
+
provisioning a temporary external database.
|
| 67 |
+
"""
|
| 68 |
+
global _redis_client
|
| 69 |
+
if not _HAS_REDIS:
|
| 70 |
+
return None
|
| 71 |
+
if _redis_client is None:
|
| 72 |
+
url = (settings.redis_url or "").strip()
|
| 73 |
+
if not url:
|
| 74 |
+
return None
|
| 75 |
+
try:
|
| 76 |
+
_redis_client = aioredis.from_url(
|
| 77 |
+
url,
|
| 78 |
+
encoding="utf-8",
|
| 79 |
+
decode_responses=True,
|
| 80 |
+
# The TLS handshake to Upstash measures ~750-850 ms on some
|
| 81 |
+
# networks; a 2 s cap killed the very first PING at exactly
|
| 82 |
+
# the timeout (observed live), which made every cache op pay
|
| 83 |
+
# the full budget and trip the breaker. 5 s connects reliably
|
| 84 |
+
# while the breaker still fails fast on real outages.
|
| 85 |
+
socket_connect_timeout=5,
|
| 86 |
+
socket_timeout=5,
|
| 87 |
+
)
|
| 88 |
+
await _redis_client.ping()
|
| 89 |
+
except Exception:
|
| 90 |
+
_redis_client = None
|
| 91 |
+
return _redis_client
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _memory_get(key: str) -> Optional[str]:
|
| 95 |
+
item = _memory_cache.get(key)
|
| 96 |
+
if item is None:
|
| 97 |
+
return None
|
| 98 |
+
value, expires = item
|
| 99 |
+
if time.time() > expires:
|
| 100 |
+
_memory_cache.pop(key, None)
|
| 101 |
+
return None
|
| 102 |
+
return value
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _memory_set(key: str, value: str, ttl: int):
|
| 106 |
+
_memory_cache[key] = (value, time.time() + ttl)
|
| 107 |
+
# keep cache bounded
|
| 108 |
+
if len(_memory_cache) > 20000:
|
| 109 |
+
now = time.time()
|
| 110 |
+
for k in [k for k, (_, exp) in _memory_cache.items() if exp < now]:
|
| 111 |
+
_memory_cache.pop(k, None)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
async def get_cache(key: str) -> Optional[str]:
|
| 115 |
+
if _circuit.allow():
|
| 116 |
+
t0 = time.perf_counter()
|
| 117 |
+
try:
|
| 118 |
+
client = await get_redis()
|
| 119 |
+
if client is not None:
|
| 120 |
+
value = await client.get(key)
|
| 121 |
+
_circuit.record(success=True, duration=time.perf_counter() - t0)
|
| 122 |
+
return value
|
| 123 |
+
# Redis is configured but get_redis() could not establish a client
|
| 124 |
+
# (connect/ping failed). Without this the 2s timeout is paid on
|
| 125 |
+
# EVERY call and the breaker never trips.
|
| 126 |
+
if (settings.redis_url or "").strip():
|
| 127 |
+
_circuit.record(success=False,
|
| 128 |
+
duration=time.perf_counter() - t0)
|
| 129 |
+
except Exception:
|
| 130 |
+
_circuit.record(success=False, duration=time.perf_counter() - t0)
|
| 131 |
+
return _memory_get(key)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async def set_cache(key: str, value: str, ttl: int = 86400):
|
| 135 |
+
if _circuit.allow():
|
| 136 |
+
t0 = time.perf_counter()
|
| 137 |
+
try:
|
| 138 |
+
client = await get_redis()
|
| 139 |
+
if client is not None:
|
| 140 |
+
await client.setex(key, ttl, value)
|
| 141 |
+
_circuit.record(success=True, duration=time.perf_counter() - t0)
|
| 142 |
+
return
|
| 143 |
+
if (settings.redis_url or "").strip():
|
| 144 |
+
_circuit.record(success=False,
|
| 145 |
+
duration=time.perf_counter() - t0)
|
| 146 |
+
except Exception:
|
| 147 |
+
_circuit.record(success=False, duration=time.perf_counter() - t0)
|
| 148 |
+
_memory_set(key, value, ttl)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
async def redis_available(timeout: float = 4.0) -> bool:
|
| 152 |
+
"""
|
| 153 |
+
Quick non-blocking check: can we talk to Redis?
|
| 154 |
+
|
| 155 |
+
Used to gate Celery .delay() calls so a missing Redis broker fails
|
| 156 |
+
fast instead of entering Celery's infinite reconnect/retry loop.
|
| 157 |
+
"""
|
| 158 |
+
if not _HAS_REDIS:
|
| 159 |
+
return False
|
| 160 |
+
try:
|
| 161 |
+
client = await asyncio.wait_for(
|
| 162 |
+
get_redis(), timeout=timeout
|
| 163 |
+
)
|
| 164 |
+
if client is None:
|
| 165 |
+
return False
|
| 166 |
+
await asyncio.wait_for(client.ping(), timeout=timeout)
|
| 167 |
+
return True
|
| 168 |
+
except asyncio.TimeoutError:
|
| 169 |
+
print("[REDIS] Connection timeout - Redis may not be running")
|
| 170 |
+
return False
|
| 171 |
+
except Exception as e:
|
| 172 |
+
print(f"[REDIS] Connection failed: {str(e)}")
|
| 173 |
+
return False
|
|
@@ -1,184 +1,238 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
|
| 3 |
-
import dns.resolver
|
| 4 |
-
import dns.asyncresolver
|
| 5 |
-
import dns.exception
|
| 6 |
-
from ..config import settings
|
| 7 |
-
from ..models import DNSResult
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
resolver
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
|
| 3 |
+
import dns.resolver
|
| 4 |
+
import dns.asyncresolver
|
| 5 |
+
import dns.exception
|
| 6 |
+
from ..config import settings
|
| 7 |
+
from ..models import DNSResult
|
| 8 |
+
from .doh_resolver import resolve_mx_dual_cached
|
| 9 |
+
from .i18n import to_punycode
|
| 10 |
+
|
| 11 |
+
DKIM_SELECTORS = ['default', 'google', 'selector1', 'selector2', 'dkim', 'k1']
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def build_resolver(timeout: float) -> dns.asyncresolver.Resolver:
|
| 15 |
+
"""Resolver that never trusts a broken system resolver config.
|
| 16 |
+
|
| 17 |
+
Diagnosed live: the host's resolver list held three stale gateway IPs
|
| 18 |
+
(192.168.3.1 / 192.168.1.1 / 172.20.10.1) from old networks. dnspython tried
|
| 19 |
+
each in turn, each timing out, so EVERY lookup burned the full lifetime and
|
| 20 |
+
returned a timeout — even for domains whose MX answered in 56 ms via a
|
| 21 |
+
public resolver. That produced 42s requests and mass false-invalid verdicts.
|
| 22 |
+
"""
|
| 23 |
+
resolver = dns.asyncresolver.Resolver(configure=not settings.dns_nameservers)
|
| 24 |
+
if settings.dns_nameservers:
|
| 25 |
+
resolver.nameservers = list(settings.dns_nameservers)
|
| 26 |
+
resolver.timeout = timeout
|
| 27 |
+
resolver.lifetime = timeout
|
| 28 |
+
return resolver
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def check_dns(domain: str, timeout: int = 5) -> DNSResult:
|
| 32 |
+
"""
|
| 33 |
+
Layer 2 & 3: Async DNS + MX Record validation.
|
| 34 |
+
Also checks SPF, DMARC and DKIM authentication records.
|
| 35 |
+
|
| 36 |
+
The MX gate runs first over DoH (free public resolvers, dual-query with a
|
| 37 |
+
consistency check — see doh_resolver.py); the legacy dnspython path is the
|
| 38 |
+
automatic fallback when DoH is disabled or unreachable. SPF/DMARC/DKIM are
|
| 39 |
+
informational TXT lookups and stay on the dnspython path.
|
| 40 |
+
|
| 41 |
+
The authentication lookups are independent of one another and are issued
|
| 42 |
+
concurrently: the DKIM selector list alone is six sequential lookups, which
|
| 43 |
+
multiplied every request's latency when run one after another.
|
| 44 |
+
"""
|
| 45 |
+
# ---- MX gate over DoH (spec §3.2), legacy path as fallback -------------
|
| 46 |
+
if settings.doh_enabled:
|
| 47 |
+
doh = await resolve_mx_dual_cached(domain, timeout)
|
| 48 |
+
status = doh.get("status")
|
| 49 |
+
if status in {"ok", "nxdomain", "no_mail"}:
|
| 50 |
+
if status == "nxdomain":
|
| 51 |
+
return DNSResult(
|
| 52 |
+
domain_exists=False,
|
| 53 |
+
has_mx_records=False,
|
| 54 |
+
error=doh.get("error") or "Domain does not exist (NXDOMAIN)",
|
| 55 |
+
error_code="nxdomain",
|
| 56 |
+
)
|
| 57 |
+
if status == "no_mail":
|
| 58 |
+
return DNSResult(
|
| 59 |
+
domain_exists=True,
|
| 60 |
+
has_mx_records=False,
|
| 61 |
+
error=doh.get("error") or "Domain has no MX, A, or AAAA records",
|
| 62 |
+
error_code="no_mail_records",
|
| 63 |
+
)
|
| 64 |
+
# status == "ok": finish the record with the informational TXT layer.
|
| 65 |
+
resolver = build_resolver(timeout)
|
| 66 |
+
ascii_domain = to_punycode(domain)
|
| 67 |
+
has_spf, spf_record = await _lookup_spf(resolver, ascii_domain)
|
| 68 |
+
(has_dmarc, dmarc_record), (has_dkim, _) = await asyncio.gather(
|
| 69 |
+
_lookup_dmarc(resolver, ascii_domain),
|
| 70 |
+
_lookup_dkim(resolver, ascii_domain),
|
| 71 |
+
)
|
| 72 |
+
return DNSResult(
|
| 73 |
+
domain_exists=True,
|
| 74 |
+
has_mx_records=bool(doh.get("mx_records")),
|
| 75 |
+
mx_records=doh.get("mx_records") or [],
|
| 76 |
+
a_fallback=bool(doh.get("a_fallback")),
|
| 77 |
+
has_spf=has_spf,
|
| 78 |
+
spf_record=spf_record,
|
| 79 |
+
has_dmarc=has_dmarc,
|
| 80 |
+
dmarc_record=dmarc_record,
|
| 81 |
+
has_dkim=has_dkim,
|
| 82 |
+
dns_inconsistent=doh.get("consistent") is False,
|
| 83 |
+
mx_source="doh:dual" if doh.get("consistent") is not None else "doh:primary",
|
| 84 |
+
)
|
| 85 |
+
# DoH errored → fall through to the legacy resolver path.
|
| 86 |
+
|
| 87 |
+
return await _check_dns_legacy(domain, timeout)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
async def _check_dns_legacy(domain: str, timeout: int = 5) -> DNSResult:
|
| 91 |
+
"""Legacy dnspython path (UDP/53 against configured public resolvers)."""
|
| 92 |
+
resolver = build_resolver(timeout)
|
| 93 |
+
|
| 94 |
+
# ---- MX Records --------------------------------------------------
|
| 95 |
+
mx_records = []
|
| 96 |
+
domain_exists = False
|
| 97 |
+
has_mx = False
|
| 98 |
+
a_fallback = False
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
mx_answers = await resolver.resolve(domain, 'MX')
|
| 102 |
+
has_mx = True
|
| 103 |
+
domain_exists = True
|
| 104 |
+
for r in sorted(mx_answers, key=lambda x: x.preference):
|
| 105 |
+
mx_records.append({
|
| 106 |
+
"host": str(r.exchange).rstrip('.'),
|
| 107 |
+
"priority": r.preference
|
| 108 |
+
})
|
| 109 |
+
except dns.resolver.NXDOMAIN:
|
| 110 |
+
return DNSResult(
|
| 111 |
+
domain_exists=False,
|
| 112 |
+
has_mx_records=False,
|
| 113 |
+
error="Domain does not exist (NXDOMAIN)",
|
| 114 |
+
error_code="nxdomain",
|
| 115 |
+
)
|
| 116 |
+
except dns.resolver.NoAnswer:
|
| 117 |
+
# No MX -> try A/AAAA fallback per RFC
|
| 118 |
+
try:
|
| 119 |
+
for record_type in ('A', 'AAAA'):
|
| 120 |
+
try:
|
| 121 |
+
await resolver.resolve(domain, record_type)
|
| 122 |
+
domain_exists = True
|
| 123 |
+
a_fallback = True
|
| 124 |
+
mx_records.append({"host": domain, "priority": 0})
|
| 125 |
+
break
|
| 126 |
+
except dns.resolver.NoAnswer:
|
| 127 |
+
continue
|
| 128 |
+
except dns.resolver.NXDOMAIN:
|
| 129 |
+
return DNSResult(
|
| 130 |
+
domain_exists=False,
|
| 131 |
+
has_mx_records=False,
|
| 132 |
+
error="Domain does not exist (NXDOMAIN)",
|
| 133 |
+
error_code="nxdomain",
|
| 134 |
+
)
|
| 135 |
+
except dns.exception.Timeout:
|
| 136 |
+
return DNSResult(
|
| 137 |
+
domain_exists=False,
|
| 138 |
+
has_mx_records=False,
|
| 139 |
+
error="DNS address lookup timed out",
|
| 140 |
+
error_code="timeout",
|
| 141 |
+
)
|
| 142 |
+
except dns.exception.DNSException as e:
|
| 143 |
+
return DNSResult(
|
| 144 |
+
domain_exists=False,
|
| 145 |
+
has_mx_records=False,
|
| 146 |
+
error=f"DNS address lookup failed: {e.__class__.__name__}",
|
| 147 |
+
error_code="resolver_error",
|
| 148 |
+
)
|
| 149 |
+
if not domain_exists:
|
| 150 |
+
# An MX NODATA response confirms the owner name exists, even when
|
| 151 |
+
# it has no address record to which SMTP can be routed.
|
| 152 |
+
domain_exists = True
|
| 153 |
+
except dns.exception.Timeout:
|
| 154 |
+
return DNSResult(
|
| 155 |
+
domain_exists=False,
|
| 156 |
+
has_mx_records=False,
|
| 157 |
+
error="DNS timeout",
|
| 158 |
+
error_code="timeout",
|
| 159 |
+
)
|
| 160 |
+
except dns.exception.DNSException as e:
|
| 161 |
+
return DNSResult(
|
| 162 |
+
domain_exists=False,
|
| 163 |
+
has_mx_records=False,
|
| 164 |
+
error=f"DNS lookup failed: {e.__class__.__name__}",
|
| 165 |
+
error_code="resolver_error",
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
if not has_mx and not a_fallback:
|
| 169 |
+
return DNSResult(
|
| 170 |
+
domain_exists=True,
|
| 171 |
+
has_mx_records=False,
|
| 172 |
+
error="Domain has no MX, A, or AAAA records",
|
| 173 |
+
error_code="no_mail_records",
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# ---- SPF / DMARC / DKIM (independent of each other -> concurrent) ----
|
| 177 |
+
has_spf, spf_record = await _lookup_spf(resolver, domain)
|
| 178 |
+
(has_dmarc, dmarc_record), (has_dkim, _) = await asyncio.gather(
|
| 179 |
+
_lookup_dmarc(resolver, domain),
|
| 180 |
+
_lookup_dkim(resolver, domain),
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
return DNSResult(
|
| 184 |
+
domain_exists=domain_exists,
|
| 185 |
+
has_mx_records=has_mx,
|
| 186 |
+
mx_records=mx_records,
|
| 187 |
+
a_fallback=a_fallback,
|
| 188 |
+
has_spf=has_spf,
|
| 189 |
+
spf_record=spf_record,
|
| 190 |
+
has_dmarc=has_dmarc,
|
| 191 |
+
dmarc_record=dmarc_record,
|
| 192 |
+
has_dkim=has_dkim
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
async def _lookup_spf(resolver, domain: str) -> tuple[bool, str | None]:
|
| 197 |
+
try:
|
| 198 |
+
txt_answers = await resolver.resolve(domain, 'TXT')
|
| 199 |
+
for record in txt_answers:
|
| 200 |
+
txt = b''.join(record.strings).decode('utf-8', errors='replace')
|
| 201 |
+
if txt.startswith('v=spf1'):
|
| 202 |
+
return True, txt
|
| 203 |
+
except Exception:
|
| 204 |
+
pass
|
| 205 |
+
return False, None
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
async def _lookup_dmarc(resolver, domain: str) -> tuple[bool, str | None]:
|
| 209 |
+
try:
|
| 210 |
+
answers = await resolver.resolve(f"_dmarc.{domain}", 'TXT')
|
| 211 |
+
for record in answers:
|
| 212 |
+
txt = b''.join(record.strings).decode('utf-8', errors='replace')
|
| 213 |
+
if 'DMARC1' in txt:
|
| 214 |
+
return True, txt
|
| 215 |
+
except Exception:
|
| 216 |
+
pass
|
| 217 |
+
return False, None
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
async def _lookup_dkim(resolver, domain: str) -> tuple[bool, str | None]:
|
| 221 |
+
"""Probe the known selectors concurrently, first hit wins."""
|
| 222 |
+
|
| 223 |
+
async def probe(selector: str):
|
| 224 |
+
try:
|
| 225 |
+
answers = await resolver.resolve(f"{selector}._domainkey.{domain}", 'TXT')
|
| 226 |
+
for record in answers:
|
| 227 |
+
txt = b''.join(record.strings).decode('utf-8', errors='replace')
|
| 228 |
+
if 'DKIM1' in txt or 'k=rsa' in txt or 'p=' in txt:
|
| 229 |
+
return txt
|
| 230 |
+
except Exception:
|
| 231 |
+
return None
|
| 232 |
+
return None
|
| 233 |
+
|
| 234 |
+
results = await asyncio.gather(*(probe(s) for s in DKIM_SELECTORS))
|
| 235 |
+
for txt in results:
|
| 236 |
+
if txt:
|
| 237 |
+
return True, txt
|
| 238 |
+
return False, None
|
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DNS/MX worker over DNS-over-HTTPS (DoH) — free public resolvers.
|
| 2 |
+
|
| 3 |
+
Architecture spec §3.2 / §7.3:
|
| 4 |
+
|
| 5 |
+
- MX resolution runs on Google Public DNS and Cloudflare DNS over HTTPS.
|
| 6 |
+
Both are free, unlimited and need no authentication; HTTPS/443 egress is
|
| 7 |
+
never blocked (unlike UDP/53 on locked-down PaaS hosts).
|
| 8 |
+
- The SAME query goes to BOTH resolvers. Divergent MX sets flag the domain as
|
| 9 |
+
`dns_inconsistent` — a deterministic risk signal (regional DNS hijack /
|
| 10 |
+
misconfiguration), requiring no inference.
|
| 11 |
+
- Results are cached at the domain level: 24h TTL for MX answers, 1h for
|
| 12 |
+
negative (null-MX / NXDOMAIN) answers per the spec.
|
| 13 |
+
- Punycode normalization happens here (§7.1) so Unicode domains resolve.
|
| 14 |
+
|
| 15 |
+
The legacy dnspython path in dns_check.py remains the fallback: any DoH
|
| 16 |
+
failure (network, JSON, schema) degrades to it transparently. The DoH layer
|
| 17 |
+
can also be disabled entirely with DOH_ENABLED=false.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import asyncio
|
| 22 |
+
import json
|
| 23 |
+
from typing import Any, Optional
|
| 24 |
+
|
| 25 |
+
import httpx
|
| 26 |
+
|
| 27 |
+
from ..config import settings
|
| 28 |
+
from .i18n import to_punycode
|
| 29 |
+
|
| 30 |
+
GOOGLE_DOH = "https://dns.google/resolve"
|
| 31 |
+
CLOUDFLARE_DOH = "https://cloudflare-dns.com/dns-query"
|
| 32 |
+
|
| 33 |
+
MX_TTL_SECONDS = 24 * 3600 # spec: 24h for MX records
|
| 34 |
+
NEGATIVE_TTL_SECONDS = 1 * 3600 # spec: 1h for null-MX negatives
|
| 35 |
+
|
| 36 |
+
_TIMEOUT_LABEL = "timeout"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def _doh_query(
|
| 40 |
+
client: httpx.AsyncClient,
|
| 41 |
+
endpoint: str,
|
| 42 |
+
name: str,
|
| 43 |
+
rtype: str,
|
| 44 |
+
extra_params: dict | None = None,
|
| 45 |
+
accept: str = "application/dns-json",
|
| 46 |
+
) -> dict:
|
| 47 |
+
"""One DoH JSON query (RFC 8484-style JSON APIs used by both providers)."""
|
| 48 |
+
params = {"name": name, "type": rtype}
|
| 49 |
+
if extra_params:
|
| 50 |
+
params.update(extra_params)
|
| 51 |
+
resp = await client.get(endpoint, params=params, headers={"accept": accept})
|
| 52 |
+
resp.raise_for_status()
|
| 53 |
+
data = resp.json()
|
| 54 |
+
if not isinstance(data, dict):
|
| 55 |
+
raise ValueError("doh: non-object response")
|
| 56 |
+
return data
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _parse_mx(data: dict) -> list[dict]:
|
| 60 |
+
"""Extract MX records (sorted by priority) from a DoH answer."""
|
| 61 |
+
records: list[dict] = []
|
| 62 |
+
for ans in data.get("Answer") or []:
|
| 63 |
+
# RFC 1035 type 15 = MX
|
| 64 |
+
if ans.get("type") != 15:
|
| 65 |
+
continue
|
| 66 |
+
parts = str(ans.get("data", "")).split()
|
| 67 |
+
if len(parts) >= 2:
|
| 68 |
+
try:
|
| 69 |
+
priority = int(parts[0])
|
| 70 |
+
except ValueError:
|
| 71 |
+
priority = 0
|
| 72 |
+
host = " ".join(parts[1:]).rstrip(".").strip().lower()
|
| 73 |
+
if host:
|
| 74 |
+
records.append({"host": host, "priority": priority})
|
| 75 |
+
records.sort(key=lambda r: (r["priority"], r["host"]))
|
| 76 |
+
return records
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _parse_a_like(data: dict, rtypes: set[int]) -> bool:
|
| 80 |
+
"""True when the answer holds at least one A (1) / AAAA (28) record."""
|
| 81 |
+
for ans in data.get("Answer") or []:
|
| 82 |
+
if ans.get("type") in rtypes:
|
| 83 |
+
return True
|
| 84 |
+
return False
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _mx_sets_match(a: list[dict], b: list[dict]) -> bool:
|
| 88 |
+
"""Set-equality on (priority, host) — order-insensitive, tolerant."""
|
| 89 |
+
norm = lambda rs: {(r["host"], r["priority"]) for r in rs}
|
| 90 |
+
return norm(a) == norm(b)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def resolve_mx_dual(
|
| 94 |
+
domain: str, timeout: Optional[float] = None
|
| 95 |
+
) -> dict[str, Any]:
|
| 96 |
+
"""Query BOTH public resolvers for MX and compare.
|
| 97 |
+
|
| 98 |
+
Returns:
|
| 99 |
+
{
|
| 100 |
+
"status": "ok" | "nxdomain" | "no_mail" | "error",
|
| 101 |
+
"mx_records": [...], # primary (Google) answer
|
| 102 |
+
"consistent": bool | None, # None when the second resolver failed
|
| 103 |
+
"a_fallback": bool,
|
| 104 |
+
"error": str | None,
|
| 105 |
+
}
|
| 106 |
+
"""
|
| 107 |
+
domain = to_punycode((domain or "").strip().lower().rstrip("."))
|
| 108 |
+
timeout = timeout if timeout is not None else settings.doh_timeout
|
| 109 |
+
|
| 110 |
+
async def _query_both() -> tuple[dict | None, dict | None, Exception | None]:
|
| 111 |
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
| 112 |
+
google_task = _doh_query(client, GOOGLE_DOH, domain, "MX")
|
| 113 |
+
cf_task = _doh_query(
|
| 114 |
+
client, CLOUDFLARE_DOH, domain, "MX", extra_params={"ct": "application/dns-json"}
|
| 115 |
+
)
|
| 116 |
+
g_res = await asyncio.gather(google_task, cf_task, return_exceptions=True)
|
| 117 |
+
g, c = g_res
|
| 118 |
+
g_err = g if isinstance(g, Exception) else None
|
| 119 |
+
c_err = c if isinstance(c, Exception) else None
|
| 120 |
+
g_data = g if isinstance(g, dict) else None
|
| 121 |
+
c_data = c if isinstance(c, dict) else None
|
| 122 |
+
# If the PRIMARY (Google) failed but Cloudflare answered, swap so the
|
| 123 |
+
# pipeline still gets an authoritative answer from a public resolver.
|
| 124 |
+
if g_data is None and c_data is not None:
|
| 125 |
+
g_data, c_data = c_data, None
|
| 126 |
+
return g_data, c_data, g_err or c_err
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
g_data, c_data, err = await _query_both()
|
| 130 |
+
except Exception as exc: # httpx transport failure etc.
|
| 131 |
+
return {"status": "error", "mx_records": [], "consistent": None,
|
| 132 |
+
"a_fallback": False, "error": f"doh transport: {exc}"}
|
| 133 |
+
|
| 134 |
+
if g_data is None:
|
| 135 |
+
return {"status": "error", "mx_records": [], "consistent": None,
|
| 136 |
+
"a_fallback": False, "error": f"doh unavailable: {err}"}
|
| 137 |
+
|
| 138 |
+
g_status = g_data.get("Status", 0)
|
| 139 |
+
if g_status == 3: # NXDOMAIN
|
| 140 |
+
return {"status": "nxdomain", "mx_records": [], "consistent": None,
|
| 141 |
+
"a_fallback": False, "error": "Domain does not exist (NXDOMAIN)"}
|
| 142 |
+
if g_status not in (0, 3):
|
| 143 |
+
return {"status": "error", "mx_records": [], "consistent": None,
|
| 144 |
+
"a_fallback": False, "error": f"doh resolver status {g_status}"}
|
| 145 |
+
|
| 146 |
+
mx = _parse_mx(g_data)
|
| 147 |
+
if mx:
|
| 148 |
+
consistent = (
|
| 149 |
+
_mx_sets_match(mx, _parse_mx(c_data))
|
| 150 |
+
if c_data is not None and c_data.get("Status", 0) == 0
|
| 151 |
+
else None
|
| 152 |
+
)
|
| 153 |
+
return {"status": "ok", "mx_records": mx, "consistent": consistent,
|
| 154 |
+
"a_fallback": False, "error": None}
|
| 155 |
+
|
| 156 |
+
# No MX → A/AAAA fallback per RFC 5321 §5.1
|
| 157 |
+
try:
|
| 158 |
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
| 159 |
+
a_task = _doh_query(client, GOOGLE_DOH, domain, "A")
|
| 160 |
+
aaaa_task = _doh_query(client, GOOGLE_DOH, domain, "AAAA")
|
| 161 |
+
a_res = await asyncio.gather(a_task, aaaa_task, return_exceptions=True)
|
| 162 |
+
has_addr = any(
|
| 163 |
+
isinstance(d, dict) and _parse_a_like(d, {1, 28}) for d in a_res
|
| 164 |
+
)
|
| 165 |
+
except Exception:
|
| 166 |
+
has_addr = False
|
| 167 |
+
|
| 168 |
+
if has_addr:
|
| 169 |
+
return {"status": "ok", "a_fallback": True,
|
| 170 |
+
"mx_records": [{"host": domain, "priority": 0}],
|
| 171 |
+
"consistent": None, "error": None}
|
| 172 |
+
return {"status": "no_mail", "mx_records": [], "consistent": None,
|
| 173 |
+
"a_fallback": False, "error": "Domain has no MX, A, or AAAA records"}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ---- domain-level TTL cache (shared-cache backed, memory tier always works) --
|
| 177 |
+
|
| 178 |
+
async def _cache_get(key: str) -> dict | None:
|
| 179 |
+
try:
|
| 180 |
+
from ..utils.cache import get_cache
|
| 181 |
+
raw = await get_cache(key)
|
| 182 |
+
if not raw:
|
| 183 |
+
return None
|
| 184 |
+
data = json.loads(raw)
|
| 185 |
+
return data if isinstance(data, dict) else None
|
| 186 |
+
except Exception:
|
| 187 |
+
return None # cache outage → plain re-resolve
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
async def _cache_set(key: str, payload: dict, ttl: int) -> None:
|
| 191 |
+
try:
|
| 192 |
+
from ..utils.cache import set_cache
|
| 193 |
+
await set_cache(key, json.dumps(payload), ttl)
|
| 194 |
+
except Exception:
|
| 195 |
+
pass # a cache write failure must never break resolution
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _mx_cache_key(domain: str) -> str:
|
| 199 |
+
return f"doh:mx:{to_punycode(domain.strip().lower())}"
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
async def resolve_mx_dual_cached(domain: str, timeout: Optional[float] = None) -> dict:
|
| 203 |
+
"""`resolve_mx_dual` with a domain-level TTL cache.
|
| 204 |
+
|
| 205 |
+
Definitive answers cache for 24h; negative answers (NXDOMAIN / no mail)
|
| 206 |
+
for 1h per the spec. Errors are never cached.
|
| 207 |
+
"""
|
| 208 |
+
domain = (domain or "").strip().lower()
|
| 209 |
+
key = _mx_cache_key(domain)
|
| 210 |
+
cached = await _cache_get(key)
|
| 211 |
+
if cached is not None:
|
| 212 |
+
cached = {**cached, "cache_hit": True}
|
| 213 |
+
return cached
|
| 214 |
+
|
| 215 |
+
result = await resolve_mx_dual(domain, timeout)
|
| 216 |
+
if result.get("status") == "ok":
|
| 217 |
+
await _cache_set(key, {**result, "cache_hit": False}, MX_TTL_SECONDS)
|
| 218 |
+
elif result.get("status") in {"nxdomain", "no_mail"}:
|
| 219 |
+
await _cache_set(key, {**result, "cache_hit": False}, NEGATIVE_TTL_SECONDS)
|
| 220 |
+
return result
|
|
@@ -1,662 +1,833 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
import re
|
| 3 |
-
import time
|
| 4 |
-
import uuid
|
| 5 |
-
from datetime import datetime, timezone
|
| 6 |
-
import
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
from .
|
| 20 |
-
from .
|
| 21 |
-
from .
|
| 22 |
-
from .
|
| 23 |
-
from .
|
| 24 |
-
from .
|
| 25 |
-
from .
|
| 26 |
-
from .
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
""
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
""
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
if
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
)
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
)
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
),
|
| 280 |
-
reason_code=_build_reason_code(
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
#
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
if
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import re
|
| 3 |
+
import time
|
| 4 |
+
import uuid
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
from typing import Any, Optional
|
| 7 |
+
import structlog
|
| 8 |
+
|
| 9 |
+
from ..models import (
|
| 10 |
+
ValidationResult,
|
| 11 |
+
ValidationStatus,
|
| 12 |
+
ScoringResult,
|
| 13 |
+
CatchAllResult,
|
| 14 |
+
DNSResult,
|
| 15 |
+
DisposableResult,
|
| 16 |
+
SMTPResult,
|
| 17 |
+
SyntaxResult,
|
| 18 |
+
)
|
| 19 |
+
from ..config import settings
|
| 20 |
+
from .syntax import validate_syntax
|
| 21 |
+
from .dns_check import check_dns
|
| 22 |
+
from .disposable import check_disposable, domain_in_blocklist, domain_is_known
|
| 23 |
+
from .mailbox_provider import MailboxProvider, resolve_provider
|
| 24 |
+
from .i18n import EAI_REASON, eai_gate
|
| 25 |
+
from ..ml.hf_scorer import AIRiskScorer, ai_scorer, label_for_score, score_many_by_domain
|
| 26 |
+
from ..ml.runtime import get_model_async, predict_async
|
| 27 |
+
from .mx_intel import mx_is_trusted
|
| 28 |
+
from .retry_queue import is_greylist_result
|
| 29 |
+
from .scoring import calculate_score
|
| 30 |
+
|
| 31 |
+
logger = structlog.get_logger()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _mask_email_for_log(email: str) -> str:
|
| 35 |
+
local_part, separator, domain = email.rpartition("@")
|
| 36 |
+
if not separator or not domain:
|
| 37 |
+
return "[invalid-email]"
|
| 38 |
+
return f"{local_part[:1]}***@{domain}"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _build_checks_run(
|
| 42 |
+
syntax: SyntaxResult | None,
|
| 43 |
+
dns: DNSResult | None,
|
| 44 |
+
disposable: DisposableResult | None,
|
| 45 |
+
catch_all: CatchAllResult | None,
|
| 46 |
+
smtp: SMTPResult | None,
|
| 47 |
+
scoring: ScoringResult | None,
|
| 48 |
+
) -> list[str]:
|
| 49 |
+
"""Which of the 7-layer taxonomy keys actually executed for this result.
|
| 50 |
+
|
| 51 |
+
Mirrors the frontend fallback in src/lib/verifier.ts classify() so a layer
|
| 52 |
+
the engine did not run is never reported as a pass. DNS without an error
|
| 53 |
+
claims both `dns` and `mx`; a DNS error claims only `dns`.
|
| 54 |
+
"""
|
| 55 |
+
checks: list[str] = []
|
| 56 |
+
if syntax is not None:
|
| 57 |
+
checks.append("syntax")
|
| 58 |
+
if dns is not None:
|
| 59 |
+
if dns.error:
|
| 60 |
+
checks.append("dns")
|
| 61 |
+
else:
|
| 62 |
+
checks.extend(["dns", "mx"])
|
| 63 |
+
if disposable is not None:
|
| 64 |
+
checks.append("disposable")
|
| 65 |
+
if catch_all is not None:
|
| 66 |
+
checks.append("catch_all")
|
| 67 |
+
if smtp is not None:
|
| 68 |
+
checks.append("smtp")
|
| 69 |
+
if scoring is not None:
|
| 70 |
+
checks.append("scoring")
|
| 71 |
+
return checks
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _build_reason_code(
|
| 75 |
+
dns: DNSResult | None,
|
| 76 |
+
disposable: DisposableResult | None,
|
| 77 |
+
catch_all: CatchAllResult | None,
|
| 78 |
+
smtp: SMTPResult | None,
|
| 79 |
+
*,
|
| 80 |
+
early: str | None = None,
|
| 81 |
+
) -> str | None:
|
| 82 |
+
"""Machine cause for the verdict. Ordered most-specific/actionable first.
|
| 83 |
+
|
| 84 |
+
disposable outranks everything (frontend classifies it as Disposable). A
|
| 85 |
+
definitive SMTP rejection (verified False + code) outranks the domain-level
|
| 86 |
+
catch-all flag: reporting "catch_all_detected" beside status=invalid is
|
| 87 |
+
incoherent — the explicit "mailbox does not exist" is the actionable fact.
|
| 88 |
+
Catch-all then outranks a merely inconclusive probe.
|
| 89 |
+
"""
|
| 90 |
+
if early:
|
| 91 |
+
return early
|
| 92 |
+
if disposable is not None and disposable.is_disposable:
|
| 93 |
+
return "disposable"
|
| 94 |
+
if dns is not None and dns.a_fallback and not dns.has_mx_records:
|
| 95 |
+
return "a_fallback_only"
|
| 96 |
+
if smtp is not None and smtp.verified is False and smtp.smtp_code:
|
| 97 |
+
if smtp.smtp_code >= 500:
|
| 98 |
+
return "rcpt_5xx"
|
| 99 |
+
if smtp.smtp_code >= 400:
|
| 100 |
+
return "rcpt_4xx"
|
| 101 |
+
if smtp.smtp_code >= 200:
|
| 102 |
+
return "rcpt_2xx"
|
| 103 |
+
if catch_all is not None and catch_all.is_catch_all:
|
| 104 |
+
return "catch_all_detected"
|
| 105 |
+
if smtp is not None and smtp.verified is None and smtp.error:
|
| 106 |
+
return (
|
| 107 |
+
"smtp_timeout"
|
| 108 |
+
if re.search(r"timed?\s*out", smtp.error, re.IGNORECASE)
|
| 109 |
+
else "smtp_unavailable"
|
| 110 |
+
)
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _deterministic_verdict(
|
| 115 |
+
disposable: DisposableResult | None,
|
| 116 |
+
catch_all: CatchAllResult | None,
|
| 117 |
+
smtp: SMTPResult | None,
|
| 118 |
+
) -> str:
|
| 119 |
+
"""Pre-AI verdict of the deterministic gauntlet (spec Phase 5).
|
| 120 |
+
|
| 121 |
+
Order matters: an explicit mailbox rejection outranks the domain-level
|
| 122 |
+
catch-all flag (mirrors _build_reason_code), and a catch-all NEVER reads
|
| 123 |
+
"valid" even when the probe was accepted — it only proves the domain
|
| 124 |
+
accepts everything.
|
| 125 |
+
"""
|
| 126 |
+
if disposable is not None and disposable.is_disposable:
|
| 127 |
+
return "disposable"
|
| 128 |
+
if (
|
| 129 |
+
smtp is not None
|
| 130 |
+
and smtp.verified is False
|
| 131 |
+
and smtp.smtp_code is not None
|
| 132 |
+
and smtp.smtp_code >= 500
|
| 133 |
+
):
|
| 134 |
+
return "invalid"
|
| 135 |
+
if catch_all is not None and catch_all.is_catch_all:
|
| 136 |
+
return "catch_all"
|
| 137 |
+
if smtp is not None and smtp.verified is True:
|
| 138 |
+
return "valid"
|
| 139 |
+
return "unknown"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _smtp_class(smtp: SMTPResult | None) -> str:
|
| 143 |
+
"""Coarse SMTP response class for the AI feature string (no PII)."""
|
| 144 |
+
if smtp is None:
|
| 145 |
+
return "skipped"
|
| 146 |
+
if smtp.verified is True:
|
| 147 |
+
return "accept"
|
| 148 |
+
if smtp.verified is False:
|
| 149 |
+
if smtp.smtp_code and smtp.smtp_code >= 500:
|
| 150 |
+
return "5xx"
|
| 151 |
+
if smtp.smtp_code and smtp.smtp_code >= 400:
|
| 152 |
+
return "4xx"
|
| 153 |
+
return "reject"
|
| 154 |
+
err = (smtp.error or "").lower()
|
| 155 |
+
if "worker" in err or "unreachable" in err:
|
| 156 |
+
return "transport_error"
|
| 157 |
+
return "unavailable"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _ambiguous(verdict: str) -> bool:
|
| 161 |
+
"""Only these two reach the AI scorer (spec gate steps 6 and 8)."""
|
| 162 |
+
return verdict in {"catch_all", "unknown"}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
class EmailValidationEngine:
|
| 166 |
+
"""
|
| 167 |
+
Master 7-Layer Email Validation Orchestrator.
|
| 168 |
+
|
| 169 |
+
Layers:
|
| 170 |
+
1. Syntax / RFC 5321 check
|
| 171 |
+
2. DNS domain existence
|
| 172 |
+
3. MX record verification
|
| 173 |
+
4. Disposable / Role-based / Free provider detection
|
| 174 |
+
5. Catch-all domain detection
|
| 175 |
+
6. SMTP mailbox verification
|
| 176 |
+
7. Deterministic heuristic deliverability scoring
|
| 177 |
+
"""
|
| 178 |
+
|
| 179 |
+
def __init__(
|
| 180 |
+
self,
|
| 181 |
+
mailbox: MailboxProvider | None = None,
|
| 182 |
+
cache=None,
|
| 183 |
+
retry_queue=None,
|
| 184 |
+
scorer: AIRiskScorer | None = None,
|
| 185 |
+
):
|
| 186 |
+
self.use_proxies = settings.use_proxies
|
| 187 |
+
# Layers 5 (catch-all) and 6 (SMTP) run through the autonomous mailbox
|
| 188 |
+
# layer: the built-in SMTP engine on this host, or the remote SMTP
|
| 189 |
+
# probe worker when SMTP_WORKER_URL is configured (spec §3.3).
|
| 190 |
+
self.mailbox = mailbox if mailbox is not None else resolve_provider()
|
| 191 |
+
# DNS/MX and definitive mailbox verdicts are cached across runs — the
|
| 192 |
+
# same domain (and often the same address) recurs constantly in bulk
|
| 193 |
+
# lists. Transient failures are never cached; see validation_cache.py.
|
| 194 |
+
if cache is None:
|
| 195 |
+
from .validation_cache import validation_cache as cache
|
| 196 |
+
|
| 197 |
+
self.cache = cache
|
| 198 |
+
# Greylisted (4xx) addresses get a bounded second chance. Without this a
|
| 199 |
+
# valid mailbox on a greylisting domain stays permanently "risky".
|
| 200 |
+
if retry_queue is None:
|
| 201 |
+
from .retry_queue import retry_queue as retry_queue
|
| 202 |
+
|
| 203 |
+
self.retry_queue = retry_queue
|
| 204 |
+
# AI risk scorer (spec §3.4): pattern-based classification for the
|
| 205 |
+
# ambiguous rows only; circuit breaker + heuristic fallback inside.
|
| 206 |
+
self.scorer = scorer if scorer is not None else ai_scorer
|
| 207 |
+
|
| 208 |
+
async def validate(self, email: str, deep: bool = True) -> ValidationResult:
|
| 209 |
+
start_time = time.monotonic()
|
| 210 |
+
run_id = uuid.uuid4().hex[:12]
|
| 211 |
+
|
| 212 |
+
log = logger.bind(email=_mask_email_for_log(email), run_id=run_id)
|
| 213 |
+
log.info("validation_started")
|
| 214 |
+
|
| 215 |
+
# ---- Layer 1: Syntax -----------------------------------------
|
| 216 |
+
syntax = validate_syntax(email)
|
| 217 |
+
|
| 218 |
+
if not syntax.passed:
|
| 219 |
+
elapsed = (time.monotonic() - start_time) * 1000
|
| 220 |
+
scoring = calculate_score(syntax, None, None, None, None)
|
| 221 |
+
log.warning("syntax_failed", error=syntax.error)
|
| 222 |
+
return ValidationResult(
|
| 223 |
+
email=email,
|
| 224 |
+
status=ValidationStatus.INVALID,
|
| 225 |
+
syntax=syntax,
|
| 226 |
+
scoring=scoring,
|
| 227 |
+
processing_time_ms=round(elapsed, 2),
|
| 228 |
+
validated_at=datetime.now(timezone.utc).isoformat(),
|
| 229 |
+
checks_run=_build_checks_run(syntax, None, None, None, None, scoring),
|
| 230 |
+
reason_code=_build_reason_code(None, None, None, None),
|
| 231 |
+
run_id=run_id,
|
| 232 |
+
validator_version=settings.app_version,
|
| 233 |
+
deterministic_verdict="invalid",
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
domain = syntax.domain
|
| 237 |
+
normalized_email = syntax.normalized_email or email
|
| 238 |
+
|
| 239 |
+
# ---- Layers 2 & 3: DNS + MX ---------------------------------
|
| 240 |
+
dns_result = await check_dns(domain, settings.dns_timeout)
|
| 241 |
+
|
| 242 |
+
if dns_result.error_code in {"timeout", "resolver_error"}:
|
| 243 |
+
elapsed = (time.monotonic() - start_time) * 1000
|
| 244 |
+
scoring = calculate_score(syntax, dns_result, None, None, None)
|
| 245 |
+
log.warning(
|
| 246 |
+
"dns_inconclusive", domain=domain, error_code=dns_result.error_code
|
| 247 |
+
)
|
| 248 |
+
return ValidationResult(
|
| 249 |
+
email=normalized_email,
|
| 250 |
+
status=self._determine_status(scoring),
|
| 251 |
+
syntax=syntax,
|
| 252 |
+
dns=dns_result,
|
| 253 |
+
scoring=scoring,
|
| 254 |
+
processing_time_ms=round(elapsed, 2),
|
| 255 |
+
validated_at=datetime.now(timezone.utc).isoformat(),
|
| 256 |
+
checks_run=_build_checks_run(syntax, dns_result, None, None, None, scoring),
|
| 257 |
+
reason_code=_build_reason_code(dns_result, None, None, None, early="dns_timeout"),
|
| 258 |
+
run_id=run_id,
|
| 259 |
+
validator_version=settings.app_version,
|
| 260 |
+
deterministic_verdict="unknown",
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
if (
|
| 264 |
+
not dns_result.domain_exists
|
| 265 |
+
or dns_result.error_code == "nxdomain"
|
| 266 |
+
or (not dns_result.has_mx_records and not dns_result.a_fallback)
|
| 267 |
+
):
|
| 268 |
+
elapsed = (time.monotonic() - start_time) * 1000
|
| 269 |
+
scoring = calculate_score(syntax, dns_result, None, None, None)
|
| 270 |
+
log.warning("dns_failed", domain=domain)
|
| 271 |
+
return ValidationResult(
|
| 272 |
+
email=normalized_email,
|
| 273 |
+
status=ValidationStatus.INVALID,
|
| 274 |
+
syntax=syntax,
|
| 275 |
+
dns=dns_result,
|
| 276 |
+
scoring=scoring,
|
| 277 |
+
processing_time_ms=round(elapsed, 2),
|
| 278 |
+
validated_at=datetime.now(timezone.utc).isoformat(),
|
| 279 |
+
checks_run=_build_checks_run(syntax, dns_result, None, None, None, scoring),
|
| 280 |
+
reason_code=_build_reason_code(
|
| 281 |
+
dns_result, None, None, None, early="nxdomain"
|
| 282 |
+
),
|
| 283 |
+
run_id=run_id,
|
| 284 |
+
validator_version=settings.app_version,
|
| 285 |
+
deterministic_verdict="invalid",
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
# ---- Layer 4: Disposable Detection ---------------------------
|
| 289 |
+
disposable = check_disposable(normalized_email, domain)
|
| 290 |
+
|
| 291 |
+
# ML advisory (single path): unknown domains only, fail-open. Mirrors
|
| 292 |
+
# the batch pipeline so both surfaces agree on the same address.
|
| 293 |
+
# Skipped when the MX runs on a trusted enterprise platform: the model
|
| 294 |
+
# learned 'unfamiliar domain string = disposable' from an imbalanced
|
| 295 |
+
# dataset and flags legitimate small businesses on Google Workspace /
|
| 296 |
+
# M365 — the deterministic MX intelligence is the better signal there.
|
| 297 |
+
ml_model = await get_model_async()
|
| 298 |
+
mx_host = (
|
| 299 |
+
dns_result.mx_records[0]["host"]
|
| 300 |
+
if dns_result.mx_records
|
| 301 |
+
else None
|
| 302 |
+
)
|
| 303 |
+
if (
|
| 304 |
+
ml_model is not None
|
| 305 |
+
and not domain_is_known(domain)
|
| 306 |
+
and deep
|
| 307 |
+
and not mx_is_trusted(mx_host)
|
| 308 |
+
):
|
| 309 |
+
# Off the event loop: CPU inference inline would stall every other
|
| 310 |
+
# concurrent request (measured 42.6s cold start before this).
|
| 311 |
+
preds = await predict_async(ml_model, [f"john.smith [SEP] {domain}"])
|
| 312 |
+
if preds:
|
| 313 |
+
disposable = disposable.model_copy(
|
| 314 |
+
update={"ml_label": preds[0][0], "ml_confidence": preds[0][1]}
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
if not deep:
|
| 318 |
+
# Fast path: skip SMTP for quick validation
|
| 319 |
+
elapsed = (time.monotonic() - start_time) * 1000
|
| 320 |
+
scoring = calculate_score(
|
| 321 |
+
syntax,
|
| 322 |
+
dns_result,
|
| 323 |
+
disposable,
|
| 324 |
+
None,
|
| 325 |
+
None,
|
| 326 |
+
mailbox_verification_skipped=True,
|
| 327 |
+
)
|
| 328 |
+
status = self._determine_status(scoring)
|
| 329 |
+
return ValidationResult(
|
| 330 |
+
email=normalized_email,
|
| 331 |
+
status=status,
|
| 332 |
+
syntax=syntax,
|
| 333 |
+
dns=dns_result,
|
| 334 |
+
disposable=disposable,
|
| 335 |
+
scoring=scoring,
|
| 336 |
+
processing_time_ms=round(elapsed, 2),
|
| 337 |
+
validated_at=datetime.now(timezone.utc).isoformat(),
|
| 338 |
+
checks_run=_build_checks_run(
|
| 339 |
+
syntax, dns_result, disposable, None, None, scoring
|
| 340 |
+
),
|
| 341 |
+
reason_code=_build_reason_code(dns_result, disposable, None, None),
|
| 342 |
+
run_id=run_id,
|
| 343 |
+
validator_version=settings.app_version,
|
| 344 |
+
# Fast path: the mailbox question was never asked, so there is
|
| 345 |
+
# no deterministic mailbox verdict to report.
|
| 346 |
+
deterministic_verdict=None,
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
# ---- Layers 5 & 6: Catch-All + SMTP --------------------------
|
| 350 |
+
mx_host = dns_result.mx_records[0]["host"]
|
| 351 |
+
|
| 352 |
+
# EAI/SMTPUTF8 gate (spec §7.2): a non-ASCII local part can only be
|
| 353 |
+
# delivered when the receiving MX advertises SMTPUTF8. Without an SMTP
|
| 354 |
+
# dialogue the engine refuses the per-address probe deterministically
|
| 355 |
+
# instead of faking a verdict. The domain-level catch-all probe (a
|
| 356 |
+
# random ASCII local part) still runs — it is independent of the
|
| 357 |
+
# address's own local part.
|
| 358 |
+
eai_reason = eai_gate(syntax.local_part or "")
|
| 359 |
+
|
| 360 |
+
catch_all_task = asyncio.create_task(
|
| 361 |
+
self.mailbox.catch_all(domain, mx_host)
|
| 362 |
+
)
|
| 363 |
+
if eai_reason:
|
| 364 |
+
smtp_result = SMTPResult(
|
| 365 |
+
verified=None,
|
| 366 |
+
error=(
|
| 367 |
+
f"{EAI_REASON}: mailbox probe requires SMTPUTF8 support "
|
| 368 |
+
"(deterministic refusal)"
|
| 369 |
+
),
|
| 370 |
+
)
|
| 371 |
+
try:
|
| 372 |
+
catch_all_raw = await catch_all_task
|
| 373 |
+
except Exception as exc:
|
| 374 |
+
# Same contract as the gather path: a failed probe is UNKNOWN.
|
| 375 |
+
catch_all_raw = exc
|
| 376 |
+
else:
|
| 377 |
+
smtp_task = asyncio.create_task(
|
| 378 |
+
self.mailbox.verify(normalized_email, mx_host)
|
| 379 |
+
)
|
| 380 |
+
catch_all_raw, smtp_result = await asyncio.gather(
|
| 381 |
+
catch_all_task, smtp_task, return_exceptions=True
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
# Handle catch-all result. An exception means the probe could not run
|
| 385 |
+
# (e.g. port-25 egress blocked) — that is UNKNOWN, not "not catch-all".
|
| 386 |
+
if isinstance(catch_all_raw, Exception):
|
| 387 |
+
catch_all = CatchAllResult(
|
| 388 |
+
is_catch_all=None, confidence=0.0, error=str(catch_all_raw)
|
| 389 |
+
)
|
| 390 |
+
else:
|
| 391 |
+
is_catch_all, confidence = catch_all_raw
|
| 392 |
+
catch_all = CatchAllResult(is_catch_all=is_catch_all, confidence=confidence)
|
| 393 |
+
|
| 394 |
+
# Handle SMTP result
|
| 395 |
+
if isinstance(smtp_result, Exception):
|
| 396 |
+
smtp_result = SMTPResult(verified=None, error=str(smtp_result))
|
| 397 |
+
|
| 398 |
+
# ---- Layer 7: Scoring ----------------------------------------
|
| 399 |
+
scoring = calculate_score(
|
| 400 |
+
syntax, dns_result, disposable, catch_all, smtp_result
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
status = self._determine_status(scoring)
|
| 404 |
+
elapsed = (time.monotonic() - start_time) * 1000
|
| 405 |
+
|
| 406 |
+
# ---- Normalized verdict (spec Phase 5): deterministic first, then AI --
|
| 407 |
+
deterministic = _deterministic_verdict(disposable, catch_all, smtp_result)
|
| 408 |
+
ai_score: float | None = None
|
| 409 |
+
ai_label: str | None = None
|
| 410 |
+
fallback_used = False
|
| 411 |
+
if deep and _ambiguous(deterministic):
|
| 412 |
+
features = AIRiskScorer.build_features(
|
| 413 |
+
domain=domain,
|
| 414 |
+
mx_host=mx_host,
|
| 415 |
+
smtp_class=_smtp_class(smtp_result),
|
| 416 |
+
catch_all=catch_all.is_catch_all if catch_all else None,
|
| 417 |
+
local_part=syntax.local_part or "",
|
| 418 |
+
dns_inconsistent=dns_result.dns_inconsistent,
|
| 419 |
+
)
|
| 420 |
+
scored = await self.scorer.score(features)
|
| 421 |
+
ai_score, ai_label, fallback_used = (
|
| 422 |
+
scored.score,
|
| 423 |
+
scored.label,
|
| 424 |
+
scored.fallback_used,
|
| 425 |
+
)
|
| 426 |
+
if scored.label == "high_risk":
|
| 427 |
+
scoring.warnings.append("ai_high_risk")
|
| 428 |
+
if dns_result.dns_inconsistent:
|
| 429 |
+
scoring.warnings.append("dns_inconsistent: resolvers disagree on MX")
|
| 430 |
+
|
| 431 |
+
reason = _build_reason_code(dns_result, disposable, catch_all, smtp_result)
|
| 432 |
+
if eai_reason:
|
| 433 |
+
reason = EAI_REASON
|
| 434 |
+
|
| 435 |
+
log.info(
|
| 436 |
+
"validation_complete",
|
| 437 |
+
status=status,
|
| 438 |
+
score=scoring.score,
|
| 439 |
+
time_ms=round(elapsed, 2),
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
return ValidationResult(
|
| 443 |
+
email=normalized_email,
|
| 444 |
+
status=status,
|
| 445 |
+
syntax=syntax,
|
| 446 |
+
dns=dns_result,
|
| 447 |
+
disposable=disposable,
|
| 448 |
+
catch_all=catch_all,
|
| 449 |
+
smtp=smtp_result,
|
| 450 |
+
scoring=scoring,
|
| 451 |
+
processing_time_ms=round(elapsed, 2),
|
| 452 |
+
validated_at=datetime.now(timezone.utc).isoformat(),
|
| 453 |
+
checks_run=_build_checks_run(
|
| 454 |
+
syntax, dns_result, disposable, catch_all, smtp_result, scoring
|
| 455 |
+
),
|
| 456 |
+
reason_code=reason,
|
| 457 |
+
run_id=run_id,
|
| 458 |
+
validator_version=settings.app_version,
|
| 459 |
+
deterministic_verdict=deterministic,
|
| 460 |
+
ai_risk_score=ai_score,
|
| 461 |
+
ai_label=ai_label,
|
| 462 |
+
fallback_used=fallback_used,
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
async def validate_batch(
|
| 466 |
+
self, emails: list[str], deep: bool = True
|
| 467 |
+
) -> list[ValidationResult]:
|
| 468 |
+
"""Validate many addresses in one pass, reusing per-domain work.
|
| 469 |
+
|
| 470 |
+
Bulk throughput comes from doing DNS/MX **once per domain** and handing
|
| 471 |
+
every (email, mx_host) pair to the mailbox provider in a **single** call,
|
| 472 |
+
so the SMTP engine can reuse one session per receiving host instead of a
|
| 473 |
+
fresh TCP handshake per address. Providers that cannot batch fall back to
|
| 474 |
+
the per-address path, so behaviour is never worse than before.
|
| 475 |
+
|
| 476 |
+
Results are returned in the same order as `emails`.
|
| 477 |
+
"""
|
| 478 |
+
if not emails:
|
| 479 |
+
return []
|
| 480 |
+
|
| 481 |
+
run_id = uuid.uuid4().hex[:12]
|
| 482 |
+
out: list[ValidationResult | None] = [None] * len(emails)
|
| 483 |
+
|
| 484 |
+
def _stamp(**kw) -> ValidationResult:
|
| 485 |
+
return ValidationResult(
|
| 486 |
+
processing_time_ms=0.0,
|
| 487 |
+
validated_at=datetime.now(timezone.utc).isoformat(),
|
| 488 |
+
run_id=run_id,
|
| 489 |
+
validator_version=settings.app_version,
|
| 490 |
+
**kw,
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
# ---- Layer 1: syntax (per address) ---------------------------
|
| 494 |
+
parsed: list[tuple[str, SyntaxResult]] = []
|
| 495 |
+
domain_index: dict[str, list[int]] = {}
|
| 496 |
+
for idx, raw in enumerate(emails):
|
| 497 |
+
syntax = validate_syntax(raw)
|
| 498 |
+
parsed.append((raw, syntax))
|
| 499 |
+
if not syntax.passed:
|
| 500 |
+
scoring = calculate_score(syntax, None, None, None, None)
|
| 501 |
+
out[idx] = _stamp(
|
| 502 |
+
email=raw,
|
| 503 |
+
status=ValidationStatus.INVALID,
|
| 504 |
+
syntax=syntax,
|
| 505 |
+
scoring=scoring,
|
| 506 |
+
checks_run=_build_checks_run(syntax, None, None, None, None, scoring),
|
| 507 |
+
reason_code=_build_reason_code(None, None, None, None),
|
| 508 |
+
)
|
| 509 |
+
continue
|
| 510 |
+
domain_index.setdefault(syntax.domain, []).append(idx)
|
| 511 |
+
|
| 512 |
+
# ---- Layers 2 & 3: DNS + MX once per domain (cached) ---------
|
| 513 |
+
domains = list(domain_index.keys())
|
| 514 |
+
domain_dns: dict[str, DNSResult] = {}
|
| 515 |
+
misses: list[str] = []
|
| 516 |
+
for domain in domains:
|
| 517 |
+
cached_dns = await self.cache.get_dns(domain)
|
| 518 |
+
if cached_dns is not None:
|
| 519 |
+
domain_dns[domain] = cached_dns
|
| 520 |
+
else:
|
| 521 |
+
misses.append(domain)
|
| 522 |
+
|
| 523 |
+
if misses:
|
| 524 |
+
resolved = await asyncio.gather(
|
| 525 |
+
*[check_dns(d, settings.dns_timeout) for d in misses],
|
| 526 |
+
return_exceptions=True,
|
| 527 |
+
)
|
| 528 |
+
for domain, dns in zip(misses, resolved):
|
| 529 |
+
result = (
|
| 530 |
+
dns
|
| 531 |
+
if isinstance(dns, DNSResult)
|
| 532 |
+
else DNSResult(
|
| 533 |
+
domain_exists=False,
|
| 534 |
+
has_mx_records=False,
|
| 535 |
+
error=str(dns),
|
| 536 |
+
error_code="resolver_error",
|
| 537 |
+
)
|
| 538 |
+
)
|
| 539 |
+
domain_dns[domain] = result
|
| 540 |
+
# put_dns ignores transient failures internally.
|
| 541 |
+
await self.cache.put_dns(domain, result)
|
| 542 |
+
|
| 543 |
+
pending: list[tuple[int, str, SyntaxResult, str, DNSResult]] = []
|
| 544 |
+
for domain, idxs in domain_index.items():
|
| 545 |
+
dns = domain_dns[domain]
|
| 546 |
+
inconclusive = dns.error_code in {"timeout", "resolver_error"}
|
| 547 |
+
dead = (
|
| 548 |
+
not dns.domain_exists
|
| 549 |
+
or dns.error_code == "nxdomain"
|
| 550 |
+
or (not dns.has_mx_records and not dns.a_fallback)
|
| 551 |
+
)
|
| 552 |
+
for idx in idxs:
|
| 553 |
+
raw, syntax = parsed[idx]
|
| 554 |
+
normalized = syntax.normalized_email or raw
|
| 555 |
+
if inconclusive:
|
| 556 |
+
scoring = calculate_score(syntax, dns, None, None, None)
|
| 557 |
+
out[idx] = _stamp(
|
| 558 |
+
email=normalized,
|
| 559 |
+
status=self._determine_status(scoring),
|
| 560 |
+
syntax=syntax,
|
| 561 |
+
dns=dns,
|
| 562 |
+
scoring=scoring,
|
| 563 |
+
checks_run=_build_checks_run(
|
| 564 |
+
syntax, dns, None, None, None, scoring
|
| 565 |
+
),
|
| 566 |
+
reason_code=_build_reason_code(
|
| 567 |
+
dns, None, None, None, early="dns_timeout"
|
| 568 |
+
),
|
| 569 |
+
deterministic_verdict="unknown",
|
| 570 |
+
)
|
| 571 |
+
elif dead:
|
| 572 |
+
scoring = calculate_score(syntax, dns, None, None, None)
|
| 573 |
+
out[idx] = _stamp(
|
| 574 |
+
email=normalized,
|
| 575 |
+
status=ValidationStatus.INVALID,
|
| 576 |
+
syntax=syntax,
|
| 577 |
+
dns=dns,
|
| 578 |
+
scoring=scoring,
|
| 579 |
+
checks_run=_build_checks_run(
|
| 580 |
+
syntax, dns, None, None, None, scoring
|
| 581 |
+
),
|
| 582 |
+
reason_code=_build_reason_code(
|
| 583 |
+
dns, None, None, None, early="nxdomain"
|
| 584 |
+
),
|
| 585 |
+
deterministic_verdict="invalid",
|
| 586 |
+
)
|
| 587 |
+
else:
|
| 588 |
+
pending.append((idx, normalized, syntax, domain, dns))
|
| 589 |
+
|
| 590 |
+
# ---- Layer 4 fast path (no mailbox probe) --------------------
|
| 591 |
+
if pending and not deep:
|
| 592 |
+
for idx, normalized, syntax, domain, dns in pending:
|
| 593 |
+
disposable = check_disposable(normalized, domain)
|
| 594 |
+
scoring = calculate_score(
|
| 595 |
+
syntax, dns, disposable, None, None,
|
| 596 |
+
mailbox_verification_skipped=True,
|
| 597 |
+
)
|
| 598 |
+
out[idx] = _stamp(
|
| 599 |
+
email=normalized,
|
| 600 |
+
status=self._determine_status(scoring),
|
| 601 |
+
syntax=syntax,
|
| 602 |
+
dns=dns,
|
| 603 |
+
disposable=disposable,
|
| 604 |
+
scoring=scoring,
|
| 605 |
+
checks_run=_build_checks_run(
|
| 606 |
+
syntax, dns, disposable, None, None, scoring
|
| 607 |
+
),
|
| 608 |
+
reason_code=_build_reason_code(dns, disposable, None, None),
|
| 609 |
+
deterministic_verdict=None,
|
| 610 |
+
)
|
| 611 |
+
pending = []
|
| 612 |
+
|
| 613 |
+
# ---- Layers 5 & 6: ONE batched mailbox call ------------------
|
| 614 |
+
cached_verdicts: dict[str, tuple[SMTPResult, Optional[bool]]] = {}
|
| 615 |
+
catch_all_map: dict[str, Optional[bool]] = {}
|
| 616 |
+
if pending:
|
| 617 |
+
# Serve anything already answered (definitive verdicts only) so we
|
| 618 |
+
# never re-probe an address we already know the answer to.
|
| 619 |
+
to_probe: list[tuple[int, str, SyntaxResult, str, DNSResult]] = []
|
| 620 |
+
for item in pending:
|
| 621 |
+
_idx, normalized, _syntax, _domain, _dns = item
|
| 622 |
+
hit = await self.cache.get_verdict(normalized, deep)
|
| 623 |
+
if hit is None:
|
| 624 |
+
to_probe.append(item)
|
| 625 |
+
else:
|
| 626 |
+
cached_verdicts[normalized] = hit
|
| 627 |
+
if hit[1] is not None:
|
| 628 |
+
catch_all_map[item[3]] = hit[1]
|
| 629 |
+
|
| 630 |
+
smtp_map: dict[str, SMTPResult] = {}
|
| 631 |
+
pairs: list[tuple[str, str]] = []
|
| 632 |
+
for _idx, normalized, _syntax, _domain, _dns in to_probe:
|
| 633 |
+
if eai_gate(_syntax.local_part or ""):
|
| 634 |
+
# Deterministic EAI refusal (spec §7.2): the probe requires
|
| 635 |
+
# an SMTPUTF8 advertisement we cannot observe, so refuse
|
| 636 |
+
# instead of guessing. Cache/check semantics unchanged.
|
| 637 |
+
smtp_map[normalized] = SMTPResult(
|
| 638 |
+
verified=None,
|
| 639 |
+
error=(
|
| 640 |
+
f"{EAI_REASON}: mailbox probe requires SMTPUTF8 "
|
| 641 |
+
"(deterministic refusal)"
|
| 642 |
+
),
|
| 643 |
+
)
|
| 644 |
+
continue
|
| 645 |
+
if _dns.mx_records:
|
| 646 |
+
pairs.append((normalized, _dns.mx_records[0]["host"]))
|
| 647 |
+
else:
|
| 648 |
+
smtp_map[normalized] = SMTPResult(
|
| 649 |
+
verified=None, error="no MX host available for probe"
|
| 650 |
+
)
|
| 651 |
+
if pairs:
|
| 652 |
+
try:
|
| 653 |
+
if hasattr(self.mailbox, "verify_many"):
|
| 654 |
+
smtp_map, caught = await self.mailbox.verify_many(pairs)
|
| 655 |
+
for domain, value in (caught or {}).items():
|
| 656 |
+
catch_all_map.setdefault(domain, value)
|
| 657 |
+
else:
|
| 658 |
+
# Provider cannot batch: per-address fallback.
|
| 659 |
+
for email, mx in pairs:
|
| 660 |
+
smtp_map[email] = await self.mailbox.verify(email, mx)
|
| 661 |
+
for domain in {e.rsplit("@", 1)[-1].lower() for e, _ in pairs}:
|
| 662 |
+
mx = next(
|
| 663 |
+
m for e, m in pairs
|
| 664 |
+
if e.rsplit("@", 1)[-1].lower() == domain
|
| 665 |
+
)
|
| 666 |
+
value, _conf = await self.mailbox.catch_all(domain, mx)
|
| 667 |
+
catch_all_map.setdefault(domain, value)
|
| 668 |
+
except Exception as exc: # one bad stage must not abort the batch
|
| 669 |
+
smtp_map = {
|
| 670 |
+
e: SMTPResult(verified=None, error=f"mailbox_stage_error: {exc}")
|
| 671 |
+
for e, _ in pairs
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
# ---- ML advisory signal (unknown domains, ONE batched call) ----
|
| 675 |
+
# Runs only where the curated lists have no answer - the exact gap
|
| 676 |
+
# a static blocklist cannot close. Any failure here is silently
|
| 677 |
+
# ignored: the deterministic verdict stands.
|
| 678 |
+
# Domains on trusted enterprise MX (Google Workspace / M365 /
|
| 679 |
+
# gateways) are excluded: disposable domains essentially never run
|
| 680 |
+
# there, and the model's 'unfamiliar string' bias would otherwise
|
| 681 |
+
# flag legitimate businesses as disposable.
|
| 682 |
+
ml_map: dict[str, tuple[str, float]] = {}
|
| 683 |
+
ml_model = await get_model_async()
|
| 684 |
+
if ml_model is not None:
|
| 685 |
+
unknown_domains = sorted(
|
| 686 |
+
{
|
| 687 |
+
domain
|
| 688 |
+
for _, _, _, domain, dns in pending
|
| 689 |
+
if not domain_is_known(domain)
|
| 690 |
+
and dns.mx_records
|
| 691 |
+
and not mx_is_trusted(dns.mx_records[0]["host"])
|
| 692 |
+
}
|
| 693 |
+
)
|
| 694 |
+
if unknown_domains:
|
| 695 |
+
# One batched call, off the event loop (CPU inference inline
|
| 696 |
+
# stalls concurrent requests), and fail-open by construction:
|
| 697 |
+
# predict_async never raises.
|
| 698 |
+
preds = await predict_async(
|
| 699 |
+
ml_model, [f"john.smith [SEP] {d}" for d in unknown_domains]
|
| 700 |
+
)
|
| 701 |
+
if preds:
|
| 702 |
+
ml_map = dict(zip(unknown_domains, preds))
|
| 703 |
+
|
| 704 |
+
# ---- Layer 7: score each row -----------------------------
|
| 705 |
+
ai_cache: dict[str, Any] = {} # feature string -> AIScore (dedupe)
|
| 706 |
+
for idx, normalized, syntax, domain, dns in pending:
|
| 707 |
+
cached = cached_verdicts.get(normalized)
|
| 708 |
+
if cached is not None:
|
| 709 |
+
smtp, ca_cached = cached
|
| 710 |
+
else:
|
| 711 |
+
smtp = smtp_map.get(normalized) or SMTPResult(
|
| 712 |
+
verified=None, error="engine returned no result"
|
| 713 |
+
)
|
| 714 |
+
ca_cached = None
|
| 715 |
+
# Persist only definitive answers (verified verdict or a
|
| 716 |
+
# confirmed catch-all); transient failures are never cached.
|
| 717 |
+
await self.cache.put_verdict(
|
| 718 |
+
normalized, deep, smtp, catch_all_map.get(domain)
|
| 719 |
+
)
|
| 720 |
+
|
| 721 |
+
ca_value = (
|
| 722 |
+
ca_cached if ca_cached is not None else catch_all_map.get(domain)
|
| 723 |
+
)
|
| 724 |
+
catch_all = CatchAllResult(
|
| 725 |
+
is_catch_all=ca_value,
|
| 726 |
+
confidence=0.9 if ca_value is not None else 0.0,
|
| 727 |
+
)
|
| 728 |
+
disposable = check_disposable(normalized, domain)
|
| 729 |
+
ml = ml_map.get(domain)
|
| 730 |
+
if ml is not None:
|
| 731 |
+
disposable = disposable.model_copy(
|
| 732 |
+
update={"ml_label": ml[0], "ml_confidence": ml[1]}
|
| 733 |
+
)
|
| 734 |
+
|
| 735 |
+
# A 4xx means "come back later", not a verdict. Schedule one
|
| 736 |
+
# bounded retry so a real mailbox on a greylisting domain is not
|
| 737 |
+
# stranded as permanently risky. The queue never raises.
|
| 738 |
+
if cached is None:
|
| 739 |
+
try:
|
| 740 |
+
if is_greylist_result(smtp):
|
| 741 |
+
await self.retry_queue.enqueue(
|
| 742 |
+
normalized, dns.mx_records[0]["host"], smtp.smtp_code
|
| 743 |
+
)
|
| 744 |
+
elif smtp.verified is not None:
|
| 745 |
+
# Definitive answer — cancel any pending retry.
|
| 746 |
+
await self.retry_queue.resolve_email(normalized)
|
| 747 |
+
except Exception:
|
| 748 |
+
pass
|
| 749 |
+
|
| 750 |
+
scoring = calculate_score(
|
| 751 |
+
syntax, dns, disposable, catch_all, smtp
|
| 752 |
+
)
|
| 753 |
+
|
| 754 |
+
# ---- Normalized verdict (spec Phase 5) -------------------
|
| 755 |
+
deterministic = _deterministic_verdict(disposable, catch_all, smtp)
|
| 756 |
+
ai_score: float | None = None
|
| 757 |
+
ai_label: str | None = None
|
| 758 |
+
fallback_used = False
|
| 759 |
+
if deep and _ambiguous(deterministic):
|
| 760 |
+
feats = AIRiskScorer.build_features(
|
| 761 |
+
domain=domain,
|
| 762 |
+
mx_host=dns.mx_records[0]["host"] if dns.mx_records else None,
|
| 763 |
+
smtp_class=_smtp_class(smtp),
|
| 764 |
+
catch_all=ca_value,
|
| 765 |
+
local_part=syntax.local_part or "",
|
| 766 |
+
dns_inconsistent=dns.dns_inconsistent,
|
| 767 |
+
)
|
| 768 |
+
if feats not in ai_cache:
|
| 769 |
+
ai_cache[feats] = await self.scorer.score(feats)
|
| 770 |
+
scored = ai_cache[feats]
|
| 771 |
+
ai_score, ai_label, fallback_used = (
|
| 772 |
+
scored.score,
|
| 773 |
+
scored.label,
|
| 774 |
+
scored.fallback_used,
|
| 775 |
+
)
|
| 776 |
+
if scored.label == "high_risk":
|
| 777 |
+
scoring.warnings.append("ai_high_risk")
|
| 778 |
+
if dns.dns_inconsistent:
|
| 779 |
+
scoring.warnings.append("dns_inconsistent: resolvers disagree on MX")
|
| 780 |
+
reason = _build_reason_code(dns, disposable, catch_all, smtp)
|
| 781 |
+
if (syntax.local_part or "") and eai_gate(syntax.local_part or ""):
|
| 782 |
+
reason = EAI_REASON
|
| 783 |
+
|
| 784 |
+
out[idx] = _stamp(
|
| 785 |
+
email=normalized,
|
| 786 |
+
status=self._determine_status(scoring),
|
| 787 |
+
syntax=syntax,
|
| 788 |
+
dns=dns,
|
| 789 |
+
disposable=disposable,
|
| 790 |
+
catch_all=catch_all,
|
| 791 |
+
smtp=smtp,
|
| 792 |
+
scoring=scoring,
|
| 793 |
+
checks_run=_build_checks_run(
|
| 794 |
+
syntax, dns, disposable, catch_all, smtp, scoring
|
| 795 |
+
),
|
| 796 |
+
reason_code=reason,
|
| 797 |
+
deterministic_verdict=deterministic,
|
| 798 |
+
ai_risk_score=ai_score,
|
| 799 |
+
ai_label=ai_label,
|
| 800 |
+
fallback_used=fallback_used,
|
| 801 |
+
cache_hit=cached is not None,
|
| 802 |
+
)
|
| 803 |
+
|
| 804 |
+
# Guarantee one result per input, in order.
|
| 805 |
+
for idx, result in enumerate(out):
|
| 806 |
+
if result is None:
|
| 807 |
+
raw = emails[idx]
|
| 808 |
+
syntax = parsed[idx][1] if idx < len(parsed) else validate_syntax(raw)
|
| 809 |
+
scoring = calculate_score(syntax, None, None, None, None)
|
| 810 |
+
out[idx] = _stamp(
|
| 811 |
+
email=raw,
|
| 812 |
+
status=ValidationStatus.UNKNOWN,
|
| 813 |
+
syntax=syntax,
|
| 814 |
+
scoring=scoring,
|
| 815 |
+
checks_run=_build_checks_run(
|
| 816 |
+
syntax, None, None, None, None, scoring
|
| 817 |
+
),
|
| 818 |
+
reason_code="engine_missing_result",
|
| 819 |
+
deterministic_verdict="unknown",
|
| 820 |
+
)
|
| 821 |
+
return [r for r in out if r is not None]
|
| 822 |
+
|
| 823 |
+
def _determine_status(self, scoring: ScoringResult) -> ValidationStatus:
|
| 824 |
+
if scoring.score >= 75:
|
| 825 |
+
return ValidationStatus.VALID
|
| 826 |
+
elif scoring.score >= 40:
|
| 827 |
+
return ValidationStatus.RISKY
|
| 828 |
+
else:
|
| 829 |
+
return ValidationStatus.INVALID
|
| 830 |
+
|
| 831 |
+
|
| 832 |
+
# Singleton engine
|
| 833 |
+
engine = EmailValidationEngine()
|
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Internationalization primitives for the autonomous engine.
|
| 2 |
+
|
| 3 |
+
Covers the two i18n hard requirements from the architecture spec:
|
| 4 |
+
|
| 5 |
+
7.1 Punycode / IDN domains — a Unicode domain (e.g. bücher.example) must be
|
| 6 |
+
normalized to Punycode (RFC 3492, "xn--…") before MX resolution and SMTP
|
| 7 |
+
probing. The `email-validator` library already does this at Layer 1, but
|
| 8 |
+
every other layer (DoH resolver, DNS cache keys, SMTP dialogue) must ALSO
|
| 9 |
+
receive the ASCII form, so the conversion lives here as the single source.
|
| 10 |
+
|
| 11 |
+
7.2 EAI / SMTPUTF8 local parts — a non-ASCII local part (utf8@bücher.example)
|
| 12 |
+
can only be delivered when the receiving MX advertises SMTPUTF8. Without
|
| 13 |
+
an SMTP dialogue we cannot observe the advertisement, so the engine must
|
| 14 |
+
NOT claim a definitive verdict for these addresses: it refuses the probe
|
| 15 |
+
deterministically and reports `unsupported_eai` (honest unknown, never a
|
| 16 |
+
fake invalid).
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import idna
|
| 21 |
+
|
| 22 |
+
EAI_REASON = "unsupported_eai"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def to_punycode(domain: str) -> str:
|
| 26 |
+
"""Return the ASCII (Punycode) form of a domain, best-effort.
|
| 27 |
+
|
| 28 |
+
Already-ASCII domains pass through unchanged (fast path). A domain that
|
| 29 |
+
cannot be encoded (malformed Unicode, over-long label) is returned as-is —
|
| 30 |
+
DNS will then fail naturally and the verdict degrades honestly.
|
| 31 |
+
"""
|
| 32 |
+
if not domain:
|
| 33 |
+
return domain
|
| 34 |
+
try:
|
| 35 |
+
domain.encode("ascii")
|
| 36 |
+
return domain # pure ASCII — nothing to do
|
| 37 |
+
except UnicodeEncodeError:
|
| 38 |
+
pass
|
| 39 |
+
try:
|
| 40 |
+
return idna.encode(domain, uts46=True).decode("ascii")
|
| 41 |
+
except Exception:
|
| 42 |
+
# Last resort: Python's builtin IDNA (RFC 3490 without uts46 mapping)
|
| 43 |
+
try:
|
| 44 |
+
return domain.encode("idna").decode("ascii")
|
| 45 |
+
except Exception:
|
| 46 |
+
return domain
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def domain_is_unicode(domain: str) -> bool:
|
| 50 |
+
"""True when the domain needs Punycode (contains non-ASCII labels)."""
|
| 51 |
+
try:
|
| 52 |
+
domain.encode("ascii")
|
| 53 |
+
return False
|
| 54 |
+
except UnicodeEncodeError:
|
| 55 |
+
return True
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def local_part_is_ascii(local_part: str) -> bool:
|
| 59 |
+
"""True when the local part is plain ASCII (no EAI/SMTPUTF8 needed)."""
|
| 60 |
+
try:
|
| 61 |
+
(local_part or "").encode("ascii")
|
| 62 |
+
return True
|
| 63 |
+
except UnicodeEncodeError:
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def eai_gate(local_part: str) -> str | None:
|
| 68 |
+
"""Deterministic EAI gate for the mailbox-probe layers.
|
| 69 |
+
|
| 70 |
+
Returns None when the local part is ASCII and probing may proceed.
|
| 71 |
+
Returns the machine reason ("unsupported_eai") when the address requires
|
| 72 |
+
SMTPUTF8 and this deployment cannot honestly verify it.
|
| 73 |
+
"""
|
| 74 |
+
if local_part_is_ascii(local_part):
|
| 75 |
+
return None
|
| 76 |
+
return EAI_REASON
|
|
@@ -1,19 +1,27 @@
|
|
| 1 |
"""Mailbox verification providers (Layer 5 catch-all + Layer 6 SMTP).
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
resolve_provider() picks the
|
| 10 |
-
|
| 11 |
-
mailbox_provider = "
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
"""
|
| 18 |
from __future__ import annotations
|
| 19 |
|
|
@@ -25,44 +33,10 @@ from typing import Optional, Tuple
|
|
| 25 |
import httpx
|
| 26 |
|
| 27 |
from ..config import settings
|
| 28 |
-
from ..models import
|
| 29 |
from .smtp_verify import GIBBERISH_LOCAL, smtp_verifier
|
| 30 |
|
| 31 |
|
| 32 |
-
def map_mv_result(payload: dict) -> SMTPResult:
|
| 33 |
-
"""Map one MillionVerifier real-time response into our SMTPResult contract.
|
| 34 |
-
|
| 35 |
-
Live schema (verified 2026-10-11 against API_KEY_FOR_TEST):
|
| 36 |
-
{"result": "ok"|"catch_all"|"invalid"|"disposable"|"unknown"|"error",
|
| 37 |
-
"quality": "good"|"risky"|"bad", "resultcode": <int>,
|
| 38 |
-
"free": bool, "role": bool, "error": str}
|
| 39 |
-
"""
|
| 40 |
-
result = (payload.get("result") or "").lower()
|
| 41 |
-
error = payload.get("error") or ""
|
| 42 |
-
|
| 43 |
-
if result == "ok":
|
| 44 |
-
return SMTPResult(verified=True, smtp_code=250, smtp_message="provider: ok")
|
| 45 |
-
if result == "invalid":
|
| 46 |
-
# A provider-confirmed dead mailbox is a permanent (5xx-class) reject.
|
| 47 |
-
# The message MUST carry an explicit mailbox-rejection phrase so
|
| 48 |
-
# scoring._is_explicit_mailbox_rejection() hard-fails it to score 0
|
| 49 |
-
# (Invalid) — a bare "invalid" would be softened to -30 "inconclusive"
|
| 50 |
-
# and mis-score a definitive answer as risky.
|
| 51 |
-
return SMTPResult(
|
| 52 |
-
verified=False,
|
| 53 |
-
smtp_code=550,
|
| 54 |
-
smtp_message=error or "550 mailbox does not exist (provider confirmed)",
|
| 55 |
-
)
|
| 56 |
-
if result in {"catch_all", "unknown", "disposable"}:
|
| 57 |
-
# None of these proves the specific mailbox exists. catch_all accepts
|
| 58 |
-
# everything; unknown is the provider's own inconclusive bucket.
|
| 59 |
-
return SMTPResult(
|
| 60 |
-
verified=None, error=f"provider: {result}" + (f" ({error})" if error else "")
|
| 61 |
-
)
|
| 62 |
-
# "error" or anything unexpected → inconclusive, surfaced as an error.
|
| 63 |
-
return SMTPResult(verified=None, error=f"provider: {result or 'error'} {error}".strip())
|
| 64 |
-
|
| 65 |
-
|
| 66 |
class MailboxProvider:
|
| 67 |
"""Common interface every mailbox provider implements."""
|
| 68 |
|
|
@@ -75,50 +49,6 @@ class MailboxProvider:
|
|
| 75 |
raise NotImplementedError
|
| 76 |
|
| 77 |
|
| 78 |
-
class MillionVerifierProvider(MailboxProvider):
|
| 79 |
-
name = "millionverifier"
|
| 80 |
-
|
| 81 |
-
def __init__(self, api_key: Optional[str] = None, timeout: Optional[int] = None):
|
| 82 |
-
self.api_key = api_key if api_key is not None else settings.mv_api_key
|
| 83 |
-
self.timeout = timeout if timeout is not None else settings.mv_timeout
|
| 84 |
-
self.endpoint = "https://api.millionverifier.com/api/v3/"
|
| 85 |
-
|
| 86 |
-
async def _call(self, email: str) -> dict:
|
| 87 |
-
async with httpx.AsyncClient(timeout=self.timeout + 5) as client:
|
| 88 |
-
resp = await client.get(
|
| 89 |
-
self.endpoint,
|
| 90 |
-
params={"api": self.api_key, "email": email, "timeout": self.timeout},
|
| 91 |
-
)
|
| 92 |
-
resp.raise_for_status()
|
| 93 |
-
return resp.json()
|
| 94 |
-
|
| 95 |
-
async def verify(self, email: str, mx_host: str) -> SMTPResult:
|
| 96 |
-
try:
|
| 97 |
-
payload = await self._call(email)
|
| 98 |
-
except Exception as exc: # network / HTTP failure → inconclusive
|
| 99 |
-
return SMTPResult(verified=None, error=f"provider unavailable: {exc}")
|
| 100 |
-
return map_mv_result(payload)
|
| 101 |
-
|
| 102 |
-
async def catch_all(self, domain: str, mx_host: str) -> Tuple[Optional[bool], float]:
|
| 103 |
-
# Domain property: probe a high-entropy gibberish local part. A 250 on
|
| 104 |
-
# gibberish ⇒ catch-all; a reject ⇒ not. The real address must never be
|
| 105 |
-
# used here (it would always "accept", a false negative).
|
| 106 |
-
gibberish = f"{GIBBERISH_LOCAL}{random.getrandbits(32):08x}@{domain}"
|
| 107 |
-
try:
|
| 108 |
-
payload = await self._call(gibberish)
|
| 109 |
-
except Exception:
|
| 110 |
-
return None, 0.0
|
| 111 |
-
result = (payload.get("result") or "").lower()
|
| 112 |
-
if result == "catch_all":
|
| 113 |
-
return True, 0.95
|
| 114 |
-
if result == "invalid":
|
| 115 |
-
return False, 0.9
|
| 116 |
-
# "ok" on gibberish would itself indicate catch-all; unknown/error → None
|
| 117 |
-
if result == "ok":
|
| 118 |
-
return True, 0.8
|
| 119 |
-
return None, 0.0
|
| 120 |
-
|
| 121 |
-
|
| 122 |
class AutonomousSmtpProvider(MailboxProvider):
|
| 123 |
"""The self-contained engine: own SMTP probing, no third-party service.
|
| 124 |
|
|
@@ -190,16 +120,27 @@ class AutonomousSmtpProvider(MailboxProvider):
|
|
| 190 |
return results, catch_all
|
| 191 |
|
| 192 |
|
| 193 |
-
class
|
| 194 |
-
"""
|
| 195 |
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
def __init__(
|
| 199 |
self,
|
|
|
|
|
|
|
|
|
|
| 200 |
fail_threshold: Optional[int] = None,
|
| 201 |
cooldown_seconds: Optional[int] = None,
|
| 202 |
):
|
|
|
|
|
|
|
|
|
|
| 203 |
self.fail_threshold = (
|
| 204 |
fail_threshold if fail_threshold is not None else settings.smtp_fail_threshold
|
| 205 |
)
|
|
@@ -211,6 +152,131 @@ class LocalSmtpProvider(MailboxProvider):
|
|
| 211 |
self._consecutive_failures = 0
|
| 212 |
self._open_until = 0.0
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
@property
|
| 215 |
def circuit_open(self) -> bool:
|
| 216 |
return time.monotonic() < self._open_until
|
|
@@ -262,19 +328,22 @@ class LocalSmtpProvider(MailboxProvider):
|
|
| 262 |
|
| 263 |
|
| 264 |
def resolve_provider() -> MailboxProvider:
|
| 265 |
-
"""Pick the mailbox
|
| 266 |
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
a stray key must never silently re-route the engine through a vendor.
|
| 271 |
"""
|
| 272 |
mode = (settings.mailbox_provider or "auto").strip().lower()
|
| 273 |
-
if mode == "
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
if mode == "local":
|
| 276 |
return AutonomousSmtpProvider()
|
| 277 |
-
# auto →
|
| 278 |
-
if settings.
|
| 279 |
-
return
|
| 280 |
return AutonomousSmtpProvider()
|
|
|
|
| 1 |
"""Mailbox verification providers (Layer 5 catch-all + Layer 6 SMTP).
|
| 2 |
|
| 3 |
+
The engine is FULLY AUTONOMOUS (v3.0.0): mailbox verdicts come from its own
|
| 4 |
+
SMTP probing engine — no third-party verification API, no vendor key, no
|
| 5 |
+
per-check fee. The former MillionVerifier delegation was removed by design
|
| 6 |
+
decision: a stray key must never be able to re-route verification data (and
|
| 7 |
+
the full recipient list) through a vendor.
|
| 8 |
+
|
| 9 |
+
resolve_provider() picks the mailbox layer from settings:
|
| 10 |
+
|
| 11 |
+
mailbox_provider = "auto" → remote SMTP worker when SMTP_WORKER_URL is
|
| 12 |
+
configured, else the built-in engine
|
| 13 |
+
mailbox_provider = "local" → built-in SMTP engine (this host)
|
| 14 |
+
mailbox_provider = "remote" → the remote SMTP probe worker (spec §3.3)
|
| 15 |
+
|
| 16 |
+
RemoteSmtpWorkerProvider implements the four-worker architecture: the
|
| 17 |
+
orchestrator (this app) delegates the port-25 dialogue to a separate
|
| 18 |
+
deployment of the same image running on a host whose egress allows it
|
| 19 |
+
(see POST /api/v1/smtp/probe in routes.py). A shared secret
|
| 20 |
+
(SMTP_WORKER_TOKEN) guards the worker route.
|
| 21 |
+
|
| 22 |
+
Both providers embed a circuit breaker so a host with blocked port-25 egress
|
| 23 |
+
(or a dead worker) fails fast instead of burning the full SMTP timeout on
|
| 24 |
+
every address in a bulk job.
|
| 25 |
"""
|
| 26 |
from __future__ import annotations
|
| 27 |
|
|
|
|
| 33 |
import httpx
|
| 34 |
|
| 35 |
from ..config import settings
|
| 36 |
+
from ..models import SMTPResult
|
| 37 |
from .smtp_verify import GIBBERISH_LOCAL, smtp_verifier
|
| 38 |
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
class MailboxProvider:
|
| 41 |
"""Common interface every mailbox provider implements."""
|
| 42 |
|
|
|
|
| 49 |
raise NotImplementedError
|
| 50 |
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
class AutonomousSmtpProvider(MailboxProvider):
|
| 53 |
"""The self-contained engine: own SMTP probing, no third-party service.
|
| 54 |
|
|
|
|
| 120 |
return results, catch_all
|
| 121 |
|
| 122 |
|
| 123 |
+
class RemoteSmtpWorkerProvider(MailboxProvider):
|
| 124 |
+
"""Delegates the port-25 dialogue to a remote SMTP probe worker (§3.3).
|
| 125 |
|
| 126 |
+
The worker is any deployment of this same image whose SMTP_WORKER_URL this
|
| 127 |
+
orchestrator can reach. Egress requirement moves to the worker host; the
|
| 128 |
+
orchestrator stays PaaS-deployable.
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
name = "remote"
|
| 132 |
|
| 133 |
def __init__(
|
| 134 |
self,
|
| 135 |
+
url: Optional[str] = None,
|
| 136 |
+
token: Optional[str] = None,
|
| 137 |
+
timeout: Optional[int] = None,
|
| 138 |
fail_threshold: Optional[int] = None,
|
| 139 |
cooldown_seconds: Optional[int] = None,
|
| 140 |
):
|
| 141 |
+
self.url = (url if url is not None else settings.smtp_worker_url).rstrip("/")
|
| 142 |
+
self.token = token if token is not None else settings.smtp_worker_token
|
| 143 |
+
self.timeout = timeout if timeout is not None else settings.smtp_worker_timeout
|
| 144 |
self.fail_threshold = (
|
| 145 |
fail_threshold if fail_threshold is not None else settings.smtp_fail_threshold
|
| 146 |
)
|
|
|
|
| 152 |
self._consecutive_failures = 0
|
| 153 |
self._open_until = 0.0
|
| 154 |
|
| 155 |
+
# ---- circuit breaker (same contract as LocalSmtpProvider) --------------
|
| 156 |
+
@property
|
| 157 |
+
def circuit_open(self) -> bool:
|
| 158 |
+
return time.monotonic() < self._open_until
|
| 159 |
+
|
| 160 |
+
def record_failure(self) -> None:
|
| 161 |
+
self._consecutive_failures += 1
|
| 162 |
+
if self._consecutive_failures >= self.fail_threshold:
|
| 163 |
+
self._open_until = time.monotonic() + self.cooldown
|
| 164 |
+
|
| 165 |
+
def record_success(self) -> None:
|
| 166 |
+
self._consecutive_failures = 0
|
| 167 |
+
self._open_until = 0.0
|
| 168 |
+
|
| 169 |
+
def _headers(self) -> dict:
|
| 170 |
+
h = {"Content-Type": "application/json"}
|
| 171 |
+
if self.token:
|
| 172 |
+
h["X-Worker-Token"] = self.token
|
| 173 |
+
return h
|
| 174 |
+
|
| 175 |
+
async def _post(self, path: str, payload: dict):
|
| 176 |
+
async with httpx.AsyncClient(timeout=self.timeout + 5) as client:
|
| 177 |
+
resp = await client.post(
|
| 178 |
+
f"{self.url}/smtp{path}", json=payload, headers=self._headers()
|
| 179 |
+
)
|
| 180 |
+
resp.raise_for_status()
|
| 181 |
+
return resp.json()
|
| 182 |
+
|
| 183 |
+
async def verify(self, email: str, mx_host: str) -> SMTPResult:
|
| 184 |
+
if self.circuit_open:
|
| 185 |
+
return SMTPResult(
|
| 186 |
+
verified=None, error="SMTP worker unavailable (circuit open)"
|
| 187 |
+
)
|
| 188 |
+
try:
|
| 189 |
+
data = await self._post(
|
| 190 |
+
"/probe", {"email": email, "mx_host": mx_host}
|
| 191 |
+
)
|
| 192 |
+
result = SMTPResult(**data)
|
| 193 |
+
except Exception as exc:
|
| 194 |
+
self.record_failure()
|
| 195 |
+
return SMTPResult(
|
| 196 |
+
verified=None, error=f"smtp_worker_unreachable: {exc}"
|
| 197 |
+
)
|
| 198 |
+
self.record_success()
|
| 199 |
+
return result
|
| 200 |
+
|
| 201 |
+
async def catch_all(
|
| 202 |
+
self, domain: str, mx_host: str
|
| 203 |
+
) -> Tuple[Optional[bool], float]:
|
| 204 |
+
if self.circuit_open:
|
| 205 |
+
return None, 0.0
|
| 206 |
+
try:
|
| 207 |
+
data = await self._post(
|
| 208 |
+
"/probe", {"domain": domain, "mx_host": mx_host, "probe": "catch_all"}
|
| 209 |
+
)
|
| 210 |
+
except Exception:
|
| 211 |
+
self.record_failure()
|
| 212 |
+
return None, 0.0
|
| 213 |
+
self.record_success()
|
| 214 |
+
value = data.get("is_catch_all")
|
| 215 |
+
confidence = float(data.get("confidence", 0.0) or 0.0)
|
| 216 |
+
if value is None or confidence == 0.0:
|
| 217 |
+
return None, 0.0
|
| 218 |
+
return bool(value), confidence
|
| 219 |
+
|
| 220 |
+
async def verify_many(self, pairs):
|
| 221 |
+
pairs = [(e, mx) for e, mx in pairs if e and mx]
|
| 222 |
+
if not pairs:
|
| 223 |
+
return {}, {}
|
| 224 |
+
if self.circuit_open:
|
| 225 |
+
return (
|
| 226 |
+
{
|
| 227 |
+
e: SMTPResult(
|
| 228 |
+
verified=None, error="SMTP worker unavailable (circuit open)"
|
| 229 |
+
)
|
| 230 |
+
for e, _ in pairs
|
| 231 |
+
},
|
| 232 |
+
{},
|
| 233 |
+
)
|
| 234 |
+
try:
|
| 235 |
+
data = await self._post(
|
| 236 |
+
"/probe/batch", {"pairs": [[e, mx] for e, mx in pairs]}
|
| 237 |
+
)
|
| 238 |
+
results = {
|
| 239 |
+
e: SMTPResult(**row) for e, row in (data.get("results") or {}).items()
|
| 240 |
+
}
|
| 241 |
+
catch_all = {d: v for d, v in (data.get("catch_all") or {}).items()}
|
| 242 |
+
self.record_success()
|
| 243 |
+
return results, catch_all
|
| 244 |
+
except Exception as exc:
|
| 245 |
+
self.record_failure()
|
| 246 |
+
return (
|
| 247 |
+
{
|
| 248 |
+
e: SMTPResult(
|
| 249 |
+
verified=None, error=f"smtp_worker_unreachable: {exc}"
|
| 250 |
+
)
|
| 251 |
+
for e, _ in pairs
|
| 252 |
+
},
|
| 253 |
+
{},
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
class LocalSmtpProvider(MailboxProvider):
|
| 258 |
+
"""Direct port-25 SMTP (one dialogue per call) with a circuit breaker.
|
| 259 |
+
|
| 260 |
+
Kept as the building block for single-probe paths and tests; bulk traffic
|
| 261 |
+
goes through AutonomousSmtpProvider's session-reusing batch engine.
|
| 262 |
+
"""
|
| 263 |
+
|
| 264 |
+
name = "local"
|
| 265 |
+
|
| 266 |
+
def __init__(
|
| 267 |
+
self,
|
| 268 |
+
fail_threshold: Optional[int] = None,
|
| 269 |
+
cooldown_seconds: Optional[int] = None,
|
| 270 |
+
):
|
| 271 |
+
self.fail_threshold = (
|
| 272 |
+
fail_threshold if fail_threshold is not None else settings.smtp_fail_threshold
|
| 273 |
+
)
|
| 274 |
+
self.cooldown = (
|
| 275 |
+
cooldown_seconds if cooldown_seconds is not None else settings.smtp_cooldown_seconds
|
| 276 |
+
)
|
| 277 |
+
self._consecutive_failures = 0
|
| 278 |
+
self._open_until = 0.0
|
| 279 |
+
|
| 280 |
@property
|
| 281 |
def circuit_open(self) -> bool:
|
| 282 |
return time.monotonic() < self._open_until
|
|
|
|
| 328 |
|
| 329 |
|
| 330 |
def resolve_provider() -> MailboxProvider:
|
| 331 |
+
"""Pick the mailbox layer.
|
| 332 |
|
| 333 |
+
Fully autonomous by default: the built-in SMTP engine answers with no
|
| 334 |
+
external dependency. A configured SMTP_WORKER_URL shifts the port-25
|
| 335 |
+
dialogue to the dedicated worker host — nothing else changes.
|
|
|
|
| 336 |
"""
|
| 337 |
mode = (settings.mailbox_provider or "auto").strip().lower()
|
| 338 |
+
if mode == "remote":
|
| 339 |
+
if settings.smtp_worker_url:
|
| 340 |
+
return RemoteSmtpWorkerProvider()
|
| 341 |
+
# Misconfiguration must not crash the API: degrade to the built-in
|
| 342 |
+
# engine, which reports honest transport failures.
|
| 343 |
+
return AutonomousSmtpProvider()
|
| 344 |
if mode == "local":
|
| 345 |
return AutonomousSmtpProvider()
|
| 346 |
+
# auto → remote worker when configured, else built-in.
|
| 347 |
+
if settings.smtp_worker_url:
|
| 348 |
+
return RemoteSmtpWorkerProvider()
|
| 349 |
return AutonomousSmtpProvider()
|
|
@@ -6,7 +6,7 @@ from app.models import SMTPResult
|
|
| 6 |
from app.validator.mailbox_provider import (
|
| 7 |
AutonomousSmtpProvider,
|
| 8 |
LocalSmtpProvider,
|
| 9 |
-
|
| 10 |
resolve_provider,
|
| 11 |
)
|
| 12 |
|
|
@@ -64,9 +64,9 @@ class AutonomousProviderTests(unittest.IsolatedAsyncioTestCase):
|
|
| 64 |
|
| 65 |
|
| 66 |
class DefaultProviderTests(unittest.TestCase):
|
| 67 |
-
def
|
| 68 |
with patch.object(settings, "mailbox_provider", "auto"), patch.object(
|
| 69 |
-
settings, "
|
| 70 |
):
|
| 71 |
self.assertIsInstance(resolve_provider(), AutonomousSmtpProvider)
|
| 72 |
|
|
@@ -74,29 +74,16 @@ class DefaultProviderTests(unittest.TestCase):
|
|
| 74 |
with patch.object(settings, "mailbox_provider", "local"):
|
| 75 |
self.assertIsInstance(resolve_provider(), AutonomousSmtpProvider)
|
| 76 |
|
| 77 |
-
def
|
| 78 |
-
with patch.object(settings, "mailbox_provider", "api"), patch.object(
|
| 79 |
-
settings, "mv_api_key", "k"
|
| 80 |
-
):
|
| 81 |
-
self.assertIsInstance(resolve_provider(), MillionVerifierProvider)
|
| 82 |
-
|
| 83 |
-
def test_auto_with_key_and_preference_uses_api(self):
|
| 84 |
-
with patch.object(settings, "mailbox_provider", "auto"), patch.object(
|
| 85 |
-
settings, "mv_api_key", "k"
|
| 86 |
-
), patch.object(settings, "prefer_api_provider", True):
|
| 87 |
-
self.assertIsInstance(resolve_provider(), MillionVerifierProvider)
|
| 88 |
-
|
| 89 |
-
def test_auto_with_key_but_no_preference_stays_autonomous(self):
|
| 90 |
-
# Independence by default: a stray key must not silently re-route the
|
| 91 |
-
# engine through a third party.
|
| 92 |
with patch.object(settings, "mailbox_provider", "auto"), patch.object(
|
| 93 |
-
settings, "
|
| 94 |
-
)
|
| 95 |
-
self.assertIsInstance(resolve_provider(),
|
| 96 |
|
| 97 |
def test_legacy_local_provider_still_importable(self):
|
| 98 |
self.assertTrue(callable(LocalSmtpProvider))
|
| 99 |
|
| 100 |
|
|
|
|
| 101 |
if __name__ == "__main__":
|
| 102 |
unittest.main()
|
|
|
|
| 6 |
from app.validator.mailbox_provider import (
|
| 7 |
AutonomousSmtpProvider,
|
| 8 |
LocalSmtpProvider,
|
| 9 |
+
RemoteSmtpWorkerProvider,
|
| 10 |
resolve_provider,
|
| 11 |
)
|
| 12 |
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
class DefaultProviderTests(unittest.TestCase):
|
| 67 |
+
def test_auto_without_worker_resolves_to_autonomous(self):
|
| 68 |
with patch.object(settings, "mailbox_provider", "auto"), patch.object(
|
| 69 |
+
settings, "smtp_worker_url", ""
|
| 70 |
):
|
| 71 |
self.assertIsInstance(resolve_provider(), AutonomousSmtpProvider)
|
| 72 |
|
|
|
|
| 74 |
with patch.object(settings, "mailbox_provider", "local"):
|
| 75 |
self.assertIsInstance(resolve_provider(), AutonomousSmtpProvider)
|
| 76 |
|
| 77 |
+
def test_auto_with_worker_resolves_remote(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
with patch.object(settings, "mailbox_provider", "auto"), patch.object(
|
| 79 |
+
settings, "smtp_worker_url", "https://worker.example.com"
|
| 80 |
+
):
|
| 81 |
+
self.assertIsInstance(resolve_provider(), RemoteSmtpWorkerProvider)
|
| 82 |
|
| 83 |
def test_legacy_local_provider_still_importable(self):
|
| 84 |
self.assertTrue(callable(LocalSmtpProvider))
|
| 85 |
|
| 86 |
|
| 87 |
+
|
| 88 |
if __name__ == "__main__":
|
| 89 |
unittest.main()
|
|
@@ -3,9 +3,16 @@ import time
|
|
| 3 |
import unittest
|
| 4 |
from unittest.mock import patch
|
| 5 |
|
|
|
|
| 6 |
from app.validator import dns_check
|
| 7 |
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
class FakeAnswer:
|
| 10 |
def __init__(self, text: str):
|
| 11 |
self.strings = [text.encode()]
|
|
@@ -39,7 +46,7 @@ class DnsAuthConcurrencyTests(unittest.IsolatedAsyncioTestCase):
|
|
| 39 |
"""
|
| 40 |
|
| 41 |
async def test_auth_record_lookups_run_concurrently(self):
|
| 42 |
-
with patch.object(dns_check.dns.asyncresolver, "Resolver",
|
| 43 |
return_value=SlowResolver()):
|
| 44 |
t0 = time.perf_counter()
|
| 45 |
result = await dns_check.check_dns("example.test")
|
|
@@ -65,7 +72,7 @@ class DnsAuthConcurrencyTests(unittest.IsolatedAsyncioTestCase):
|
|
| 65 |
"preference": 10})()]
|
| 66 |
raise dns_check.dns.resolver.NoAnswer()
|
| 67 |
|
| 68 |
-
with patch.object(dns_check.dns.asyncresolver, "Resolver", return_value=Bare()):
|
| 69 |
result = await dns_check.check_dns("example.test")
|
| 70 |
|
| 71 |
self.assertTrue(result.has_mx_records)
|
|
|
|
| 3 |
import unittest
|
| 4 |
from unittest.mock import patch
|
| 5 |
|
| 6 |
+
from app.config import settings
|
| 7 |
from app.validator import dns_check
|
| 8 |
|
| 9 |
|
| 10 |
+
def disable_doh():
|
| 11 |
+
"""Concurrency tests target the legacy dnspython lookups; keep DoH off so
|
| 12 |
+
the MX gate does not hit the network."""
|
| 13 |
+
return patch.object(settings, "doh_enabled", False)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
class FakeAnswer:
|
| 17 |
def __init__(self, text: str):
|
| 18 |
self.strings = [text.encode()]
|
|
|
|
| 46 |
"""
|
| 47 |
|
| 48 |
async def test_auth_record_lookups_run_concurrently(self):
|
| 49 |
+
with disable_doh(), patch.object(dns_check.dns.asyncresolver, "Resolver",
|
| 50 |
return_value=SlowResolver()):
|
| 51 |
t0 = time.perf_counter()
|
| 52 |
result = await dns_check.check_dns("example.test")
|
|
|
|
| 72 |
"preference": 10})()]
|
| 73 |
raise dns_check.dns.resolver.NoAnswer()
|
| 74 |
|
| 75 |
+
with disable_doh(), patch.object(dns_check.dns.asyncresolver, "Resolver", return_value=Bare()):
|
| 76 |
result = await dns_check.check_dns("example.test")
|
| 77 |
|
| 78 |
self.assertTrue(result.has_mx_records)
|
|
@@ -1,95 +1,103 @@
|
|
| 1 |
-
import unittest
|
| 2 |
-
from unittest.mock import patch
|
| 3 |
-
|
| 4 |
-
import dns.exception
|
| 5 |
-
import dns.resolver
|
| 6 |
-
|
| 7 |
-
from app.
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from unittest.mock import patch
|
| 3 |
+
|
| 4 |
+
import dns.exception
|
| 5 |
+
import dns.resolver
|
| 6 |
+
|
| 7 |
+
from app.config import settings
|
| 8 |
+
from app.validator.dns_check import check_dns
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def disable_doh():
|
| 12 |
+
"""These tests exercise the LEGACY dnspython path — keep them hermetic by
|
| 13 |
+
turning the DoH layer off (its own behaviour is covered by
|
| 14 |
+
test_doh_resolver.py)."""
|
| 15 |
+
return patch.object(settings, "doh_enabled", False)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ResolverStub:
|
| 19 |
+
timeout = 0
|
| 20 |
+
lifetime = 0
|
| 21 |
+
|
| 22 |
+
def __init__(self, responses):
|
| 23 |
+
self.responses = responses
|
| 24 |
+
|
| 25 |
+
async def resolve(self, name, record_type):
|
| 26 |
+
response = self.responses.get((name, record_type))
|
| 27 |
+
if isinstance(response, Exception):
|
| 28 |
+
raise response
|
| 29 |
+
if response is None:
|
| 30 |
+
raise dns.resolver.NoAnswer()
|
| 31 |
+
return response
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class DNSCheckTests(unittest.IsolatedAsyncioTestCase):
|
| 35 |
+
async def test_timeout_is_reported_as_transient(self):
|
| 36 |
+
resolver = ResolverStub({("example.test", "MX"): dns.exception.Timeout()})
|
| 37 |
+
with disable_doh(), patch(
|
| 38 |
+
"app.validator.dns_check.dns.asyncresolver.Resolver", return_value=resolver
|
| 39 |
+
):
|
| 40 |
+
result = await check_dns("example.test")
|
| 41 |
+
|
| 42 |
+
self.assertEqual(result.error_code, "timeout")
|
| 43 |
+
self.assertFalse(result.domain_exists)
|
| 44 |
+
|
| 45 |
+
async def test_no_nameservers_is_reported_as_transient(self):
|
| 46 |
+
resolver = ResolverStub(
|
| 47 |
+
{("unavailable.test", "MX"): dns.resolver.NoNameservers()}
|
| 48 |
+
)
|
| 49 |
+
with disable_doh(), patch(
|
| 50 |
+
"app.validator.dns_check.dns.asyncresolver.Resolver", return_value=resolver
|
| 51 |
+
):
|
| 52 |
+
result = await check_dns("unavailable.test")
|
| 53 |
+
|
| 54 |
+
self.assertEqual(result.error_code, "resolver_error")
|
| 55 |
+
|
| 56 |
+
async def test_nxdomain_is_reported_as_definitive(self):
|
| 57 |
+
resolver = ResolverStub({("missing.test", "MX"): dns.resolver.NXDOMAIN()})
|
| 58 |
+
with disable_doh(), patch(
|
| 59 |
+
"app.validator.dns_check.dns.asyncresolver.Resolver", return_value=resolver
|
| 60 |
+
):
|
| 61 |
+
result = await check_dns("missing.test")
|
| 62 |
+
|
| 63 |
+
self.assertEqual(result.error_code, "nxdomain")
|
| 64 |
+
self.assertFalse(result.domain_exists)
|
| 65 |
+
|
| 66 |
+
async def test_a_record_fallback_is_preserved(self):
|
| 67 |
+
resolver = ResolverStub(
|
| 68 |
+
{
|
| 69 |
+
("mail.example.test", "MX"): dns.resolver.NoAnswer(),
|
| 70 |
+
("mail.example.test", "A"): ["192.0.2.10"],
|
| 71 |
+
}
|
| 72 |
+
)
|
| 73 |
+
with disable_doh(), patch(
|
| 74 |
+
"app.validator.dns_check.dns.asyncresolver.Resolver", return_value=resolver
|
| 75 |
+
):
|
| 76 |
+
result = await check_dns("mail.example.test")
|
| 77 |
+
|
| 78 |
+
self.assertTrue(result.domain_exists)
|
| 79 |
+
self.assertFalse(result.has_mx_records)
|
| 80 |
+
self.assertTrue(result.a_fallback)
|
| 81 |
+
self.assertEqual(result.mx_records[0]["host"], "mail.example.test")
|
| 82 |
+
|
| 83 |
+
async def test_no_mx_or_address_is_distinct_from_transient_dns_error(self):
|
| 84 |
+
resolver = ResolverStub(
|
| 85 |
+
{
|
| 86 |
+
("no-mail.example.test", "MX"): dns.resolver.NoAnswer(),
|
| 87 |
+
("no-mail.example.test", "A"): dns.resolver.NoAnswer(),
|
| 88 |
+
("no-mail.example.test", "AAAA"): dns.resolver.NoAnswer(),
|
| 89 |
+
}
|
| 90 |
+
)
|
| 91 |
+
with disable_doh(), patch(
|
| 92 |
+
"app.validator.dns_check.dns.asyncresolver.Resolver", return_value=resolver
|
| 93 |
+
):
|
| 94 |
+
result = await check_dns("no-mail.example.test")
|
| 95 |
+
|
| 96 |
+
self.assertEqual(result.error_code, "no_mail_records")
|
| 97 |
+
self.assertTrue(result.domain_exists)
|
| 98 |
+
self.assertFalse(result.has_mx_records)
|
| 99 |
+
self.assertFalse(result.a_fallback)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
unittest.main()
|
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DoH DNS/MX worker tests (spec §3.2 / §7.3) — mocked HTTP, hermetic."""
|
| 2 |
+
import unittest
|
| 3 |
+
from unittest.mock import AsyncMock, patch
|
| 4 |
+
|
| 5 |
+
from app.validator import doh_resolver
|
| 6 |
+
from app.validator.doh_resolver import (
|
| 7 |
+
_mx_sets_match,
|
| 8 |
+
_parse_mx,
|
| 9 |
+
resolve_mx_dual,
|
| 10 |
+
resolve_mx_dual_cached,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _doh_response(status=0, answers=None):
|
| 15 |
+
return {"Status": status, "Answer": answers or []}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _mx_answer(priority, host):
|
| 19 |
+
return {"name": "example.com.", "type": 15, "TTL": 300, "data": f"{priority} {host}"}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _a_answer(ip="93.184.216.34"):
|
| 23 |
+
return {"name": "example.com.", "type": 1, "TTL": 300, "data": ip}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class _FakeResp:
|
| 27 |
+
def __init__(self, data):
|
| 28 |
+
self._data = data
|
| 29 |
+
|
| 30 |
+
def raise_for_status(self):
|
| 31 |
+
return None
|
| 32 |
+
|
| 33 |
+
def json(self):
|
| 34 |
+
return self._data
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class FakeDoHClient:
|
| 38 |
+
"""Routes by (host, type) so MX / A / AAAA queries can differ.
|
| 39 |
+
|
| 40 |
+
`responses` maps ("google"|"cloudflare", rtype) -> payload-or-Exception.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
responses: dict = {}
|
| 44 |
+
|
| 45 |
+
def __init__(self, responses=None, *a, **kw):
|
| 46 |
+
if responses is not None:
|
| 47 |
+
self.responses = responses
|
| 48 |
+
|
| 49 |
+
async def __aenter__(self):
|
| 50 |
+
return self
|
| 51 |
+
|
| 52 |
+
async def __aexit__(self, *a):
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
async def get(self, url, params=None, **kw):
|
| 56 |
+
host = "google" if "dns.google" in url else "cloudflare"
|
| 57 |
+
rtype = (params or {}).get("type", "MX")
|
| 58 |
+
payload = self.responses.get((host, rtype))
|
| 59 |
+
if payload is None:
|
| 60 |
+
payload = {"Status": 0, "Answer": []}
|
| 61 |
+
if isinstance(payload, Exception):
|
| 62 |
+
raise payload
|
| 63 |
+
return _FakeResp(payload)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _client_patch(responses):
|
| 67 |
+
return patch(
|
| 68 |
+
"app.validator.doh_resolver.httpx.AsyncClient",
|
| 69 |
+
lambda *a, **kw: FakeDoHClient(responses, *a, **kw),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class ParseTests(unittest.TestCase):
|
| 74 |
+
def test_parse_mx_sorted_by_priority(self):
|
| 75 |
+
data = _doh_response(0, [_mx_answer(20, "mx2.example.com."), _mx_answer(10, "mx1.example.com.")])
|
| 76 |
+
records = _parse_mx(data)
|
| 77 |
+
self.assertEqual(records, [
|
| 78 |
+
{"host": "mx1.example.com", "priority": 10},
|
| 79 |
+
{"host": "mx2.example.com", "priority": 20},
|
| 80 |
+
])
|
| 81 |
+
|
| 82 |
+
def test_parse_mx_ignores_non_mx(self):
|
| 83 |
+
data = _doh_response(0, [
|
| 84 |
+
{"name": "example.com.", "type": 1, "TTL": 300, "data": "93.184.216.34"},
|
| 85 |
+
_mx_answer(10, "mx1.example.com."),
|
| 86 |
+
])
|
| 87 |
+
records = _parse_mx(data)
|
| 88 |
+
self.assertEqual(len(records), 1)
|
| 89 |
+
|
| 90 |
+
def test_sets_match_order_insensitive(self):
|
| 91 |
+
a = [{"host": "mx1", "priority": 10}, {"host": "mx2", "priority": 20}]
|
| 92 |
+
b = [{"host": "mx2", "priority": 20}, {"host": "mx1", "priority": 10}]
|
| 93 |
+
self.assertTrue(_mx_sets_match(a, b))
|
| 94 |
+
self.assertFalse(_mx_sets_match(a, [{"host": "mx3", "priority": 10}]))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class ResolveDualTests(unittest.IsolatedAsyncioTestCase):
|
| 98 |
+
async def test_consistent_resolvers(self):
|
| 99 |
+
mx = [_mx_answer(10, "mx1.example.com.")]
|
| 100 |
+
responses = {("google", "MX"): _doh_response(0, mx), ("cloudflare", "MX"): _doh_response(0, mx)}
|
| 101 |
+
with _client_patch(responses):
|
| 102 |
+
out = await resolve_mx_dual("example.com")
|
| 103 |
+
self.assertEqual(out["status"], "ok")
|
| 104 |
+
self.assertTrue(out["consistent"])
|
| 105 |
+
self.assertEqual(out["mx_records"][0]["host"], "mx1.example.com")
|
| 106 |
+
|
| 107 |
+
async def test_inconsistent_resolvers_flagged(self):
|
| 108 |
+
g = [_mx_answer(10, "mx1.example.com.")]
|
| 109 |
+
c = [_mx_answer(10, "mx2.example.com.")]
|
| 110 |
+
responses = {("google", "MX"): _doh_response(0, g), ("cloudflare", "MX"): _doh_response(0, c)}
|
| 111 |
+
with _client_patch(responses):
|
| 112 |
+
out = await resolve_mx_dual("example.com")
|
| 113 |
+
self.assertEqual(out["status"], "ok")
|
| 114 |
+
self.assertIs(out["consistent"], False)
|
| 115 |
+
self.assertEqual(out["mx_records"][0]["host"], "mx1.example.com")
|
| 116 |
+
|
| 117 |
+
async def test_nxdomain(self):
|
| 118 |
+
responses = {
|
| 119 |
+
("google", "MX"): _doh_response(3),
|
| 120 |
+
("cloudflare", "MX"): _doh_response(3),
|
| 121 |
+
}
|
| 122 |
+
with _client_patch(responses):
|
| 123 |
+
out = await resolve_mx_dual("nope.invalid")
|
| 124 |
+
self.assertEqual(out["status"], "nxdomain")
|
| 125 |
+
|
| 126 |
+
async def test_a_fallback_when_no_mx(self):
|
| 127 |
+
responses = {
|
| 128 |
+
("google", "MX"): _doh_response(0, []),
|
| 129 |
+
("cloudflare", "MX"): _doh_response(0, []),
|
| 130 |
+
("google", "A"): _doh_response(0, [_a_answer()]),
|
| 131 |
+
("google", "AAAA"): _doh_response(0, []),
|
| 132 |
+
}
|
| 133 |
+
with _client_patch(responses):
|
| 134 |
+
out = await resolve_mx_dual("example.com")
|
| 135 |
+
self.assertEqual(out["status"], "ok")
|
| 136 |
+
self.assertTrue(out["a_fallback"])
|
| 137 |
+
self.assertEqual(out["mx_records"], [{"host": "example.com", "priority": 0}])
|
| 138 |
+
|
| 139 |
+
async def test_no_mail_when_no_mx_no_address(self):
|
| 140 |
+
responses = {
|
| 141 |
+
("google", "MX"): _doh_response(0, []),
|
| 142 |
+
("cloudflare", "MX"): _doh_response(0, []),
|
| 143 |
+
("google", "A"): _doh_response(0, []),
|
| 144 |
+
("google", "AAAA"): _doh_response(0, []),
|
| 145 |
+
}
|
| 146 |
+
with _client_patch(responses):
|
| 147 |
+
out = await resolve_mx_dual("example.com")
|
| 148 |
+
self.assertEqual(out["status"], "no_mail")
|
| 149 |
+
|
| 150 |
+
async def test_primary_failure_falls_back_to_cloudflare_answer(self):
|
| 151 |
+
mx = [_mx_answer(10, "mx1.example.com.")]
|
| 152 |
+
responses = {
|
| 153 |
+
("google", "MX"): RuntimeError("google down"),
|
| 154 |
+
("cloudflare", "MX"): _doh_response(0, mx),
|
| 155 |
+
}
|
| 156 |
+
with _client_patch(responses):
|
| 157 |
+
out = await resolve_mx_dual("example.com")
|
| 158 |
+
self.assertEqual(out["status"], "ok")
|
| 159 |
+
self.assertEqual(out["mx_records"][0]["host"], "mx1.example.com")
|
| 160 |
+
self.assertIsNone(out["consistent"])
|
| 161 |
+
|
| 162 |
+
async def test_both_fail_is_error(self):
|
| 163 |
+
responses = {
|
| 164 |
+
("google", "MX"): RuntimeError("g down"),
|
| 165 |
+
("cloudflare", "MX"): RuntimeError("c down"),
|
| 166 |
+
}
|
| 167 |
+
with _client_patch(responses):
|
| 168 |
+
out = await resolve_mx_dual("example.com")
|
| 169 |
+
self.assertEqual(out["status"], "error")
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class CachedResolveTests(unittest.IsolatedAsyncioTestCase):
|
| 173 |
+
async def test_cache_hit_short_circuits_network(self):
|
| 174 |
+
payload = {
|
| 175 |
+
"status": "ok", "mx_records": [{"host": "mx1.example.com", "priority": 10}],
|
| 176 |
+
"consistent": True, "a_fallback": False, "error": None, "cache_hit": True,
|
| 177 |
+
}
|
| 178 |
+
with patch.object(
|
| 179 |
+
doh_resolver, "_cache_get", AsyncMock(return_value=payload)
|
| 180 |
+
), patch.object(
|
| 181 |
+
doh_resolver, "resolve_mx_dual",
|
| 182 |
+
AsyncMock(side_effect=AssertionError("must not resolve")),
|
| 183 |
+
):
|
| 184 |
+
out = await resolve_mx_dual_cached("example.com")
|
| 185 |
+
self.assertTrue(out["cache_hit"])
|
| 186 |
+
self.assertEqual(out["mx_records"][0]["host"], "mx1.example.com")
|
| 187 |
+
|
| 188 |
+
async def test_ok_result_is_cached(self):
|
| 189 |
+
result = {
|
| 190 |
+
"status": "ok", "mx_records": [{"host": "mx1.example.com", "priority": 10}],
|
| 191 |
+
"consistent": True, "a_fallback": False, "error": None,
|
| 192 |
+
}
|
| 193 |
+
with patch.object(doh_resolver, "_cache_get", AsyncMock(return_value=None)), \
|
| 194 |
+
patch.object(doh_resolver, "_cache_set", AsyncMock()) as set_mock, \
|
| 195 |
+
patch.object(doh_resolver, "resolve_mx_dual", AsyncMock(return_value=result)):
|
| 196 |
+
out = await resolve_mx_dual_cached("example.com")
|
| 197 |
+
set_mock.assert_awaited_once()
|
| 198 |
+
args, kwargs = set_mock.call_args
|
| 199 |
+
self.assertEqual(kwargs.get("ttl") or args[2], doh_resolver.MX_TTL_SECONDS)
|
| 200 |
+
self.assertFalse(out.get("cache_hit", False))
|
| 201 |
+
|
| 202 |
+
async def test_error_results_never_cached(self):
|
| 203 |
+
calls = {"n": 0}
|
| 204 |
+
|
| 205 |
+
async def fake_resolve(domain, timeout=None):
|
| 206 |
+
calls["n"] += 1
|
| 207 |
+
return {"status": "error", "mx_records": [], "consistent": None,
|
| 208 |
+
"a_fallback": False, "error": "doh down"}
|
| 209 |
+
|
| 210 |
+
with patch.object(doh_resolver, "_cache_get", AsyncMock(return_value=None)), \
|
| 211 |
+
patch.object(doh_resolver, "_cache_set", AsyncMock()) as set_mock, \
|
| 212 |
+
patch.object(doh_resolver, "resolve_mx_dual", fake_resolve):
|
| 213 |
+
await resolve_mx_dual_cached("example.com")
|
| 214 |
+
await resolve_mx_dual_cached("example.com")
|
| 215 |
+
set_mock.assert_not_called()
|
| 216 |
+
self.assertEqual(calls["n"], 2)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
if __name__ == "__main__":
|
| 220 |
+
unittest.main()
|
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Autonomous-engine engine-level tests (spec Phase 3 gate + Phase 5 verdict).
|
| 2 |
+
|
| 3 |
+
Verifies the deterministic-first routing, the normalized verdict fields on
|
| 4 |
+
both single and batch paths, the EAI deterministic refusal, and the
|
| 5 |
+
catch-all-never-valid invariant.
|
| 6 |
+
"""
|
| 7 |
+
import unittest
|
| 8 |
+
from unittest.mock import AsyncMock, patch
|
| 9 |
+
|
| 10 |
+
from app.config import settings
|
| 11 |
+
from app.models import DNSResult, DisposableResult, SMTPResult, SyntaxResult
|
| 12 |
+
from app.validator.engine import (
|
| 13 |
+
EmailValidationEngine,
|
| 14 |
+
_deterministic_verdict,
|
| 15 |
+
_smtp_class,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
GOOD_SYNTAX = SyntaxResult(
|
| 20 |
+
passed=True, normalized_email="john@example.test",
|
| 21 |
+
domain="example.test", local_part="john",
|
| 22 |
+
)
|
| 23 |
+
GOOD_DNS = DNSResult(
|
| 24 |
+
domain_exists=True, has_mx_records=True,
|
| 25 |
+
mx_records=[{"host": "mx.example.test", "priority": 10}],
|
| 26 |
+
)
|
| 27 |
+
GOOD_DISPOSABLE = DisposableResult(
|
| 28 |
+
is_disposable=False, is_free_provider=False, is_role_based=False,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class FakeScorer:
|
| 33 |
+
"""Records feature strings; returns a fixed score."""
|
| 34 |
+
|
| 35 |
+
def __init__(self, score=0.2, label="low_risk", fallback_used=False):
|
| 36 |
+
self.fixed_score = score
|
| 37 |
+
self.label = label
|
| 38 |
+
self.fallback_used = fallback_used
|
| 39 |
+
self.calls = []
|
| 40 |
+
|
| 41 |
+
async def score(self, features):
|
| 42 |
+
self.calls.append(features)
|
| 43 |
+
from app.ml.hf_scorer import AIScore
|
| 44 |
+
|
| 45 |
+
return AIScore(score=self.fixed_score, label=self.label,
|
| 46 |
+
source="heuristic" if self.fallback_used else "inference",
|
| 47 |
+
fallback_used=self.fallback_used)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _build_engine(mailbox, scorer):
|
| 51 |
+
from app.validator.validation_cache import ValidationCache
|
| 52 |
+
|
| 53 |
+
async def _no_get(key):
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
async def _no_set(key, value, ttl=None):
|
| 57 |
+
return None
|
| 58 |
+
|
| 59 |
+
eng = EmailValidationEngine(
|
| 60 |
+
mailbox=mailbox, cache=ValidationCache(get=_no_get, set=_no_set),
|
| 61 |
+
scorer=scorer,
|
| 62 |
+
)
|
| 63 |
+
return eng
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _patch_pipeline(syntax=GOOD_SYNTAX, dns=None, disposable=GOOD_DISPOSABLE):
|
| 67 |
+
dns = dns or GOOD_DNS
|
| 68 |
+
return (
|
| 69 |
+
patch("app.validator.engine.validate_syntax", return_value=syntax),
|
| 70 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=dns)),
|
| 71 |
+
patch("app.validator.engine.check_disposable", return_value=disposable),
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class DeterministicVerdictUnitTests(unittest.TestCase):
|
| 76 |
+
def test_explicit_5xx_outranks_catch_all(self):
|
| 77 |
+
smtp = SMTPResult(verified=False, smtp_code=550, smtp_message="no such user")
|
| 78 |
+
verdict = _deterministic_verdict(None, None, smtp)
|
| 79 |
+
self.assertEqual(verdict, "invalid")
|
| 80 |
+
|
| 81 |
+
def test_catch_all_never_valid(self):
|
| 82 |
+
from app.models import CatchAllResult
|
| 83 |
+
|
| 84 |
+
ca = CatchAllResult(is_catch_all=True, confidence=0.95)
|
| 85 |
+
smtp = SMTPResult(verified=True, smtp_code=250)
|
| 86 |
+
self.assertEqual(_deterministic_verdict(None, ca, smtp), "catch_all")
|
| 87 |
+
|
| 88 |
+
def test_definitive_accept_is_valid(self):
|
| 89 |
+
smtp = SMTPResult(verified=True, smtp_code=250)
|
| 90 |
+
self.assertEqual(_deterministic_verdict(None, None, smtp), "valid")
|
| 91 |
+
|
| 92 |
+
def test_disposable_outranks_everything(self):
|
| 93 |
+
from app.models import DisposableResult
|
| 94 |
+
|
| 95 |
+
d = DisposableResult(is_disposable=True, is_free_provider=False,
|
| 96 |
+
is_role_based=False)
|
| 97 |
+
smtp = SMTPResult(verified=True, smtp_code=250)
|
| 98 |
+
self.assertEqual(_deterministic_verdict(d, None, smtp), "disposable")
|
| 99 |
+
|
| 100 |
+
def test_inconclusive_is_unknown(self):
|
| 101 |
+
smtp = SMTPResult(verified=None, error="smtp_unreachable: egress blocked")
|
| 102 |
+
self.assertEqual(_deterministic_verdict(None, None, smtp), "unknown")
|
| 103 |
+
|
| 104 |
+
def test_smtp_class_mapping(self):
|
| 105 |
+
self.assertEqual(_smtp_class(SMTPResult(verified=True, smtp_code=250)), "accept")
|
| 106 |
+
self.assertEqual(_smtp_class(SMTPResult(verified=False, smtp_code=550)), "5xx")
|
| 107 |
+
self.assertEqual(_smtp_class(SMTPResult(verified=False, smtp_code=451)), "4xx")
|
| 108 |
+
self.assertEqual(
|
| 109 |
+
_smtp_class(SMTPResult(verified=None, error="smtp_unreachable: blocked")),
|
| 110 |
+
"transport_error",
|
| 111 |
+
)
|
| 112 |
+
self.assertEqual(_smtp_class(None), "skipped")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class SinglePathTests(unittest.IsolatedAsyncioTestCase):
|
| 116 |
+
async def test_ambiguous_rows_reach_scorer_and_get_fields(self):
|
| 117 |
+
mailbox_accepts = AsyncMock()
|
| 118 |
+
mailbox_accepts.name = "fake"
|
| 119 |
+
mailbox_accepts.verify = AsyncMock(
|
| 120 |
+
return_value=SMTPResult(verified=None, error="smtp_unreachable: egress")
|
| 121 |
+
)
|
| 122 |
+
mailbox_accepts.catch_all = AsyncMock(return_value=(True, 0.9))
|
| 123 |
+
scorer = FakeScorer(score=0.75, label="high_risk", fallback_used=True)
|
| 124 |
+
eng = _build_engine(mailbox_accepts, scorer)
|
| 125 |
+
|
| 126 |
+
patches = _patch_pipeline()
|
| 127 |
+
with patches[0], patches[1], patches[2]:
|
| 128 |
+
result = await eng.validate("john@example.test", deep=True)
|
| 129 |
+
|
| 130 |
+
self.assertEqual(result.deterministic_verdict, "catch_all")
|
| 131 |
+
self.assertEqual(result.ai_risk_score, 0.75)
|
| 132 |
+
self.assertEqual(result.ai_label, "high_risk")
|
| 133 |
+
self.assertTrue(result.fallback_used)
|
| 134 |
+
# catch-all NEVER collapses into valid regardless of the AI score.
|
| 135 |
+
self.assertNotEqual(result.status.value, "valid")
|
| 136 |
+
self.assertIn("ai_high_risk", result.scoring.warnings)
|
| 137 |
+
self.assertEqual(len(scorer.calls), 1)
|
| 138 |
+
|
| 139 |
+
async def test_deterministic_rows_never_reach_scorer(self):
|
| 140 |
+
mailbox = AsyncMock()
|
| 141 |
+
mailbox.name = "fake"
|
| 142 |
+
mailbox.verify = AsyncMock(
|
| 143 |
+
return_value=SMTPResult(verified=False, smtp_code=550,
|
| 144 |
+
smtp_message="550 no such user")
|
| 145 |
+
)
|
| 146 |
+
mailbox.catch_all = AsyncMock(return_value=(False, 0.9))
|
| 147 |
+
scorer = FakeScorer()
|
| 148 |
+
eng = _build_engine(mailbox, scorer)
|
| 149 |
+
|
| 150 |
+
patches = _patch_pipeline()
|
| 151 |
+
with patches[0], patches[1], patches[2]:
|
| 152 |
+
result = await eng.validate("john@example.test", deep=True)
|
| 153 |
+
|
| 154 |
+
self.assertEqual(result.deterministic_verdict, "invalid")
|
| 155 |
+
self.assertEqual(scorer.calls, [])
|
| 156 |
+
self.assertIsNone(result.ai_risk_score)
|
| 157 |
+
self.assertFalse(result.fallback_used)
|
| 158 |
+
|
| 159 |
+
async def test_eai_address_refused_deterministically(self):
|
| 160 |
+
unicode_syntax = SyntaxResult(
|
| 161 |
+
passed=True, normalized_email="用户@example.test",
|
| 162 |
+
domain="example.test", local_part="用户",
|
| 163 |
+
)
|
| 164 |
+
mailbox = AsyncMock()
|
| 165 |
+
mailbox.name = "fake"
|
| 166 |
+
mailbox.verify = AsyncMock(
|
| 167 |
+
side_effect=AssertionError("must not probe an EAI address")
|
| 168 |
+
)
|
| 169 |
+
mailbox.catch_all = AsyncMock(return_value=(None, 0.0))
|
| 170 |
+
scorer = FakeScorer()
|
| 171 |
+
eng = _build_engine(mailbox, scorer)
|
| 172 |
+
|
| 173 |
+
patches = _patch_pipeline(syntax=unicode_syntax)
|
| 174 |
+
with patches[0], patches[1], patches[2]:
|
| 175 |
+
result = await eng.validate("用户@example.test", deep=True)
|
| 176 |
+
|
| 177 |
+
self.assertEqual(result.reason_code, "unsupported_eai")
|
| 178 |
+
mailbox.verify.assert_not_awaited()
|
| 179 |
+
# The domain-level catch-all probe still ran (independent of local part).
|
| 180 |
+
mailbox.catch_all.assert_awaited_once()
|
| 181 |
+
|
| 182 |
+
async def test_dns_inconsistent_adds_warning(self):
|
| 183 |
+
bad_dns = DNSResult(
|
| 184 |
+
domain_exists=True, has_mx_records=True,
|
| 185 |
+
mx_records=[{"host": "mx.example.test", "priority": 10}],
|
| 186 |
+
dns_inconsistent=True, mx_source="doh:dual",
|
| 187 |
+
)
|
| 188 |
+
mailbox = AsyncMock()
|
| 189 |
+
mailbox.name = "fake"
|
| 190 |
+
mailbox.verify = AsyncMock(
|
| 191 |
+
return_value=SMTPResult(verified=None, error="smtp_unreachable: egress")
|
| 192 |
+
)
|
| 193 |
+
mailbox.catch_all = AsyncMock(return_value=(True, 0.9))
|
| 194 |
+
scorer = FakeScorer(score=0.3, label="low_risk")
|
| 195 |
+
eng = _build_engine(mailbox, scorer)
|
| 196 |
+
|
| 197 |
+
patches = _patch_pipeline(dns=bad_dns)
|
| 198 |
+
with patches[0], patches[1], patches[2]:
|
| 199 |
+
result = await eng.validate("john@example.test", deep=True)
|
| 200 |
+
|
| 201 |
+
self.assertIn("dns_inconsistent: resolvers disagree on MX",
|
| 202 |
+
result.scoring.warnings)
|
| 203 |
+
# The inconsistency is visible to the scorer feature string.
|
| 204 |
+
self.assertIn("dns_consistent=no", scorer.calls[0])
|
| 205 |
+
|
| 206 |
+
async def test_fast_path_has_no_deterministic_verdict(self):
|
| 207 |
+
scorer = FakeScorer()
|
| 208 |
+
eng = _build_engine(AsyncMock(), scorer)
|
| 209 |
+
patches = _patch_pipeline()
|
| 210 |
+
with patches[0], patches[1], patches[2]:
|
| 211 |
+
result = await eng.validate("john@example.test", deep=False)
|
| 212 |
+
self.assertIsNone(result.deterministic_verdict)
|
| 213 |
+
self.assertEqual(scorer.calls, [])
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
class BatchPathTests(unittest.IsolatedAsyncioTestCase):
|
| 217 |
+
async def test_batch_rows_carry_normalized_fields(self):
|
| 218 |
+
class BatchMailbox:
|
| 219 |
+
name = "fake-batch"
|
| 220 |
+
|
| 221 |
+
async def verify_many(self, pairs):
|
| 222 |
+
results = {
|
| 223 |
+
e: SMTPResult(verified=None, error="smtp_unreachable: egress")
|
| 224 |
+
for e, _ in pairs
|
| 225 |
+
}
|
| 226 |
+
return results, {"example.test": True}
|
| 227 |
+
|
| 228 |
+
scorer = FakeScorer(score=0.55, label="medium_risk", fallback_used=True)
|
| 229 |
+
eng = _build_engine(BatchMailbox(), scorer)
|
| 230 |
+
|
| 231 |
+
patches = _patch_pipeline()
|
| 232 |
+
with patches[0], patches[1], patches[2]:
|
| 233 |
+
results = await eng.validate_batch(
|
| 234 |
+
["john@example.test", "jane@example.test"], deep=True
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
self.assertEqual(len(results), 2)
|
| 238 |
+
for row in results:
|
| 239 |
+
self.assertEqual(row.deterministic_verdict, "catch_all")
|
| 240 |
+
self.assertEqual(row.ai_label, "medium_risk")
|
| 241 |
+
self.assertEqual(row.ai_risk_score, 0.55)
|
| 242 |
+
self.assertTrue(row.fallback_used)
|
| 243 |
+
self.assertFalse(row.cache_hit)
|
| 244 |
+
# Feature dedupe: identical rows share one scorer call.
|
| 245 |
+
self.assertEqual(len(scorer.calls), 1)
|
| 246 |
+
|
| 247 |
+
async def test_batch_eai_refused_without_probe(self):
|
| 248 |
+
class BatchMailbox:
|
| 249 |
+
name = "fake-batch"
|
| 250 |
+
|
| 251 |
+
async def verify_many(self, pairs):
|
| 252 |
+
# Fires only if the EAI row wrongly reaches the mailbox stage.
|
| 253 |
+
assert all(
|
| 254 |
+
all(ord(c) < 128 for c in e.rsplit("@", 1)[0]) for e, _ in pairs
|
| 255 |
+
), "EAI rows must not reach the mailbox stage"
|
| 256 |
+
results = {
|
| 257 |
+
e: SMTPResult(verified=None, error="smtp_unreachable: egress")
|
| 258 |
+
for e, _ in pairs
|
| 259 |
+
}
|
| 260 |
+
return results, {"example.test": True}
|
| 261 |
+
|
| 262 |
+
ascii_syntax = GOOD_SYNTAX
|
| 263 |
+
unicode_syntax = SyntaxResult(
|
| 264 |
+
passed=True, normalized_email="用户@example.test",
|
| 265 |
+
domain="example.test", local_part="用户",
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
def syntax_side(email):
|
| 269 |
+
return unicode_syntax if "用户" in email else ascii_syntax
|
| 270 |
+
|
| 271 |
+
scorer = FakeScorer(score=0.5, label="medium_risk")
|
| 272 |
+
eng = _build_engine(BatchMailbox(), scorer)
|
| 273 |
+
|
| 274 |
+
syntax_patch, dns_patch, disposable_patch = _patch_pipeline()
|
| 275 |
+
with patch("app.validator.engine.validate_syntax", side_effect=syntax_side), \
|
| 276 |
+
dns_patch, disposable_patch:
|
| 277 |
+
results = await eng.validate_batch(
|
| 278 |
+
["john@example.test", "用户@example.test"], deep=True
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
by_email = {r.email: r for r in results}
|
| 282 |
+
self.assertEqual(by_email["用户@example.test"].reason_code, "unsupported_eai")
|
| 283 |
+
self.assertEqual(by_email["john@example.test"].deterministic_verdict,
|
| 284 |
+
"catch_all")
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
if __name__ == "__main__":
|
| 288 |
+
unittest.main()
|
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AI Risk Scorer tests (spec §3.4 / Phase 4 / Phase 6).
|
| 2 |
+
|
| 3 |
+
Pins the three invariants that make the metered layer safe:
|
| 4 |
+
1. Circuit breaker — consecutive failures open it; quota/auth failures open
|
| 5 |
+
it permanently; the heuristic takes over with fallback_used=True.
|
| 6 |
+
2. Feature privacy — the feature string carries domain + SMTP metadata,
|
| 7 |
+
never the full address.
|
| 8 |
+
3. Burn-rate accounting — every verification is counted; inference vs
|
| 9 |
+
heuristic split is observable against the <5% target.
|
| 10 |
+
"""
|
| 11 |
+
import unittest
|
| 12 |
+
from unittest.mock import AsyncMock, patch
|
| 13 |
+
|
| 14 |
+
from app.ml import hf_scorer as hs
|
| 15 |
+
from app.ml.hf_scorer import (
|
| 16 |
+
AIRiskScorer,
|
| 17 |
+
AIScore,
|
| 18 |
+
BURN_RATE_TARGET,
|
| 19 |
+
label_for_score,
|
| 20 |
+
score_many_by_domain,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _scorer(**kw) -> AIRiskScorer:
|
| 25 |
+
defaults = dict(model="test/model", token="tok", timeout=5,
|
| 26 |
+
fail_threshold=3, cooldown=300)
|
| 27 |
+
defaults.update(kw)
|
| 28 |
+
return AIRiskScorer(**defaults)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class FeaturePrivacyTests(unittest.TestCase):
|
| 32 |
+
def test_feature_string_never_contains_the_address(self):
|
| 33 |
+
features = AIRiskScorer.build_features(
|
| 34 |
+
domain="corp.test", mx_host="mx.corp.test", smtp_class="4xx",
|
| 35 |
+
catch_all=True, local_part="secret.john.doe",
|
| 36 |
+
)
|
| 37 |
+
self.assertNotIn("secret.john.doe", features)
|
| 38 |
+
self.assertNotIn("@", features)
|
| 39 |
+
self.assertIn("domain=corp.test", features)
|
| 40 |
+
self.assertIn("smtp=4xx", features)
|
| 41 |
+
self.assertIn("catch_all=yes", features)
|
| 42 |
+
|
| 43 |
+
def test_local_shape_classification(self):
|
| 44 |
+
role = AIRiskScorer.build_features(domain="d.test", mx_host=None,
|
| 45 |
+
smtp_class="accept", catch_all=False,
|
| 46 |
+
local_part="info")
|
| 47 |
+
self.assertIn("local=role", role)
|
| 48 |
+
entropy = AIRiskScorer.build_features(domain="d.test", mx_host=None,
|
| 49 |
+
smtp_class="accept", catch_all=False,
|
| 50 |
+
local_part="ab123456")
|
| 51 |
+
self.assertIn("local=high_entropy", entropy)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class LabelTests(unittest.TestCase):
|
| 55 |
+
def test_label_thresholds(self):
|
| 56 |
+
self.assertEqual(label_for_score(0.1), "low_risk")
|
| 57 |
+
self.assertEqual(label_for_score(0.5), "medium_risk")
|
| 58 |
+
self.assertEqual(label_for_score(0.9), "high_risk")
|
| 59 |
+
|
| 60 |
+
def test_label_mapping(self):
|
| 61 |
+
self.assertAlmostEqual(hs._label_risk("high_risk", 0.9), 0.9)
|
| 62 |
+
self.assertAlmostEqual(hs._label_risk("low", 0.9), 0.1)
|
| 63 |
+
self.assertIsNone(hs._label_risk("weird_unknown_label", 0.5))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class HeuristicTests(unittest.TestCase):
|
| 67 |
+
def test_greylist_scores_medium(self):
|
| 68 |
+
s = _scorer()
|
| 69 |
+
out = s.heuristic("domain=d.test mx=other smtp=4xx catch_all=yes local=normal dns_consistent=yes")
|
| 70 |
+
self.assertTrue(0.4 <= out.score < 0.7)
|
| 71 |
+
self.assertEqual(out.source, "heuristic")
|
| 72 |
+
self.assertTrue(out.fallback_used)
|
| 73 |
+
|
| 74 |
+
def test_trusted_platform_catchall_scores_lower(self):
|
| 75 |
+
s = _scorer()
|
| 76 |
+
trusted = s.heuristic("domain=d.test mx=trusted_platform smtp=accept catch_all=yes local=normal dns_consistent=yes")
|
| 77 |
+
other = s.heuristic("domain=d.test mx=other smtp=accept catch_all=yes local=normal dns_consistent=yes")
|
| 78 |
+
self.assertLess(trusted.score, other.score)
|
| 79 |
+
|
| 80 |
+
def test_scores_clamped(self):
|
| 81 |
+
s = _scorer()
|
| 82 |
+
out = s.heuristic("domain=d.test mx=other smtp=4xx catch_all=yes local=high_entropy dns_consistent=no")
|
| 83 |
+
self.assertTrue(0.0 <= out.score <= 1.0)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class InferenceTests(unittest.IsolatedAsyncioTestCase):
|
| 87 |
+
def _patch_post(self, resp_status=200, payload=None, exc=None):
|
| 88 |
+
captured = {}
|
| 89 |
+
|
| 90 |
+
class FakeResp:
|
| 91 |
+
status_code = resp_status
|
| 92 |
+
text = "quota exhausted" if resp_status == 429 else ""
|
| 93 |
+
def raise_for_status(self):
|
| 94 |
+
return None
|
| 95 |
+
def json(self):
|
| 96 |
+
return payload or []
|
| 97 |
+
|
| 98 |
+
class FakeClient:
|
| 99 |
+
def __init__(self, *a, **kw):
|
| 100 |
+
pass
|
| 101 |
+
async def __aenter__(self):
|
| 102 |
+
return self
|
| 103 |
+
async def __aexit__(self, *a):
|
| 104 |
+
return False
|
| 105 |
+
async def post(self, url, json=None, headers=None):
|
| 106 |
+
captured["url"] = url
|
| 107 |
+
captured["headers"] = headers
|
| 108 |
+
if exc:
|
| 109 |
+
raise exc
|
| 110 |
+
return FakeResp()
|
| 111 |
+
|
| 112 |
+
return patch("app.ml.hf_scorer.httpx.AsyncClient", FakeClient), captured
|
| 113 |
+
|
| 114 |
+
async def test_successful_inference(self):
|
| 115 |
+
s = _scorer()
|
| 116 |
+
payload = [[{"label": "high_risk", "score": 0.92}]]
|
| 117 |
+
p, captured = self._patch_post(payload=payload)
|
| 118 |
+
with p:
|
| 119 |
+
out = await s.score("domain=d.test mx=other smtp=4xx catch_all=yes local=normal dns_consistent=yes")
|
| 120 |
+
self.assertEqual(out.source, "inference")
|
| 121 |
+
self.assertFalse(out.fallback_used)
|
| 122 |
+
self.assertEqual(out.label, "high_risk")
|
| 123 |
+
self.assertIn("router.huggingface.co", captured["url"])
|
| 124 |
+
self.assertEqual(captured["headers"]["Authorization"], "Bearer tok")
|
| 125 |
+
self.assertEqual(s.metrics.inference_calls, 1)
|
| 126 |
+
|
| 127 |
+
async def test_unmappable_labels_degrade_to_heuristic(self):
|
| 128 |
+
s = _scorer()
|
| 129 |
+
payload = [[{"label": "gibberish_label", "score": 0.99}]]
|
| 130 |
+
p, _ = self._patch_post(payload=payload)
|
| 131 |
+
with p:
|
| 132 |
+
out = await s.score("domain=d.test mx=other smtp=4xx catch_all=yes local=normal dns_consistent=yes")
|
| 133 |
+
self.assertEqual(out.source, "heuristic")
|
| 134 |
+
self.assertTrue(out.fallback_used)
|
| 135 |
+
|
| 136 |
+
async def test_quota_error_opens_circuit_permanently(self):
|
| 137 |
+
s = _scorer()
|
| 138 |
+
p, _ = self._patch_post(resp_status=429)
|
| 139 |
+
with p:
|
| 140 |
+
out1 = await s.score("domain=d.test mx=other smtp=4xx catch_all=yes local=normal dns_consistent=yes")
|
| 141 |
+
self.assertTrue(out1.fallback_used)
|
| 142 |
+
self.assertTrue(s.breaker.permanently_open)
|
| 143 |
+
# Subsequent calls never touch the network again.
|
| 144 |
+
out2 = await s.score("domain=d.test mx=other smtp=4xx catch_all=yes local=normal dns_consistent=yes")
|
| 145 |
+
self.assertEqual(out2.source, "heuristic")
|
| 146 |
+
self.assertEqual(s.metrics.circuit_opens, 1)
|
| 147 |
+
|
| 148 |
+
async def test_transient_failures_open_after_threshold(self):
|
| 149 |
+
s = _scorer(fail_threshold=2, cooldown=300)
|
| 150 |
+
p, _ = self._patch_post(resp_status=500)
|
| 151 |
+
with p:
|
| 152 |
+
await s.score("domain=d.test mx=other smtp=4xx catch_all=yes local=normal dns_consistent=yes")
|
| 153 |
+
self.assertFalse(s.breaker.is_open)
|
| 154 |
+
await s.score("domain=d.test mx=other smtp=4xx catch_all=yes local=normal dns_consistent=yes")
|
| 155 |
+
self.assertTrue(s.breaker.is_open)
|
| 156 |
+
|
| 157 |
+
async def test_disabled_inference_uses_heuristic_without_fallback_penalty(self):
|
| 158 |
+
# No model/token configured: the heuristic IS the primary source.
|
| 159 |
+
s = _scorer(model="", token="")
|
| 160 |
+
out = await s.score("domain=d.test mx=other smtp=4xx catch_all=yes local=normal dns_consistent=yes")
|
| 161 |
+
self.assertEqual(out.source, "heuristic")
|
| 162 |
+
# Not counted as a "fallback event" — nothing was configured to fall back FROM.
|
| 163 |
+
self.assertEqual(s.metrics.fallback_events, 0)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class ZeroShotAndQuotaTests(unittest.IsolatedAsyncioTestCase):
|
| 167 |
+
"""Zero-shot NLI mode + quota-exhaustion handling (HTTP 402)."""
|
| 168 |
+
|
| 169 |
+
def test_risk_from_payload_zero_shot_shape(self):
|
| 170 |
+
risk = hs._risk_from_payload(
|
| 171 |
+
{"labels": ["high_risk", "low_risk", "medium_risk"],
|
| 172 |
+
"scores": [0.7, 0.2, 0.1]}
|
| 173 |
+
)
|
| 174 |
+
# Probability-weighted expectation: 0.7*0.7 + 0.8*0.2 + 0.4*0.1 = 0.69
|
| 175 |
+
self.assertIsNotNone(risk)
|
| 176 |
+
self.assertGreaterEqual(risk, 0.6)
|
| 177 |
+
self.assertLessEqual(risk, 0.75)
|
| 178 |
+
|
| 179 |
+
def test_risk_from_payload_text_classification_shape(self):
|
| 180 |
+
risk = hs._risk_from_payload([[{"label": "low_risk", "score": 0.9}]])
|
| 181 |
+
self.assertIsNotNone(risk)
|
| 182 |
+
self.assertLessEqual(risk, 0.39)
|
| 183 |
+
|
| 184 |
+
def test_risk_from_payload_unmappable_is_none(self):
|
| 185 |
+
self.assertIsNone(hs._risk_from_payload({"labels": ["x"], "scores": [1.0]}))
|
| 186 |
+
|
| 187 |
+
async def test_402_opens_circuit_permanently(self):
|
| 188 |
+
s = _scorer()
|
| 189 |
+
|
| 190 |
+
class FakeResp:
|
| 191 |
+
status_code = 402
|
| 192 |
+
text = '{"error":"You have no remaining credits."}'
|
| 193 |
+
def raise_for_status(self):
|
| 194 |
+
return None
|
| 195 |
+
def json(self):
|
| 196 |
+
return {}
|
| 197 |
+
|
| 198 |
+
class FakeClient:
|
| 199 |
+
def __init__(self, *a, **kw):
|
| 200 |
+
pass
|
| 201 |
+
async def __aenter__(self):
|
| 202 |
+
return self
|
| 203 |
+
async def __aexit__(self, *a):
|
| 204 |
+
return False
|
| 205 |
+
async def post(self, *a, **kw):
|
| 206 |
+
return FakeResp()
|
| 207 |
+
|
| 208 |
+
with patch("app.ml.hf_scorer.httpx.AsyncClient", FakeClient):
|
| 209 |
+
out = await s.score(
|
| 210 |
+
"domain=d.test mx=other smtp=4xx catch_all=yes local=normal dns_consistent=yes"
|
| 211 |
+
)
|
| 212 |
+
self.assertTrue(s.breaker.permanently_open)
|
| 213 |
+
self.assertTrue(out.fallback_used)
|
| 214 |
+
self.assertIn("402", s.metrics.last_error)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
class BurnRateTests(unittest.IsolatedAsyncioTestCase):
|
| 218 |
+
async def test_metrics_track_verification_share(self):
|
| 219 |
+
s = _scorer(model="", token="") # heuristic-only
|
| 220 |
+
features = "domain=d.test mx=other smtp=4xx catch_all=yes local=normal dns_consistent=yes"
|
| 221 |
+
for _ in range(10):
|
| 222 |
+
await s.score(features)
|
| 223 |
+
m = s.metrics.as_dict()
|
| 224 |
+
self.assertEqual(m["verifications_scored"], 10)
|
| 225 |
+
self.assertEqual(m["heuristic_calls"], 10)
|
| 226 |
+
self.assertEqual(m["inference_calls"], 0)
|
| 227 |
+
self.assertEqual(m["burn_rate"], 0.0)
|
| 228 |
+
self.assertTrue(m["burn_rate_ok"])
|
| 229 |
+
self.assertEqual(m["burn_rate_target"], BURN_RATE_TARGET)
|
| 230 |
+
|
| 231 |
+
async def test_score_many_by_domain(self):
|
| 232 |
+
s = _scorer(model="", token="")
|
| 233 |
+
out = await score_many_by_domain(s, {
|
| 234 |
+
"a.test": "domain=a.test mx=other smtp=4xx catch_all=yes local=normal dns_consistent=yes",
|
| 235 |
+
"b.test": "domain=b.test mx=other smtp=4xx catch_all=yes local=normal dns_consistent=yes",
|
| 236 |
+
})
|
| 237 |
+
self.assertEqual(set(out.keys()), {"a.test", "b.test"})
|
| 238 |
+
self.assertTrue(all(isinstance(v, AIScore) for v in out.values()))
|
| 239 |
+
|
| 240 |
+
async def test_status_shape(self):
|
| 241 |
+
s = _scorer()
|
| 242 |
+
st = s.status()
|
| 243 |
+
self.assertTrue(st["enabled"])
|
| 244 |
+
self.assertIn("circuit", st)
|
| 245 |
+
self.assertIn("metrics", st)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
if __name__ == "__main__":
|
| 249 |
+
unittest.main()
|
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Internationalization primitives (spec §7.1 Punycode / §7.2 SMTPUTF8)."""
|
| 2 |
+
import unittest
|
| 3 |
+
|
| 4 |
+
from app.validator.i18n import (
|
| 5 |
+
EAI_REASON,
|
| 6 |
+
domain_is_unicode,
|
| 7 |
+
eai_gate,
|
| 8 |
+
local_part_is_ascii,
|
| 9 |
+
to_punycode,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class PunycodeTests(unittest.TestCase):
|
| 14 |
+
def test_ascii_domain_untouched(self):
|
| 15 |
+
self.assertEqual(to_punycode("example.com"), "example.com")
|
| 16 |
+
|
| 17 |
+
def test_unicode_domain_encoded(self):
|
| 18 |
+
out = to_punycode("bücher.example")
|
| 19 |
+
self.assertTrue(out.startswith("xn--"))
|
| 20 |
+
self.assertEqual(out.encode("ascii").decode(), out)
|
| 21 |
+
|
| 22 |
+
def test_already_punycode_roundtrip(self):
|
| 23 |
+
self.assertEqual(to_punycode("xn--bcher-kva.example"), "xn--bcher-kva.example")
|
| 24 |
+
|
| 25 |
+
def test_unicode_tld(self):
|
| 26 |
+
out = to_punycode("пример.рф")
|
| 27 |
+
self.assertTrue(all(ord(c) < 128 for c in out))
|
| 28 |
+
self.assertIn("xn--", out)
|
| 29 |
+
|
| 30 |
+
def test_garbage_domain_returns_as_is(self):
|
| 31 |
+
# Must never raise — DNS will fail naturally and the verdict degrades.
|
| 32 |
+
self.assertEqual(to_punycode("\x00bad"), "\x00bad")
|
| 33 |
+
|
| 34 |
+
def test_empty_and_dot_stripping_safe(self):
|
| 35 |
+
self.assertEqual(to_punycode(""), "")
|
| 36 |
+
self.assertEqual(to_punycode("example.com"), "example.com")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class UnicodeDetectionTests(unittest.TestCase):
|
| 40 |
+
def test_domain_is_unicode(self):
|
| 41 |
+
self.assertTrue(domain_is_unicode("münchen.de"))
|
| 42 |
+
self.assertFalse(domain_is_unicode("xn--mnchen-3ya.de"))
|
| 43 |
+
self.assertFalse(domain_is_unicode("example.com"))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class EAITests(unittest.TestCase):
|
| 47 |
+
def test_ascii_local_passes_gate(self):
|
| 48 |
+
self.assertIsNone(eai_gate("john.smith"))
|
| 49 |
+
self.assertIsNone(eai_gate("o+brien+tag"))
|
| 50 |
+
|
| 51 |
+
def test_unicode_local_blocked_deterministically(self):
|
| 52 |
+
self.assertEqual(eai_gate("用户"), EAI_REASON)
|
| 53 |
+
self.assertEqual(eai_gate("café"), EAI_REASON)
|
| 54 |
+
|
| 55 |
+
def test_ascii_flag_consistency(self):
|
| 56 |
+
self.assertTrue(local_part_is_ascii("simple"))
|
| 57 |
+
self.assertFalse(local_part_is_ascii("日本語"))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
if __name__ == "__main__":
|
| 61 |
+
unittest.main()
|
|
@@ -1,161 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import unittest
|
| 2 |
from unittest.mock import AsyncMock, patch
|
| 3 |
|
| 4 |
from app.config import settings
|
| 5 |
from app.models import SMTPResult
|
|
|
|
| 6 |
from app.validator.mailbox_provider import (
|
| 7 |
MailboxProvider,
|
| 8 |
-
MillionVerifierProvider,
|
| 9 |
LocalSmtpProvider,
|
| 10 |
AutonomousSmtpProvider,
|
|
|
|
| 11 |
resolve_provider,
|
| 12 |
-
map_mv_result,
|
| 13 |
)
|
| 14 |
|
| 15 |
|
| 16 |
-
class
|
| 17 |
-
"""
|
| 18 |
-
{"result": "ok"|"catch_all"|"invalid"|"disposable"|"unknown"|"error",
|
| 19 |
-
"quality": "good"|"risky"|"bad", "resultcode": 1..4, "free": bool, "role": bool}
|
| 20 |
-
The mapping must translate that into our SMTPResult taxonomy."""
|
| 21 |
-
|
| 22 |
-
def test_ok_maps_to_verified(self):
|
| 23 |
-
r = map_mv_result({"result": "ok", "quality": "good", "resultcode": 1, "free": False, "role": False})
|
| 24 |
-
self.assertTrue(r.verified)
|
| 25 |
-
|
| 26 |
-
def test_invalid_maps_to_rejected_5xx(self):
|
| 27 |
-
r = map_mv_result({"result": "invalid", "quality": "bad", "resultcode": 4, "free": False, "role": False})
|
| 28 |
-
self.assertFalse(r.verified)
|
| 29 |
-
self.assertEqual(r.smtp_code, 550)
|
| 30 |
-
|
| 31 |
-
def test_invalid_carries_explicit_rejection_phrase(self):
|
| 32 |
-
# A provider-confirmed dead mailbox must score 0 (Invalid), not be
|
| 33 |
-
# softened to -30 "inconclusive". calculate_score() only hard-fails a
|
| 34 |
-
# 5xx when the message carries an explicit mailbox-rejection phrase, so
|
| 35 |
-
# the adapter must supply one — otherwise a definitive "invalid" is
|
| 36 |
-
# mis-scored as risky/valid.
|
| 37 |
-
r = map_mv_result({"result": "invalid", "quality": "bad", "resultcode": 4})
|
| 38 |
-
self.assertIn("does not exist", (r.smtp_message or "").lower())
|
| 39 |
-
|
| 40 |
-
def test_catch_all_maps_to_inconclusive(self):
|
| 41 |
-
# A catch-all domain accepts the probe but proves nothing about the
|
| 42 |
-
# specific mailbox — it must NOT read as verified.
|
| 43 |
-
r = map_mv_result({"result": "catch_all", "quality": "risky", "resultcode": 2, "free": False, "role": False})
|
| 44 |
-
self.assertIsNone(r.verified)
|
| 45 |
-
|
| 46 |
-
def test_unknown_maps_to_inconclusive(self):
|
| 47 |
-
r = map_mv_result({"result": "unknown", "quality": "risky", "resultcode": 5, "free": False, "role": False})
|
| 48 |
-
self.assertIsNone(r.verified)
|
| 49 |
-
|
| 50 |
-
def test_error_maps_to_inconclusive_with_error(self):
|
| 51 |
-
r = map_mv_result({"result": "error", "error": "rate limited"})
|
| 52 |
-
self.assertIsNone(r.verified)
|
| 53 |
-
self.assertIn("rate limited", r.error)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
class MillionVerifierProviderTests(unittest.IsolatedAsyncioTestCase):
|
| 57 |
-
async def test_verify_calls_api_with_email_and_key(self):
|
| 58 |
-
provider = MillionVerifierProvider(api_key="test-key", timeout=5)
|
| 59 |
-
fake = {"result": "ok", "quality": "good", "resultcode": 1, "free": False, "role": False}
|
| 60 |
-
with patch("app.validator.mailbox_provider.httpx.AsyncClient") as client_cls:
|
| 61 |
-
resp = AsyncMock()
|
| 62 |
-
resp.raise_for_status = lambda: None
|
| 63 |
-
resp.json = lambda: fake
|
| 64 |
-
client_cls.return_value.__aenter__ = AsyncMock(return_value=AsyncMock(get=AsyncMock(return_value=resp)))
|
| 65 |
-
client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
| 66 |
-
result = await provider.verify("a@example.com", "mx.example.com")
|
| 67 |
-
self.assertTrue(result.verified)
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
provider = MillionVerifierProvider(api_key="test-key", timeout=5)
|
| 73 |
-
captured = {}
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
captured["params"] = params
|
| 78 |
-
resp = AsyncMock()
|
| 79 |
-
resp.raise_for_status = lambda: None
|
| 80 |
-
resp.json = lambda: {"result": "catch_all", "quality": "risky", "resultcode": 2}
|
| 81 |
-
return resp
|
| 82 |
-
client_cls.return_value.__aenter__ = AsyncMock(return_value=AsyncMock(get=fake_get))
|
| 83 |
-
client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
| 84 |
-
is_catch_all, confidence = await provider.catch_all("example.com", "mx.example.com")
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
class EngineProviderVerdictTests(unittest.IsolatedAsyncioTestCase):
|
| 92 |
-
"""End-to-end:
|
| 93 |
-
(Invalid) through the whole engine
|
| 94 |
-
|
| 95 |
|
| 96 |
-
async def
|
| 97 |
-
from unittest.mock import AsyncMock, patch as _patch
|
| 98 |
from app.models import DNSResult, DisposableResult, SyntaxResult
|
| 99 |
from app.validator.engine import EmailValidationEngine
|
| 100 |
|
| 101 |
-
engine = EmailValidationEngine(mailbox=MillionVerifierProvider(api_key="k"))
|
| 102 |
-
|
| 103 |
class DeadMailboxProvider:
|
| 104 |
async def verify(self, email, mx_host):
|
| 105 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
async def catch_all(self, domain, mx_host):
|
| 107 |
return (False, 0.9)
|
| 108 |
|
| 109 |
-
|
| 110 |
with (
|
| 111 |
-
|
| 112 |
passed=True, normalized_email="a@example.test",
|
| 113 |
domain="example.test", local_part="a",
|
| 114 |
)),
|
| 115 |
-
|
| 116 |
domain_exists=True, has_mx_records=True,
|
| 117 |
mx_records=[{"host": "mx.example.test", "priority": 10}],
|
| 118 |
))),
|
| 119 |
-
|
| 120 |
is_disposable=False, is_free_provider=False, is_role_based=False,
|
| 121 |
)),
|
| 122 |
):
|
| 123 |
-
result = await
|
| 124 |
|
| 125 |
self.assertEqual(result.scoring.score, 0)
|
| 126 |
self.assertEqual(result.status.value, "invalid")
|
| 127 |
self.assertEqual(result.reason_code, "rcpt_5xx")
|
|
|
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
-
class
|
| 131 |
-
|
| 132 |
-
with patch.object(settings, "mailbox_provider", "api"), patch.object(
|
| 133 |
-
settings, "mv_api_key", "k"
|
| 134 |
-
):
|
| 135 |
-
self.assertIsInstance(resolve_provider(), MillionVerifierProvider)
|
| 136 |
|
| 137 |
-
def
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
| 140 |
|
| 141 |
-
def
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
-
def
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
-
def
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
|
| 161 |
class CircuitBreakerTests(unittest.TestCase):
|
|
|
|
| 1 |
+
"""Mailbox-layer tests after the v3.0.0 autonomy refactor.
|
| 2 |
+
|
| 3 |
+
The MillionVerifier delegation was removed BY DESIGN: the engine must run with
|
| 4 |
+
no vendor account, key, or per-check fee, and a stray key must never be able
|
| 5 |
+
to re-route the recipient list through a third party. These tests pin that
|
| 6 |
+
decision: no MV symbol is importable, no settings knob re-enables it, and the
|
| 7 |
+
explicit 5xx mailbox-rejection path (previously exercised through the MV
|
| 8 |
+
adapter) still hard-fails to score 0 through a plain provider.
|
| 9 |
+
"""
|
| 10 |
import unittest
|
| 11 |
from unittest.mock import AsyncMock, patch
|
| 12 |
|
| 13 |
from app.config import settings
|
| 14 |
from app.models import SMTPResult
|
| 15 |
+
from app.validator import mailbox_provider as mp
|
| 16 |
from app.validator.mailbox_provider import (
|
| 17 |
MailboxProvider,
|
|
|
|
| 18 |
LocalSmtpProvider,
|
| 19 |
AutonomousSmtpProvider,
|
| 20 |
+
RemoteSmtpWorkerProvider,
|
| 21 |
resolve_provider,
|
|
|
|
| 22 |
)
|
| 23 |
|
| 24 |
|
| 25 |
+
class AutonomyInvariantTests(unittest.TestCase):
|
| 26 |
+
"""The engine is autonomous: no third-party verification API exists."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
+
def test_millionverifier_is_gone(self):
|
| 29 |
+
self.assertFalse(hasattr(mp, "MillionVerifierProvider"))
|
| 30 |
+
self.assertFalse(hasattr(mp, "map_mv_result"))
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
def test_config_has_no_mv_knobs(self):
|
| 33 |
+
from app.config import Settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
for field in ("mv_api_key", "mv_timeout", "prefer_api_provider"):
|
| 36 |
+
self.assertFalse(
|
| 37 |
+
hasattr(settings, field),
|
| 38 |
+
f"settings.{field} must not exist after the autonomy refactor",
|
| 39 |
+
)
|
| 40 |
+
self.assertFalse(
|
| 41 |
+
field in Settings.model_fields,
|
| 42 |
+
f"Settings.{field} must not be declared",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
def test_api_mode_is_no_longer_a_valid_choice(self):
|
| 46 |
+
# Any leftover "api" configuration degrades to the built-in engine
|
| 47 |
+
# instead of raising or silently selecting a vendor.
|
| 48 |
+
with patch.object(settings, "mailbox_provider", "api"), patch.object(
|
| 49 |
+
settings, "smtp_worker_url", ""
|
| 50 |
+
):
|
| 51 |
+
provider = resolve_provider()
|
| 52 |
+
self.assertIsInstance(provider, (AutonomousSmtpProvider, RemoteSmtpWorkerProvider))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class ResolveProviderTests(unittest.TestCase):
|
| 56 |
+
def test_local_forced_is_autonomous(self):
|
| 57 |
+
with patch.object(settings, "mailbox_provider", "local"), patch.object(
|
| 58 |
+
settings, "smtp_worker_url", "https://worker.example.com"
|
| 59 |
+
):
|
| 60 |
+
self.assertIsInstance(resolve_provider(), AutonomousSmtpProvider)
|
| 61 |
+
|
| 62 |
+
def test_auto_without_worker_is_autonomous(self):
|
| 63 |
+
with patch.object(settings, "mailbox_provider", "auto"), patch.object(
|
| 64 |
+
settings, "smtp_worker_url", ""
|
| 65 |
+
):
|
| 66 |
+
self.assertIsInstance(resolve_provider(), AutonomousSmtpProvider)
|
| 67 |
+
|
| 68 |
+
def test_auto_with_worker_resolves_remote(self):
|
| 69 |
+
with patch.object(settings, "mailbox_provider", "auto"), patch.object(
|
| 70 |
+
settings, "smtp_worker_url", "https://worker.example.com"
|
| 71 |
+
):
|
| 72 |
+
self.assertIsInstance(resolve_provider(), RemoteSmtpWorkerProvider)
|
| 73 |
+
|
| 74 |
+
def test_remote_forced_without_url_degrades_to_autonomous(self):
|
| 75 |
+
with patch.object(settings, "mailbox_provider", "remote"), patch.object(
|
| 76 |
+
settings, "smtp_worker_url", ""
|
| 77 |
+
):
|
| 78 |
+
self.assertIsInstance(resolve_provider(), AutonomousSmtpProvider)
|
| 79 |
+
|
| 80 |
+
def test_providers_satisfy_protocol(self):
|
| 81 |
+
for cls in (AutonomousSmtpProvider, RemoteSmtpWorkerProvider, LocalSmtpProvider):
|
| 82 |
+
self.assertTrue(hasattr(cls, "verify"))
|
| 83 |
+
self.assertTrue(hasattr(cls, "catch_all"))
|
| 84 |
+
self.assertTrue(issubclass(cls, MailboxProvider))
|
| 85 |
|
| 86 |
|
| 87 |
class EngineProviderVerdictTests(unittest.IsolatedAsyncioTestCase):
|
| 88 |
+
"""End-to-end: an explicit 5xx mailbox rejection must hard-fail to score 0
|
| 89 |
+
(Invalid) through the whole engine — the invariant the MV adapter used to
|
| 90 |
+
guarantee, now pinned against a plain provider."""
|
| 91 |
|
| 92 |
+
async def test_explicit_rejection_scores_zero(self):
|
|
|
|
| 93 |
from app.models import DNSResult, DisposableResult, SyntaxResult
|
| 94 |
from app.validator.engine import EmailValidationEngine
|
| 95 |
|
|
|
|
|
|
|
| 96 |
class DeadMailboxProvider:
|
| 97 |
async def verify(self, email, mx_host):
|
| 98 |
+
return SMTPResult(
|
| 99 |
+
verified=False,
|
| 100 |
+
smtp_code=550,
|
| 101 |
+
smtp_message="550 mailbox does not exist",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
async def catch_all(self, domain, mx_host):
|
| 105 |
return (False, 0.9)
|
| 106 |
|
| 107 |
+
eng = EmailValidationEngine(mailbox=DeadMailboxProvider())
|
| 108 |
with (
|
| 109 |
+
patch("app.validator.engine.validate_syntax", return_value=SyntaxResult(
|
| 110 |
passed=True, normalized_email="a@example.test",
|
| 111 |
domain="example.test", local_part="a",
|
| 112 |
)),
|
| 113 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=DNSResult(
|
| 114 |
domain_exists=True, has_mx_records=True,
|
| 115 |
mx_records=[{"host": "mx.example.test", "priority": 10}],
|
| 116 |
))),
|
| 117 |
+
patch("app.validator.engine.check_disposable", return_value=DisposableResult(
|
| 118 |
is_disposable=False, is_free_provider=False, is_role_based=False,
|
| 119 |
)),
|
| 120 |
):
|
| 121 |
+
result = await eng.validate("a@example.test", deep=True)
|
| 122 |
|
| 123 |
self.assertEqual(result.scoring.score, 0)
|
| 124 |
self.assertEqual(result.status.value, "invalid")
|
| 125 |
self.assertEqual(result.reason_code, "rcpt_5xx")
|
| 126 |
+
# The normalized verdict agrees with the deterministic gauntlet.
|
| 127 |
+
self.assertEqual(result.deterministic_verdict, "invalid")
|
| 128 |
|
| 129 |
|
| 130 |
+
class RemoteSmtpWorkerProviderTests(unittest.IsolatedAsyncioTestCase):
|
| 131 |
+
"""The remote worker path (spec §3.3): orchestrator delegates port-25."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
+
def _provider(self):
|
| 134 |
+
return RemoteSmtpWorkerProvider(
|
| 135 |
+
url="https://worker.example.com", token="s3cret", timeout=5,
|
| 136 |
+
fail_threshold=2, cooldown_seconds=60,
|
| 137 |
+
)
|
| 138 |
|
| 139 |
+
async def test_verify_posts_to_worker(self):
|
| 140 |
+
p = self._provider()
|
| 141 |
+
payload = {"verified": True, "smtp_code": 250, "smtp_message": "ok"}
|
| 142 |
+
with patch("app.validator.mailbox_provider.httpx.AsyncClient") as client_cls:
|
| 143 |
+
resp = AsyncMock()
|
| 144 |
+
resp.raise_for_status = lambda: None
|
| 145 |
+
resp.json = lambda: payload
|
| 146 |
+
client = AsyncMock()
|
| 147 |
+
client.post = AsyncMock(return_value=resp)
|
| 148 |
+
client_cls.return_value.__aenter__ = AsyncMock(return_value=client)
|
| 149 |
+
client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
| 150 |
+
result = await p.verify("a@corp.test", "mx.corp.test")
|
| 151 |
+
self.assertTrue(result.verified)
|
| 152 |
+
args, kwargs = client.post.call_args
|
| 153 |
+
self.assertEqual(args[0], "https://worker.example.com/smtp/probe")
|
| 154 |
+
self.assertEqual(kwargs["headers"]["X-Worker-Token"], "s3cret")
|
| 155 |
|
| 156 |
+
async def test_worker_unreachable_is_inconclusive_and_opens_circuit(self):
|
| 157 |
+
p = self._provider()
|
| 158 |
+
with patch("app.validator.mailbox_provider.httpx.AsyncClient") as client_cls:
|
| 159 |
+
client_cls.return_value.__aenter__ = AsyncMock(
|
| 160 |
+
side_effect=RuntimeError("boom")
|
| 161 |
+
)
|
| 162 |
+
client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
| 163 |
+
r1 = await p.verify("a@corp.test", "mx.corp.test")
|
| 164 |
+
r2 = await p.verify("b@corp.test", "mx.corp.test")
|
| 165 |
+
self.assertIsNone(r1.verified)
|
| 166 |
+
self.assertIn("unreachable", (r1.error or ""))
|
| 167 |
+
# Threshold 2 reached → circuit open, next call fails fast.
|
| 168 |
+
self.assertTrue(p.circuit_open)
|
| 169 |
+
r3 = await p.verify("c@corp.test", "mx.corp.test")
|
| 170 |
+
self.assertIn("circuit open", (r3.error or "").lower())
|
| 171 |
+
|
| 172 |
+
async def test_catch_all_roundtrip(self):
|
| 173 |
+
p = self._provider()
|
| 174 |
+
payload = {"is_catch_all": True, "confidence": 0.95}
|
| 175 |
+
with patch("app.validator.mailbox_provider.httpx.AsyncClient") as client_cls:
|
| 176 |
+
resp = AsyncMock()
|
| 177 |
+
resp.raise_for_status = lambda: None
|
| 178 |
+
resp.json = lambda: payload
|
| 179 |
+
client = AsyncMock()
|
| 180 |
+
client.post = AsyncMock(return_value=resp)
|
| 181 |
+
client_cls.return_value.__aenter__ = AsyncMock(return_value=client)
|
| 182 |
+
client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
| 183 |
+
is_ca, confidence = await p.catch_all("corp.test", "mx.corp.test")
|
| 184 |
+
self.assertTrue(is_ca)
|
| 185 |
+
self.assertGreater(confidence, 0.5)
|
| 186 |
|
| 187 |
+
async def test_verify_many_batch_roundtrip(self):
|
| 188 |
+
p = self._provider()
|
| 189 |
+
payload = {
|
| 190 |
+
"results": {
|
| 191 |
+
"a@corp.test": {"verified": True, "smtp_code": 250},
|
| 192 |
+
"b@corp.test": {"verified": False, "smtp_code": 550},
|
| 193 |
+
},
|
| 194 |
+
"catch_all": {"corp.test": False},
|
| 195 |
+
}
|
| 196 |
+
with patch("app.validator.mailbox_provider.httpx.AsyncClient") as client_cls:
|
| 197 |
+
resp = AsyncMock()
|
| 198 |
+
resp.raise_for_status = lambda: None
|
| 199 |
+
resp.json = lambda: payload
|
| 200 |
+
client = AsyncMock()
|
| 201 |
+
client.post = AsyncMock(return_value=resp)
|
| 202 |
+
client_cls.return_value.__aenter__ = AsyncMock(return_value=client)
|
| 203 |
+
client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
| 204 |
+
results, catch_all = await p.verify_many(
|
| 205 |
+
[("a@corp.test", "mx"), ("b@corp.test", "mx")]
|
| 206 |
+
)
|
| 207 |
+
self.assertTrue(results["a@corp.test"].verified)
|
| 208 |
+
self.assertFalse(results["b@corp.test"].verified)
|
| 209 |
+
self.assertEqual(catch_all, {"corp.test": False})
|
| 210 |
|
| 211 |
|
| 212 |
class CircuitBreakerTests(unittest.TestCase):
|
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Worker endpoint tests (spec §3.3) — POST /api/v1/smtp/probe*.
|
| 2 |
+
|
| 3 |
+
Uses FastAPI TestClient against the real router so auth open-prefix handling
|
| 4 |
+
and the X-Worker-Token guard are exercised end-to-end.
|
| 5 |
+
"""
|
| 6 |
+
import unittest
|
| 7 |
+
from unittest.mock import AsyncMock, patch
|
| 8 |
+
|
| 9 |
+
from fastapi.testclient import TestClient
|
| 10 |
+
|
| 11 |
+
from app.config import settings
|
| 12 |
+
from app.main import app
|
| 13 |
+
from app.models import SMTPResult
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _client():
|
| 17 |
+
return TestClient(app)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class WorkerEndpointTests(unittest.TestCase):
|
| 21 |
+
def setUp(self):
|
| 22 |
+
self.probe_payload = {"email": "a@corp.test", "mx_host": "mx.corp.test"}
|
| 23 |
+
|
| 24 |
+
def test_mailbox_probe_returns_smtp_result(self):
|
| 25 |
+
with patch(
|
| 26 |
+
"app.validator.mailbox_provider.AutonomousSmtpProvider.verify",
|
| 27 |
+
new=AsyncMock(return_value=SMTPResult(verified=True, smtp_code=250)),
|
| 28 |
+
):
|
| 29 |
+
with _client() as client:
|
| 30 |
+
res = client.post("/api/v1/smtp/probe", json=self.probe_payload)
|
| 31 |
+
self.assertEqual(res.status_code, 200)
|
| 32 |
+
body = res.json()
|
| 33 |
+
self.assertTrue(body["verified"])
|
| 34 |
+
self.assertEqual(body["smtp_code"], 250)
|
| 35 |
+
|
| 36 |
+
def test_catch_all_probe_shape(self):
|
| 37 |
+
with patch(
|
| 38 |
+
"app.validator.mailbox_provider.AutonomousSmtpProvider.catch_all",
|
| 39 |
+
new=AsyncMock(return_value=(True, 0.95)),
|
| 40 |
+
):
|
| 41 |
+
with _client() as client:
|
| 42 |
+
res = client.post(
|
| 43 |
+
"/api/v1/smtp/probe",
|
| 44 |
+
json={"domain": "corp.test", "mx_host": "mx.corp.test",
|
| 45 |
+
"probe": "catch_all"},
|
| 46 |
+
)
|
| 47 |
+
self.assertEqual(res.status_code, 200)
|
| 48 |
+
self.assertEqual(res.json(), {"is_catch_all": True, "confidence": 0.95})
|
| 49 |
+
|
| 50 |
+
def test_batch_probe_roundtrip(self):
|
| 51 |
+
with patch(
|
| 52 |
+
"app.validator.mailbox_provider.AutonomousSmtpProvider.verify_many",
|
| 53 |
+
new=AsyncMock(return_value=(
|
| 54 |
+
{"a@corp.test": SMTPResult(verified=True, smtp_code=250)},
|
| 55 |
+
{"corp.test": False},
|
| 56 |
+
)),
|
| 57 |
+
):
|
| 58 |
+
with _client() as client:
|
| 59 |
+
res = client.post(
|
| 60 |
+
"/api/v1/smtp/probe/batch",
|
| 61 |
+
json={"pairs": [["a@corp.test", "mx.corp.test"]]},
|
| 62 |
+
)
|
| 63 |
+
self.assertEqual(res.status_code, 200)
|
| 64 |
+
body = res.json()
|
| 65 |
+
self.assertTrue(body["results"]["a@corp.test"]["verified"])
|
| 66 |
+
self.assertEqual(body["catch_all"], {"corp.test": False})
|
| 67 |
+
|
| 68 |
+
def test_token_guard_rejects_bad_token(self):
|
| 69 |
+
with patch.object(settings, "smtp_worker_token", "s3cret"):
|
| 70 |
+
with _client() as client:
|
| 71 |
+
res = client.post("/api/v1/smtp/probe", json=self.probe_payload)
|
| 72 |
+
self.assertEqual(res.status_code, 401)
|
| 73 |
+
|
| 74 |
+
def test_token_guard_accepts_good_token(self):
|
| 75 |
+
with patch.object(settings, "smtp_worker_token", "s3cret"), patch(
|
| 76 |
+
"app.validator.mailbox_provider.AutonomousSmtpProvider.verify",
|
| 77 |
+
new=AsyncMock(return_value=SMTPResult(verified=True, smtp_code=250)),
|
| 78 |
+
):
|
| 79 |
+
with _client() as client:
|
| 80 |
+
res = client.post(
|
| 81 |
+
"/api/v1/smtp/probe",
|
| 82 |
+
json=self.probe_payload,
|
| 83 |
+
headers={"X-Worker-Token": "s3cret"},
|
| 84 |
+
)
|
| 85 |
+
self.assertEqual(res.status_code, 200)
|
| 86 |
+
|
| 87 |
+
def test_probe_requires_email_or_domain(self):
|
| 88 |
+
with _client() as client:
|
| 89 |
+
res = client.post("/api/v1/smtp/probe", json={"mx_host": "mx.corp.test"})
|
| 90 |
+
self.assertEqual(res.status_code, 422)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class OpsEndpointTests(unittest.TestCase):
|
| 94 |
+
def test_engine_status_shape(self):
|
| 95 |
+
with _client() as client:
|
| 96 |
+
res = client.get("/api/v1/engine/status")
|
| 97 |
+
self.assertEqual(res.status_code, 200)
|
| 98 |
+
body = res.json()
|
| 99 |
+
self.assertEqual(body["version"], settings.app_version)
|
| 100 |
+
self.assertIn("dns", body)
|
| 101 |
+
self.assertIn("smtp", body)
|
| 102 |
+
self.assertIn("ai_scorer", body)
|
| 103 |
+
self.assertIn("egress_port25", body["smtp"])
|
| 104 |
+
|
| 105 |
+
def test_inference_metrics_shape(self):
|
| 106 |
+
with _client() as client:
|
| 107 |
+
res = client.get("/api/v1/metrics/inference")
|
| 108 |
+
self.assertEqual(res.status_code, 200)
|
| 109 |
+
body = res.json()
|
| 110 |
+
for key in ("verifications_scored", "inference_calls", "heuristic_calls",
|
| 111 |
+
"burn_rate", "burn_rate_target", "burn_rate_ok"):
|
| 112 |
+
self.assertIn(key, body)
|
| 113 |
+
|
| 114 |
+
def test_health_carries_workers_summary(self):
|
| 115 |
+
with _client() as client:
|
| 116 |
+
res = client.get("/api/v1/health")
|
| 117 |
+
self.assertEqual(res.status_code, 200)
|
| 118 |
+
body = res.json()
|
| 119 |
+
self.assertIn("workers", body)
|
| 120 |
+
self.assertIn("ai_scorer", body["workers"])
|
| 121 |
+
self.assertEqual(body["version"], settings.app_version)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
unittest.main()
|