FortyDN Dev commited on
Commit
de745bf
·
1 Parent(s): 4661ac1

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 CHANGED
@@ -1,50 +1,11 @@
1
- # ========================================================================
2
- # EmailValidator Pro - Environment Configuration
3
- # ========================================================================
4
- # Supabase Project: ciytvulujiqemqljablq
5
- # Dashboard: https://supabase.com/dashboard/project/ciytvulujiqemqljablq
6
- # ========================================================================
7
-
8
- # --- Application ---
9
- APP_NAME="EmailValidator Pro"
10
- APP_VERSION="2.0.0"
11
- DEBUG=true
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).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.env.example CHANGED
@@ -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 is AUTONOMOUS by default: it probes recipient MX servers
44
- # directly on port 25 with its own connection-reusing SMTP engine. No vendor
45
- # account, API key, or per-check fee is required.
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. Run on a VPS/host with
51
- # port-25 egress for real mailbox verdicts.
52
- MAILBOX_PROVIDER=auto # auto | local(=autonomous) | api
53
- # Optional: route Layers 5-6 through a third-party API instead of own SMTP.
54
- # Off by default so the engine stays independent even if a key is present.
55
- PREFER_API_PROVIDER=false
56
- MV_API_KEY=
57
- MV_TIMEOUT=10
58
-
59
- # SMTP identity used in the probe dialogue. Use a real, resolvable hostname with
60
- # a matching PTR record on a production host — strict receivers reject generic
61
- # HELO names and that degrades accuracy.
62
- SMTP_HELO_DOMAIN=validator.local
63
- SMTP_MAIL_FROM=probe@validator.local
64
-
65
- # Catch-all is a domain property: cache the verdict for 72h.
66
- CATCH_ALL_TTL_SECONDS=259200
67
- # Per-MX-host session cap and overall bulk concurrency.
68
- MAX_CONNECTIONS_PER_HOST=2
69
- BULK_CONCURRENCY=20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
app/api/routes.py CHANGED
@@ -1,466 +1,580 @@
1
- from fastapi import APIRouter, HTTPException, Query
2
- from fastapi.responses import JSONResponse
3
- import json
4
- import time
5
- import asyncio
6
- import uuid
7
-
8
- from ..models import (
9
- SingleEmailRequest, BulkEmailRequest,
10
- ValidationResult, BulkValidationResponse,
11
- TaskStatusResponse
12
- )
13
- from ..validator.engine import engine
14
- from ..utils.cache import get_cache, set_cache
15
- from ..config import settings
16
- from ..db import repo
17
-
18
- # Strong references to in-flight background bulk jobs (the event loop only
19
- # keeps weak ones, so a task can vanish mid-run and its results are lost), and
20
- # a cap so a burst of bulk requests cannot spawn unbounded SMTP work.
21
- _background_tasks: set[asyncio.Task] = set()
22
- _bulk_semaphore = asyncio.Semaphore(settings.bulk_concurrency)
23
-
24
- # Celery task handle (module level so the routing is testable/patchable).
25
- # Import failure degrades to the background-task path; Celery itself is only
26
- # required when celery_bulk_enabled=True.
27
- try:
28
- from ..tasks.celery_tasks import bulk_validate_task # noqa: E402
29
- except Exception: # pragma: no cover - celery not installed
30
- bulk_validate_task = None
31
-
32
- router = APIRouter()
33
-
34
-
35
- async def process_bulk_sync(emails: list, webhook_url: str = None, deep: bool = True) -> dict:
36
- """
37
- Process bulk validation synchronously in chunks.
38
- Used when Celery is unavailable.
39
-
40
- `deep` defaults to True so bulk runs the full 7-layer pipeline (catch-all +
41
- SMTP) exactly like single verification — the product charges 1 credit/email
42
- and must not silently degrade to a syntax+DNS-only check.
43
-
44
- Chunks feed the engine's *batched* path: DNS/MX resolves once per domain and
45
- the mailbox stage takes the whole chunk in one call, so the SMTP engine reuses
46
- a session per receiving host instead of opening a TCP connection per address.
47
- """
48
- results = []
49
- total = len(emails)
50
- chunk_size = settings.bulk_sync_chunk_size # Configurable chunk size
51
-
52
- for i in range(0, total, chunk_size):
53
- chunk = emails[i:i + chunk_size]
54
- try:
55
- chunk_results = await engine.validate_batch(chunk, deep=deep)
56
- except Exception as exc: # never lose a chunk to one bad stage
57
- results.extend(
58
- {
59
- "email": email,
60
- "error": str(exc) or "Unknown error",
61
- "status": "error",
62
- }
63
- for email in chunk
64
- )
65
- continue
66
-
67
- # validate_batch guarantees one result per input, in input order.
68
- if len(chunk_results) == len(chunk):
69
- pairs = zip(chunk, chunk_results)
70
- else:
71
- # Defensive: never lose or mis-attribute a row.
72
- by_email = {getattr(r, "email", "").lower(): r for r in chunk_results}
73
- pairs = ((email, by_email.get(email.lower())) for email in chunk)
74
-
75
- for email, result in pairs:
76
- if isinstance(result, ValidationResult):
77
- results.append(result.model_dump())
78
- else:
79
- results.append(
80
- {
81
- "email": email,
82
- "error": "engine returned no result for this address",
83
- "status": "error",
84
- }
85
- )
86
-
87
- # Webhook notification if provided
88
- if webhook_url:
89
- import httpx
90
- try:
91
- async with httpx.AsyncClient(timeout=10) as client:
92
- await client.post(webhook_url, json={"results": results})
93
- except Exception:
94
- pass
95
-
96
- # Run-quality monitor (report item 10): fire the signed system-alert
97
- # webhook when this run's risky/unknown share crosses the threshold.
98
- try:
99
- from ..utils.rate_alerts import maybe_alert_risky_rate
100
-
101
- await maybe_alert_risky_rate(
102
- results,
103
- webhook_url=webhook_url,
104
- job_id="sync",
105
- threshold=settings.risky_rate_alert_threshold,
106
- )
107
- except Exception:
108
- pass
109
-
110
- return results
111
-
112
-
113
- # ---- Single Email Validation --------------------------------------
114
- @router.post(
115
- "/validate/single",
116
- response_model=ValidationResult,
117
- summary="Validate a single email address",
118
- tags=["Validation"]
119
- )
120
- async def validate_single(
121
- request: SingleEmailRequest,
122
- deep: bool = Query(default=True, description="Enable SMTP verification (slower but more accurate)")
123
- ):
124
- """
125
- Full 7-layer email validation:
126
- Syntax → DNS → MX → Disposable → Catch-All → SMTP → Score
127
- """
128
- email = request.email
129
-
130
- # Check cache first
131
- cache_key = f"val:{email}:{'deep' if deep else 'fast'}"
132
- cached = await get_cache(cache_key)
133
- if cached:
134
- result = ValidationResult(**json.loads(cached))
135
- return result
136
-
137
- # Run full validation
138
- result = await engine.validate(email, deep=deep)
139
-
140
- # Cache the result
141
- await set_cache(cache_key, result.model_dump_json(), ttl=settings.redis_cache_ttl)
142
-
143
- # Persist to Supabase (best-effort)
144
- if settings.enable_db_persistence:
145
- repo.save_validation_result(result)
146
-
147
- return result
148
-
149
-
150
- # ---- Bulk Email Validation ----------------------------------------
151
- @router.post(
152
- "/validate/bulk",
153
- summary="Validate multiple email addresses (async)",
154
- tags=["Validation"]
155
- )
156
- async def validate_bulk(request: BulkEmailRequest):
157
- """
158
- Async bulk validation. Returns a task_id.
159
- Poll /validate/bulk/status/{task_id} for results.
160
-
161
- Uses Celery when Redis is available; otherwise falls back to a
162
- synchronous pass (now supports any batch size).
163
- """
164
- print(f"[BULK] Received bulk validation request for {len(request.emails)} emails")
165
-
166
- # Only use Celery when the broker is actually reachable — otherwise
167
- # .delay() would block retrying the Redis connection.
168
- try:
169
- from ..utils.cache import redis_available
170
- use_celery = (settings.celery_bulk_enabled
171
- and await redis_available())
172
- print(f"[BULK] Redis available: {use_celery}")
173
- except Exception as e:
174
- print(f"[BULK] Redis check failed: {str(e)}")
175
- use_celery = False
176
-
177
- if use_celery:
178
- try:
179
- task = bulk_validate_task.delay(request.emails, request.webhook_url, request.deep)
180
- # Record the job in Supabase so it can be tracked across restarts
181
- if settings.enable_db_persistence:
182
- repo.create_bulk_job(task.id, request.emails)
183
- return BulkValidationResponse(
184
- task_id=task.id,
185
- status="processing",
186
- total=len(request.emails),
187
- message=f"Processing {len(request.emails)} emails. Poll /validate/bulk/status/{task.id}"
188
- )
189
- except Exception as exc:
190
- print(f"[BULK] Celery unavailable ({exc}) — using synchronous fallback.")
191
-
192
- # Synchronous fallback for any size list — but NON-BLOCKING: the request
193
- # returns immediately with a task_id, and the results are processed in a
194
- # background asyncio task. The old blocking behavior held the HTTP request
195
- # for the entire validation (60-112s on a blocked-egress host), which
196
- # exceeded the frontend's 45s fetch timeout and made the Results page
197
- # show nothing.
198
- task_id = uuid.uuid4().hex
199
- print(f"[BULK] Processing {len(request.emails)} emails in background (task {task_id})")
200
-
201
- # Record the job BEFORE processing: without this row, finish_bulk_job is an
202
- # UPDATE that matches nothing, and in-flight polls hit the "Redis down"
203
- # branch and 404 — which the frontend treats as terminal.
204
- if settings.enable_db_persistence:
205
- try:
206
- repo.create_bulk_job(task_id, request.emails)
207
- except Exception as exc:
208
- print(f"[BULK] create_bulk_job failed: {exc}")
209
-
210
- async def _run_and_store():
211
- async with _bulk_semaphore:
212
- try:
213
- results = await process_bulk_sync(
214
- request.emails, request.webhook_url, deep=request.deep
215
- )
216
- if settings.enable_db_persistence:
217
- try:
218
- repo.finish_bulk_job(task_id, results)
219
- except Exception as exc:
220
- print(f"[BULK] finish_bulk_job failed: {exc}")
221
- await set_cache(
222
- f"bulk_result:{task_id}",
223
- json.dumps({"status": "completed", "total": len(request.emails),
224
- "results": results}),
225
- ttl=settings.redis_cache_ttl,
226
- )
227
- print(f"[BULK] task {task_id} completed: {len(results)} results")
228
- except Exception as exc:
229
- print(f"[BULK] task {task_id} failed: {exc}")
230
- await set_cache(
231
- f"bulk_result:{task_id}",
232
- json.dumps({"status": "error", "error": str(exc),
233
- "results": []}),
234
- ttl=settings.redis_cache_ttl,
235
- )
236
-
237
- # The loop holds only a WEAK reference to tasks: without a strong ref the
238
- # job can be garbage-collected mid-run and its results silently lost.
239
- task = asyncio.create_task(_run_and_store())
240
- _background_tasks.add(task)
241
- task.add_done_callback(_background_tasks.discard)
242
-
243
- return JSONResponse(content={
244
- "task_id": task_id,
245
- "status": "processing",
246
- "total": len(request.emails),
247
- "message": f"Processing {len(request.emails)} emails. Poll /validate/bulk/status/{task_id}",
248
- })
249
-
250
-
251
- @router.get("/validate/bulk/status/{task_id}", response_model=TaskStatusResponse, tags=["Validation"])
252
- async def get_bulk_status(task_id: str):
253
- """Poll the status of a bulk validation task."""
254
- from ..utils.cache import redis_available
255
-
256
- def _row_to_result(row):
257
- """Best-effort coercion of a stored/fallback row into a ValidationResult."""
258
- if row is None or not isinstance(row, dict):
259
- return None
260
- if "scoring" not in row and "score" in row:
261
- row = {
262
- **row,
263
- "status": row.get("status") or "unknown",
264
- "scoring": {
265
- "score": row.get("score") or 0,
266
- "risk_level": row.get("risk_level") or "low",
267
- },
268
- }
269
- try:
270
- return ValidationResult(**row)
271
- except Exception:
272
- return None
273
-
274
- def _normalize_results(raw):
275
- rows = raw if isinstance(raw, list) else []
276
- results = [r for r in (_row_to_result(row) for row in rows) if r is not None]
277
- return results or None # None when absent → serialized as "results": null
278
-
279
- # Background-processed bulk results (the non-Celery async path) are stored
280
- # in the shared cache — check it FIRST so polling works for every task id.
281
- stored = await get_cache(f"bulk_result:{task_id}")
282
- if stored:
283
- try:
284
- data = json.loads(stored)
285
- return TaskStatusResponse(
286
- task_id=task_id,
287
- status=data.get("status", "completed"),
288
- progress=data.get("total"),
289
- total=data.get("total"),
290
- results=_normalize_results(data.get("results")),
291
- )
292
- except Exception:
293
- pass # corrupt payload -> fall through to the other backends
294
-
295
- if task_id == "sync":
296
- # Sync-fallback results are persisted next to the "sync" job row; read
297
- # them back so polling keeps working after the original response.
298
- if settings.enable_db_persistence:
299
- job = repo.get_bulk_job(task_id)
300
- if job:
301
- return TaskStatusResponse(
302
- task_id=task_id,
303
- status=job.get("status", "completed") or "completed",
304
- progress=job.get("processed"),
305
- total=job.get("total"),
306
- results=_normalize_results(job.get("results")),
307
- )
308
- return TaskStatusResponse(task_id=task_id, status="completed")
309
- if not await redis_available():
310
- # Redis down → fall back to Supabase job tracking (never hang on the backend)
311
- if settings.enable_db_persistence:
312
- job = repo.get_bulk_job(task_id)
313
- if job:
314
- return TaskStatusResponse(
315
- task_id=task_id,
316
- status=job.get("status", "unknown"),
317
- progress=job.get("processed"),
318
- total=job.get("total"),
319
- results=_normalize_results(job.get("results"))
320
- )
321
- # An in-flight background job has no cached result yet. Report
322
- # "processing" instead of 404 — the frontend treats 404 as terminal and
323
- # would abandon a job that is still running.
324
- if any(not t.done() for t in _background_tasks):
325
- return TaskStatusResponse(task_id=task_id, status="processing")
326
- raise HTTPException(status_code=404, detail=f"Task not found: {task_id}")
327
-
328
- try:
329
- from celery.result import AsyncResult
330
- result = AsyncResult(task_id)
331
- payload = {
332
- "task_id": task_id,
333
- "status": result.status.lower(),
334
- }
335
- # Surface live progress while the task is still running. Celery's
336
- # PROGRESS state stores {"current": n, "total": n} in result.info.
337
- if result.status == "PROGRESS" and isinstance(result.info, dict):
338
- payload["progress"] = result.info.get("current")
339
- payload["total"] = result.info.get("total")
340
- if result.ready():
341
- raw = result.result if isinstance(result.result, list) else []
342
- payload["results"] = _normalize_results(raw)
343
- # Persist finished job so it survives worker restarts
344
- if settings.enable_db_persistence:
345
- repo.finish_bulk_job(task_id, raw)
346
- return TaskStatusResponse(**payload)
347
- except Exception as e:
348
- # Fallback: read job progress from Supabase
349
- if settings.enable_db_persistence:
350
- job = repo.get_bulk_job(task_id)
351
- if job:
352
- return TaskStatusResponse(
353
- task_id=task_id,
354
- status=job.get("status", "unknown"),
355
- progress=job.get("processed"),
356
- total=job.get("total"),
357
- results=_normalize_results(job.get("results"))
358
- )
359
- raise HTTPException(status_code=404, detail=f"Task not found: {str(e)}")
360
-
361
-
362
- # ---- Domain Info ---------------------------------------------------
363
- @router.get("/domain/{domain}", summary="Get full DNS/authentication info for a domain", tags=["Domain"])
364
- async def get_domain_info(domain: str):
365
- """Get MX, SPF, DMARC, DKIM records for a domain."""
366
- from ..validator.dns_check import check_dns
367
- result = await check_dns(domain, timeout=settings.dns_timeout)
368
- if settings.enable_db_persistence:
369
- repo.save_domain_record(domain, result)
370
- return result
371
-
372
-
373
- # ---- Quick Check (Syntax + DNS only, no SMTP) ---------------------
374
- @router.get("/validate/quick/{email}", summary="Quick syntax + DNS check (no SMTP)", tags=["Validation"])
375
- async def quick_validate(email: str):
376
- """Syntax + DNS only. Fast. No SMTP probe."""
377
- result = await engine.validate(email, deep=False)
378
- return result
379
-
380
-
381
- # ---- Health Check --------------------------------------------------
382
- @router.get("/health", tags=["System"])
383
- async def health():
384
- db = await repo.db_status()
385
-
386
- # Check Redis and Celery status
387
- redis_status = False
388
- celery_status = False
389
- try:
390
- from ..utils.cache import redis_available
391
- redis_status = await redis_available()
392
- except Exception:
393
- pass
394
-
395
- if redis_status:
396
- try:
397
- from celery import Celery
398
- test_celery = Celery(
399
- "health_check",
400
- broker=settings.celery_broker_url,
401
- backend=settings.celery_result_backend
402
- )
403
- # Try to inspect workers
404
- inspect = test_celery.control.inspect(timeout=2)
405
- celery_status = inspect.stats() is not None
406
- except Exception:
407
- pass
408
-
409
- return {
410
- "status": "healthy",
411
- "app": settings.app_name,
412
- "version": settings.app_version,
413
- "database": db,
414
- "redis": {"available": redis_status},
415
- "celery": {"available": celery_status},
416
- "timestamp": time.time()
417
- }
418
-
419
-
420
- # ---- Database Status ------------------------------------------------
421
- @router.get("/db/status", tags=["System"], response_model=dict)
422
- async def db_status_endpoint():
423
- """Supabase connectivity + schema readiness."""
424
- return await repo.db_status()
425
-
426
-
427
- # ---- ML advisory diagnostics ----------------------------------------
428
- @router.get("/ml/status", tags=["System"])
429
- async def ml_status():
430
- """Operability of the advisory DistilBERT layer (flag / repo / load state)."""
431
- from ..config import settings
432
- from ..ml.runtime import get_model
433
-
434
- model = get_model()
435
- sample = None
436
- if model is not None and model.loaded:
437
- try:
438
- sample = model.predict(["john.smith [SEP] stripe.com"])
439
- except Exception as exc: # pragma: no cover - defensive
440
- sample = [f"predict raised: {exc}"]
441
- return {
442
- "enabled": settings.ml_disposable_enabled,
443
- "repo": settings.ml_model_repo,
444
- "loaded": bool(model is not None and model.loaded),
445
- "last_error": getattr(model, "last_error", None) if model else None,
446
- "predict_sample": sample,
447
- }
448
-
449
-
450
- # ---- History --------------------------------------------------------
451
- @router.get("/history", tags=["Validation"])
452
- async def get_history(limit: int = Query(default=50, ge=1, le=500)):
453
- """Most recent validations persisted in Supabase (newest first)."""
454
- return repo.get_validation_history(limit=limit)
455
-
456
-
457
- # ---- Stats ---------------------------------------------------------
458
- @router.get("/stats", tags=["System"])
459
- async def stats():
460
- from ..validator.proxy_engine import proxy_pool
461
- db_stats = repo.get_stats()
462
- return {
463
- "proxy_pool": proxy_pool.stats,
464
- "database": db_stats,
465
- "version": settings.app_version
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
+ }
app/auth.py CHANGED
@@ -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
- _OPEN_PREFIXES = ("/api/v1/health", "/api/health", "/docs", "/redoc", "/openapi.json")
136
- _OPEN_EXACT = frozenset({"/", "/api/v1/health", "/api/health"})
137
-
138
-
139
- def _is_open_path(path: str) -> bool:
140
- if path in _OPEN_EXACT:
141
- return True
142
- return any(path.startswith(p) and path != p for p in _OPEN_PREFIXES)
143
-
144
-
145
- async def license_middleware(request: Request, call_next):
146
- """FastAPI HTTP middleware: JWT + license gate for paid routes.
147
-
148
- Mirrors the Express pattern (Modul-S/E): /api/v1/health stays open,
149
- everything else under /api requires a valid JWT + active license.
150
- No-op while LICENSE_ENFORCEMENT != "true" (demo mode).
151
- """
152
- if not enforcement_on():
153
- return await call_next(request)
154
- path = request.url.path
155
- if _is_open_path(path) or not path.startswith("/api"):
156
- return await call_next(request)
157
- token = _bearer_token(request)
158
- if not token:
159
- return JSONResponse(
160
- status_code=401,
161
- content={"detail": "Missing bearer token - sign in first."},
162
- )
163
- try:
164
- claims = await verify_supabase_jwt(token)
165
- except JWTError:
166
- return JSONResponse(
167
- status_code=401,
168
- content={"detail": "Invalid or expired token."},
169
- )
170
- user_id = claims.get("sub", "")
171
- if not user_id or not await has_modulx_license(user_id):
172
- return JSONResponse(
173
- status_code=402,
174
- content={"detail": "No active Modul-X license - see /pricing."},
175
- )
176
- request.state.auth = {"user_id": user_id, "email": claims.get("email")}
177
- return await call_next(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
app/config.py CHANGED
@@ -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 = "2.2.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
- # On hosts that block outbound port 25 (Hugging Face Spaces, most cloud),
81
- # direct SMTP cannot run — so the mailbox verdict is delegated to a
82
- # third-party verification API instead. "auto" uses the API when an API key
83
- # is present, else falls back to direct SMTP. "local"/"api" force a provider.
84
- mailbox_provider: str = "auto" # auto | local | api
85
- # Independence by default: the engine uses its own SMTP probing even when a
86
- # third-party key happens to be present. Set True only to deliberately route
87
- # the mailbox layers through the API.
88
- prefer_api_provider: bool = False
89
- # Public HELO/MX identity used in the SMTP dialogue. A real, resolvable
90
- # hostname with matching PTR improves acceptance by strict receivers.
91
- smtp_helo_domain: str = "validator.local"
92
- smtp_mail_from: str = "probe@validator.local"
93
- mv_api_key: str = "" # MillionVerifier real-time API key (optional)
94
- mv_timeout: int = 10 # per-check provider timeout (seconds)
95
- # Circuit breaker: after this many consecutive direct-SMTP connection
96
- # failures (blocked egress), fail fast for `smtp_cooldown_seconds` instead
97
- # of burning the full timeout on every address.
98
- smtp_fail_threshold: int = 3
99
- smtp_cooldown_seconds: int = 300
100
- # Catch-all is a domain property — cache the verdict for 72h so a bulk list
101
- # does not re-probe the same domain for every address.
102
- catch_all_ttl_seconds: int = 72 * 3600
103
- # DNS/MX answers change slowly; a definitive mailbox verdict is stable for
104
- # about a day. Transient failures are never cached regardless of TTL.
105
- dns_cache_ttl_seconds: int = 24 * 3600
106
- # Per-MX-host SMTP session cap and global concurrency for bulk runs.
107
- max_connections_per_host: int = 2
108
- bulk_concurrency: int = 20
109
-
110
- # --- DistilBERT email-risk signal (advisory, unknown domains only) ---
111
- # OFF by default: with the flag off the engine's behaviour is identical to
112
- # the pre-ML build. The model is a private HF repo trained on the engine's
113
- # own blocklists (see app/ml/). Promotion from advisory to blocking requires
114
- # measured DISPOSABLE precision >= 0.98 (app/ml/train.py reports it).
115
- ml_disposable_enabled: bool = False
116
- ml_model_repo: str = ""
117
- ml_batch_size: int = 64
118
- ml_score_penalty: int = 5
119
- ml_hf_token: str = ""
120
-
121
- # Run-quality monitor (report item 10): alert when >= this share of a bulk
122
- # run comes back risky/unknown/error — the signature of a systemic failure
123
- # (dead provider, blocked egress, engine regression). 0 disables it.
124
- risky_rate_alert_threshold: float = 0.5
125
-
126
- # Celery
127
- celery_broker_url: str = "redis://localhost:6379/1"
128
- celery_result_backend: str = "redis://localhost:6379/2"
129
-
130
- # Supabase (Postgres + PostgREST)
131
- supabase_url: str = ""
132
- supabase_anon_key: str = ""
133
- supabase_service_role_key: str = ""
134
- enable_db_persistence: bool = True
135
-
136
- # Central billing gate: "true" enforces Supabase JWT + active license
137
- # (modul-x directly, via combo-validate-send, or via combo-full-bridge)
138
- # on every paid API route. Anything else = open demo mode.
139
- license_enforcement: str = ""
140
-
141
- @model_validator(mode="after")
142
- def validate_production_settings(self) -> "Settings":
143
- if self.app_environment.strip().casefold() != "production":
144
- return self
145
-
146
- problems = []
147
- if self.debug:
148
- problems.append("DEBUG must be false")
149
- if self.license_enforcement.strip().casefold() != "true":
150
- problems.append("LICENSE_ENFORCEMENT must be true")
151
- if not self.supabase_configured:
152
- problems.append("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required")
153
- elif not _is_https_origin(self.supabase_url):
154
- problems.append("SUPABASE_URL must be an HTTPS URL")
155
- if not self.cors_origins or not all(
156
- _is_https_origin(origin) for origin in self.cors_origins
157
- ):
158
- problems.append("CORS_ORIGINS must contain only HTTPS origins")
159
-
160
- if problems:
161
- raise ValueError("Unsafe production configuration: " + "; ".join(problems))
162
- return self
163
-
164
- @property
165
- def supabase_configured(self) -> bool:
166
- return bool(self.supabase_url and self.supabase_service_role_key)
167
-
168
-
169
- settings = Settings()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
app/main.py CHANGED
@@ -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
+ }
app/ml/hf_scorer.py ADDED
@@ -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))
app/models.py CHANGED
@@ -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
- error: Optional[str] = None
40
- error_code: Optional[str] = None
41
-
42
-
43
- class DisposableResult(BaseModel):
44
- is_disposable: bool
45
- is_free_provider: bool
46
- is_role_based: bool
47
- provider_name: Optional[str] = None
48
- # Advisory ML signal (DistilBERT, unknown domains only). Never a verdict:
49
- # the deterministic layer remains the source of truth until the measured
50
- # precision gate promotes the model (see app/ml/).
51
- ml_label: Optional[str] = None
52
- ml_confidence: Optional[float] = None
53
-
54
-
55
- class CatchAllResult(BaseModel):
56
- # None = the catch-all probe could not run (e.g. port-25 egress blocked on
57
- # this host). It must NEVER be coerced to False, which would falsely claim
58
- # "domain rejects unknown recipients".
59
- is_catch_all: Optional[bool] = None
60
- confidence: float = 0.0
61
- error: Optional[str] = None
62
-
63
-
64
- class SMTPResult(BaseModel):
65
- verified: Optional[bool] = None
66
- smtp_code: Optional[int] = None
67
- smtp_message: Optional[str] = None
68
- server_banner: Optional[str] = None
69
- supports_tls: bool = False
70
- error: Optional[str] = None
71
- via_proxy: bool = False
72
-
73
-
74
- class ScoringResult(BaseModel):
75
- score: int
76
- risk_level: RiskLevel
77
- breakdown: Dict[str, int] = {}
78
- warnings: List[str] = []
79
-
80
-
81
- # ---- Main Response ----------------------------------------------------
82
- class ValidationResult(BaseModel):
83
- email: str
84
- status: ValidationStatus
85
- syntax: SyntaxResult
86
- dns: Optional[DNSResult] = None
87
- disposable: Optional[DisposableResult] = None
88
- catch_all: Optional[CatchAllResult] = None
89
- smtp: Optional[SMTPResult] = None
90
- scoring: ScoringResult
91
- processing_time_ms: float
92
- validated_at: str
93
- # Provenance the frontend contract expects (src/lib/verifier.ts classify()).
94
- # checks_run lists the 7-layer taxonomy keys that actually executed; an
95
- # unchecked layer must never read as a pass. reason_code is a machine cause
96
- # for Unknown/risky rows. run_id correlates a single + its bulk chunk.
97
- checks_run: List[str] = []
98
- reason_code: Optional[str] = None
99
- run_id: Optional[str] = None
100
- validator_version: Optional[str] = None
101
-
102
-
103
- class SingleEmailRequest(BaseModel):
104
- email: str
105
-
106
- @field_validator("email")
107
- @classmethod
108
- def strip_email(cls, v: str) -> str:
109
- return v.strip().lower()
110
-
111
-
112
- class BulkEmailRequest(BaseModel):
113
- emails: List[str]
114
- webhook_url: Optional[str] = None
115
- # Product promise: bulk runs the SAME 7-layer pipeline as single (1 credit
116
- # per email). deep=False skips catch-all + SMTP, which collapsed every bulk
117
- # row to "Unknown / insufficient depth" — so it defaults to True and is only
118
- # flipped off by an explicit caller that accepts the reduced verdict.
119
- deep: bool = True
120
-
121
- @field_validator("emails")
122
- @classmethod
123
- def limit_emails(cls, v: List[str]) -> List[str]:
124
- if len(v) > 10000:
125
- raise ValueError("Max 10,000 emails per bulk request")
126
- return [e.strip().lower() for e in v]
127
-
128
-
129
- class BulkValidationResponse(BaseModel):
130
- task_id: str
131
- status: str
132
- total: int
133
- message: str
134
-
135
-
136
- class TaskStatusResponse(BaseModel):
137
- task_id: str
138
- status: str
139
- progress: Optional[int] = None
140
- total: Optional[int] = None
141
- results: Optional[List[ValidationResult]] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
app/utils/cache.py CHANGED
@@ -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
app/validator/dns_check.py CHANGED
@@ -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
- DKIM_SELECTORS = ['default', 'google', 'selector1', 'selector2', 'dkim', 'k1']
10
-
11
-
12
- def build_resolver(timeout: float) -> dns.asyncresolver.Resolver:
13
- """Resolver that never trusts a broken system resolver config.
14
-
15
- Diagnosed live: the host's resolver list held three stale gateway IPs
16
- (192.168.3.1 / 192.168.1.1 / 172.20.10.1) from old networks. dnspython tried
17
- each in turn, each timing out, so EVERY lookup burned the full lifetime and
18
- returned a timeout — even for domains whose MX answered in 56 ms via a
19
- public resolver. That produced 42s requests and mass false-invalid verdicts.
20
- """
21
- resolver = dns.asyncresolver.Resolver(configure=not settings.dns_nameservers)
22
- if settings.dns_nameservers:
23
- resolver.nameservers = list(settings.dns_nameservers)
24
- resolver.timeout = timeout
25
- resolver.lifetime = timeout
26
- return resolver
27
-
28
-
29
- async def check_dns(domain: str, timeout: int = 5) -> DNSResult:
30
- """
31
- Layer 2 & 3: Async DNS + MX Record validation.
32
- Also checks SPF, DMARC and DKIM authentication records.
33
-
34
- The authentication lookups are independent of one another and are issued
35
- concurrently: the DKIM selector list alone is six sequential lookups, which
36
- multiplied every request's latency when run one after another.
37
- """
38
- resolver = build_resolver(timeout)
39
-
40
- # ---- MX Records --------------------------------------------------
41
- mx_records = []
42
- domain_exists = False
43
- has_mx = False
44
- a_fallback = False
45
-
46
- try:
47
- mx_answers = await resolver.resolve(domain, 'MX')
48
- has_mx = True
49
- domain_exists = True
50
- for r in sorted(mx_answers, key=lambda x: x.preference):
51
- mx_records.append({
52
- "host": str(r.exchange).rstrip('.'),
53
- "priority": r.preference
54
- })
55
- except dns.resolver.NXDOMAIN:
56
- return DNSResult(
57
- domain_exists=False,
58
- has_mx_records=False,
59
- error="Domain does not exist (NXDOMAIN)",
60
- error_code="nxdomain",
61
- )
62
- except dns.resolver.NoAnswer:
63
- # No MX -> try A/AAAA fallback per RFC
64
- try:
65
- for record_type in ('A', 'AAAA'):
66
- try:
67
- await resolver.resolve(domain, record_type)
68
- domain_exists = True
69
- a_fallback = True
70
- mx_records.append({"host": domain, "priority": 0})
71
- break
72
- except dns.resolver.NoAnswer:
73
- continue
74
- except dns.resolver.NXDOMAIN:
75
- return DNSResult(
76
- domain_exists=False,
77
- has_mx_records=False,
78
- error="Domain does not exist (NXDOMAIN)",
79
- error_code="nxdomain",
80
- )
81
- except dns.exception.Timeout:
82
- return DNSResult(
83
- domain_exists=False,
84
- has_mx_records=False,
85
- error="DNS address lookup timed out",
86
- error_code="timeout",
87
- )
88
- except dns.exception.DNSException as e:
89
- return DNSResult(
90
- domain_exists=False,
91
- has_mx_records=False,
92
- error=f"DNS address lookup failed: {e.__class__.__name__}",
93
- error_code="resolver_error",
94
- )
95
- if not domain_exists:
96
- # An MX NODATA response confirms the owner name exists, even when
97
- # it has no address record to which SMTP can be routed.
98
- domain_exists = True
99
- except dns.exception.Timeout:
100
- return DNSResult(
101
- domain_exists=False,
102
- has_mx_records=False,
103
- error="DNS timeout",
104
- error_code="timeout",
105
- )
106
- except dns.exception.DNSException as e:
107
- return DNSResult(
108
- domain_exists=False,
109
- has_mx_records=False,
110
- error=f"DNS lookup failed: {e.__class__.__name__}",
111
- error_code="resolver_error",
112
- )
113
-
114
- if not has_mx and not a_fallback:
115
- return DNSResult(
116
- domain_exists=True,
117
- has_mx_records=False,
118
- error="Domain has no MX, A, or AAAA records",
119
- error_code="no_mail_records",
120
- )
121
-
122
- # ---- SPF / DMARC / DKIM (independent of each other -> concurrent) ----
123
- has_spf, spf_record = await _lookup_spf(resolver, domain)
124
- (has_dmarc, dmarc_record), (has_dkim, _) = await asyncio.gather(
125
- _lookup_dmarc(resolver, domain),
126
- _lookup_dkim(resolver, domain),
127
- )
128
-
129
- return DNSResult(
130
- domain_exists=domain_exists,
131
- has_mx_records=has_mx,
132
- mx_records=mx_records,
133
- a_fallback=a_fallback,
134
- has_spf=has_spf,
135
- spf_record=spf_record,
136
- has_dmarc=has_dmarc,
137
- dmarc_record=dmarc_record,
138
- has_dkim=has_dkim
139
- )
140
-
141
-
142
- async def _lookup_spf(resolver, domain: str) -> tuple[bool, str | None]:
143
- try:
144
- txt_answers = await resolver.resolve(domain, 'TXT')
145
- for record in txt_answers:
146
- txt = b''.join(record.strings).decode('utf-8', errors='replace')
147
- if txt.startswith('v=spf1'):
148
- return True, txt
149
- except Exception:
150
- pass
151
- return False, None
152
-
153
-
154
- async def _lookup_dmarc(resolver, domain: str) -> tuple[bool, str | None]:
155
- try:
156
- answers = await resolver.resolve(f"_dmarc.{domain}", 'TXT')
157
- for record in answers:
158
- txt = b''.join(record.strings).decode('utf-8', errors='replace')
159
- if 'DMARC1' in txt:
160
- return True, txt
161
- except Exception:
162
- pass
163
- return False, None
164
-
165
-
166
- async def _lookup_dkim(resolver, domain: str) -> tuple[bool, str | None]:
167
- """Probe the known selectors concurrently, first hit wins."""
168
-
169
- async def probe(selector: str):
170
- try:
171
- answers = await resolver.resolve(f"{selector}._domainkey.{domain}", 'TXT')
172
- for record in answers:
173
- txt = b''.join(record.strings).decode('utf-8', errors='replace')
174
- if 'DKIM1' in txt or 'k=rsa' in txt or 'p=' in txt:
175
- return txt
176
- except Exception:
177
- return None
178
- return None
179
-
180
- results = await asyncio.gather(*(probe(s) for s in DKIM_SELECTORS))
181
- for txt in results:
182
- if txt:
183
- return True, txt
184
- return False, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
app/validator/doh_resolver.py ADDED
@@ -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
app/validator/engine.py CHANGED
@@ -1,662 +1,833 @@
1
- import asyncio
2
- import re
3
- import time
4
- import uuid
5
- from datetime import datetime, timezone
6
- import structlog
7
-
8
- from ..models import (
9
- ValidationResult,
10
- ValidationStatus,
11
- ScoringResult,
12
- CatchAllResult,
13
- DNSResult,
14
- DisposableResult,
15
- SMTPResult,
16
- SyntaxResult,
17
- )
18
- from ..config import settings
19
- from .syntax import validate_syntax
20
- from .dns_check import check_dns
21
- from .disposable import check_disposable, domain_in_blocklist, domain_is_known
22
- from .mailbox_provider import MailboxProvider, resolve_provider
23
- from ..ml.runtime import get_model_async, predict_async
24
- from .mx_intel import mx_is_trusted
25
- from .retry_queue import is_greylist_result
26
- from .scoring import calculate_score
27
-
28
- logger = structlog.get_logger()
29
-
30
-
31
- def _mask_email_for_log(email: str) -> str:
32
- local_part, separator, domain = email.rpartition("@")
33
- if not separator or not domain:
34
- return "[invalid-email]"
35
- return f"{local_part[:1]}***@{domain}"
36
-
37
-
38
- def _build_checks_run(
39
- syntax: SyntaxResult | None,
40
- dns: DNSResult | None,
41
- disposable: DisposableResult | None,
42
- catch_all: CatchAllResult | None,
43
- smtp: SMTPResult | None,
44
- scoring: ScoringResult | None,
45
- ) -> list[str]:
46
- """Which of the 7-layer taxonomy keys actually executed for this result.
47
-
48
- Mirrors the frontend fallback in src/lib/verifier.ts classify() so a layer
49
- the engine did not run is never reported as a pass. DNS without an error
50
- claims both `dns` and `mx`; a DNS error claims only `dns`.
51
- """
52
- checks: list[str] = []
53
- if syntax is not None:
54
- checks.append("syntax")
55
- if dns is not None:
56
- if dns.error:
57
- checks.append("dns")
58
- else:
59
- checks.extend(["dns", "mx"])
60
- if disposable is not None:
61
- checks.append("disposable")
62
- if catch_all is not None:
63
- checks.append("catch_all")
64
- if smtp is not None:
65
- checks.append("smtp")
66
- if scoring is not None:
67
- checks.append("scoring")
68
- return checks
69
-
70
-
71
- def _build_reason_code(
72
- dns: DNSResult | None,
73
- disposable: DisposableResult | None,
74
- catch_all: CatchAllResult | None,
75
- smtp: SMTPResult | None,
76
- *,
77
- early: str | None = None,
78
- ) -> str | None:
79
- """Machine cause for the verdict. Ordered most-specific/actionable first.
80
-
81
- disposable outranks everything (frontend classifies it as Disposable). A
82
- definitive SMTP rejection (verified False + code) outranks the domain-level
83
- catch-all flag: reporting "catch_all_detected" beside status=invalid is
84
- incoherent — the explicit "mailbox does not exist" is the actionable fact.
85
- Catch-all then outranks a merely inconclusive probe.
86
- """
87
- if early:
88
- return early
89
- if disposable is not None and disposable.is_disposable:
90
- return "disposable"
91
- if dns is not None and dns.a_fallback and not dns.has_mx_records:
92
- return "a_fallback_only"
93
- if smtp is not None and smtp.verified is False and smtp.smtp_code:
94
- if smtp.smtp_code >= 500:
95
- return "rcpt_5xx"
96
- if smtp.smtp_code >= 400:
97
- return "rcpt_4xx"
98
- if smtp.smtp_code >= 200:
99
- return "rcpt_2xx"
100
- if catch_all is not None and catch_all.is_catch_all:
101
- return "catch_all_detected"
102
- if smtp is not None and smtp.verified is None and smtp.error:
103
- return (
104
- "smtp_timeout"
105
- if re.search(r"timed?\s*out", smtp.error, re.IGNORECASE)
106
- else "smtp_unavailable"
107
- )
108
- return None
109
-
110
-
111
- class EmailValidationEngine:
112
- """
113
- Master 7-Layer Email Validation Orchestrator.
114
-
115
- Layers:
116
- 1. Syntax / RFC 5321 check
117
- 2. DNS domain existence
118
- 3. MX record verification
119
- 4. Disposable / Role-based / Free provider detection
120
- 5. Catch-all domain detection
121
- 6. SMTP mailbox verification
122
- 7. Deterministic heuristic deliverability scoring
123
- """
124
-
125
- def __init__(
126
- self,
127
- mailbox: MailboxProvider | None = None,
128
- cache=None,
129
- retry_queue=None,
130
- ):
131
- self.use_proxies = settings.use_proxies
132
- # Layers 5 (catch-all) and 6 (SMTP) run through a mailbox provider. On
133
- # hosts that block port 25 (HF Spaces), this resolves to the verification
134
- # API; elsewhere it is direct SMTP. Injectable for tests.
135
- self.mailbox = mailbox if mailbox is not None else resolve_provider()
136
- # DNS/MX and definitive mailbox verdicts are cached across runs — the
137
- # same domain (and often the same address) recurs constantly in bulk
138
- # lists. Transient failures are never cached; see validation_cache.py.
139
- if cache is None:
140
- from .validation_cache import validation_cache as cache
141
-
142
- self.cache = cache
143
- # Greylisted (4xx) addresses get a bounded second chance. Without this a
144
- # valid mailbox on a greylisting domain stays permanently "risky".
145
- if retry_queue is None:
146
- from .retry_queue import retry_queue as retry_queue
147
-
148
- self.retry_queue = retry_queue
149
-
150
- async def validate(self, email: str, deep: bool = True) -> ValidationResult:
151
- start_time = time.monotonic()
152
- run_id = uuid.uuid4().hex[:12]
153
-
154
- log = logger.bind(email=_mask_email_for_log(email), run_id=run_id)
155
- log.info("validation_started")
156
-
157
- # ---- Layer 1: Syntax -----------------------------------------
158
- syntax = validate_syntax(email)
159
-
160
- if not syntax.passed:
161
- elapsed = (time.monotonic() - start_time) * 1000
162
- scoring = calculate_score(syntax, None, None, None, None)
163
- log.warning("syntax_failed", error=syntax.error)
164
- return ValidationResult(
165
- email=email,
166
- status=ValidationStatus.INVALID,
167
- syntax=syntax,
168
- scoring=scoring,
169
- processing_time_ms=round(elapsed, 2),
170
- validated_at=datetime.now(timezone.utc).isoformat(),
171
- checks_run=_build_checks_run(syntax, None, None, None, None, scoring),
172
- reason_code=_build_reason_code(None, None, None, None),
173
- run_id=run_id,
174
- validator_version=settings.app_version,
175
- )
176
-
177
- domain = syntax.domain
178
- normalized_email = syntax.normalized_email or email
179
-
180
- # ---- Layers 2 & 3: DNS + MX ---------------------------------
181
- dns_result = await check_dns(domain, settings.dns_timeout)
182
-
183
- if dns_result.error_code in {"timeout", "resolver_error"}:
184
- elapsed = (time.monotonic() - start_time) * 1000
185
- scoring = calculate_score(syntax, dns_result, None, None, None)
186
- log.warning(
187
- "dns_inconclusive", domain=domain, error_code=dns_result.error_code
188
- )
189
- return ValidationResult(
190
- email=normalized_email,
191
- status=self._determine_status(scoring),
192
- syntax=syntax,
193
- dns=dns_result,
194
- scoring=scoring,
195
- processing_time_ms=round(elapsed, 2),
196
- validated_at=datetime.now(timezone.utc).isoformat(),
197
- checks_run=_build_checks_run(syntax, dns_result, None, None, None, scoring),
198
- reason_code=_build_reason_code(dns_result, None, None, None, early="dns_timeout"),
199
- run_id=run_id,
200
- validator_version=settings.app_version,
201
- )
202
-
203
- if (
204
- not dns_result.domain_exists
205
- or dns_result.error_code == "nxdomain"
206
- or (not dns_result.has_mx_records and not dns_result.a_fallback)
207
- ):
208
- elapsed = (time.monotonic() - start_time) * 1000
209
- scoring = calculate_score(syntax, dns_result, None, None, None)
210
- log.warning("dns_failed", domain=domain)
211
- return ValidationResult(
212
- email=normalized_email,
213
- status=ValidationStatus.INVALID,
214
- syntax=syntax,
215
- dns=dns_result,
216
- scoring=scoring,
217
- processing_time_ms=round(elapsed, 2),
218
- validated_at=datetime.now(timezone.utc).isoformat(),
219
- checks_run=_build_checks_run(syntax, dns_result, None, None, None, scoring),
220
- reason_code=_build_reason_code(
221
- dns_result, None, None, None, early="nxdomain"
222
- ),
223
- run_id=run_id,
224
- validator_version=settings.app_version,
225
- )
226
-
227
- # ---- Layer 4: Disposable Detection ---------------------------
228
- disposable = check_disposable(normalized_email, domain)
229
-
230
- # ML advisory (single path): unknown domains only, fail-open. Mirrors
231
- # the batch pipeline so both surfaces agree on the same address.
232
- # Skipped when the MX runs on a trusted enterprise platform: the model
233
- # learned 'unfamiliar domain string = disposable' from an imbalanced
234
- # dataset and flags legitimate small businesses on Google Workspace /
235
- # M365 — the deterministic MX intelligence is the better signal there.
236
- ml_model = await get_model_async()
237
- mx_host = (
238
- dns_result.mx_records[0]["host"]
239
- if dns_result.mx_records
240
- else None
241
- )
242
- if (
243
- ml_model is not None
244
- and not domain_is_known(domain)
245
- and deep
246
- and not mx_is_trusted(mx_host)
247
- ):
248
- # Off the event loop: CPU inference inline would stall every other
249
- # concurrent request (measured 42.6s cold start before this).
250
- preds = await predict_async(ml_model, [f"john.smith [SEP] {domain}"])
251
- if preds:
252
- disposable = disposable.model_copy(
253
- update={"ml_label": preds[0][0], "ml_confidence": preds[0][1]}
254
- )
255
-
256
- if not deep:
257
- # Fast path: skip SMTP for quick validation
258
- elapsed = (time.monotonic() - start_time) * 1000
259
- scoring = calculate_score(
260
- syntax,
261
- dns_result,
262
- disposable,
263
- None,
264
- None,
265
- mailbox_verification_skipped=True,
266
- )
267
- status = self._determine_status(scoring)
268
- return ValidationResult(
269
- email=normalized_email,
270
- status=status,
271
- syntax=syntax,
272
- dns=dns_result,
273
- disposable=disposable,
274
- scoring=scoring,
275
- processing_time_ms=round(elapsed, 2),
276
- validated_at=datetime.now(timezone.utc).isoformat(),
277
- checks_run=_build_checks_run(
278
- syntax, dns_result, disposable, None, None, scoring
279
- ),
280
- reason_code=_build_reason_code(dns_result, disposable, None, None),
281
- run_id=run_id,
282
- validator_version=settings.app_version,
283
- )
284
-
285
- # ---- Layers 5 & 6: Catch-All + SMTP --------------------------
286
- mx_host = dns_result.mx_records[0]["host"]
287
-
288
- # Run catch-all and SMTP in parallel for speed, via the mailbox
289
- # provider (direct SMTP or the verification API, per configuration).
290
- catch_all_task = asyncio.create_task(
291
- self.mailbox.catch_all(domain, mx_host)
292
- )
293
- smtp_task = asyncio.create_task(
294
- self.mailbox.verify(normalized_email, mx_host)
295
- )
296
-
297
- catch_all_raw, smtp_result = await asyncio.gather(
298
- catch_all_task, smtp_task, return_exceptions=True
299
- )
300
-
301
- # Handle catch-all result. An exception means the probe could not run
302
- # (e.g. port-25 egress blocked) — that is UNKNOWN, not "not catch-all".
303
- if isinstance(catch_all_raw, Exception):
304
- catch_all = CatchAllResult(
305
- is_catch_all=None, confidence=0.0, error=str(catch_all_raw)
306
- )
307
- else:
308
- is_catch_all, confidence = catch_all_raw
309
- catch_all = CatchAllResult(is_catch_all=is_catch_all, confidence=confidence)
310
-
311
- # Handle SMTP result
312
- if isinstance(smtp_result, Exception):
313
- smtp_result = SMTPResult(verified=None, error=str(smtp_result))
314
-
315
- # ---- Layer 7: Scoring ----------------------------------------
316
- scoring = calculate_score(
317
- syntax, dns_result, disposable, catch_all, smtp_result
318
- )
319
-
320
- status = self._determine_status(scoring)
321
- elapsed = (time.monotonic() - start_time) * 1000
322
-
323
- log.info(
324
- "validation_complete",
325
- status=status,
326
- score=scoring.score,
327
- time_ms=round(elapsed, 2),
328
- )
329
-
330
- return ValidationResult(
331
- email=normalized_email,
332
- status=status,
333
- syntax=syntax,
334
- dns=dns_result,
335
- disposable=disposable,
336
- catch_all=catch_all,
337
- smtp=smtp_result,
338
- scoring=scoring,
339
- processing_time_ms=round(elapsed, 2),
340
- validated_at=datetime.now(timezone.utc).isoformat(),
341
- checks_run=_build_checks_run(
342
- syntax, dns_result, disposable, catch_all, smtp_result, scoring
343
- ),
344
- reason_code=_build_reason_code(dns_result, disposable, catch_all, smtp_result),
345
- run_id=run_id,
346
- validator_version=settings.app_version,
347
- )
348
-
349
- async def validate_batch(
350
- self, emails: list[str], deep: bool = True
351
- ) -> list[ValidationResult]:
352
- """Validate many addresses in one pass, reusing per-domain work.
353
-
354
- Bulk throughput comes from doing DNS/MX **once per domain** and handing
355
- every (email, mx_host) pair to the mailbox provider in a **single** call,
356
- so the SMTP engine can reuse one session per receiving host instead of a
357
- fresh TCP handshake per address. Providers that cannot batch fall back to
358
- the per-address path, so behaviour is never worse than before.
359
-
360
- Results are returned in the same order as `emails`.
361
- """
362
- if not emails:
363
- return []
364
-
365
- run_id = uuid.uuid4().hex[:12]
366
- out: list[ValidationResult | None] = [None] * len(emails)
367
-
368
- def _stamp(**kw) -> ValidationResult:
369
- return ValidationResult(
370
- processing_time_ms=0.0,
371
- validated_at=datetime.now(timezone.utc).isoformat(),
372
- run_id=run_id,
373
- validator_version=settings.app_version,
374
- **kw,
375
- )
376
-
377
- # ---- Layer 1: syntax (per address) ---------------------------
378
- parsed: list[tuple[str, SyntaxResult]] = []
379
- domain_index: dict[str, list[int]] = {}
380
- for idx, raw in enumerate(emails):
381
- syntax = validate_syntax(raw)
382
- parsed.append((raw, syntax))
383
- if not syntax.passed:
384
- scoring = calculate_score(syntax, None, None, None, None)
385
- out[idx] = _stamp(
386
- email=raw,
387
- status=ValidationStatus.INVALID,
388
- syntax=syntax,
389
- scoring=scoring,
390
- checks_run=_build_checks_run(syntax, None, None, None, None, scoring),
391
- reason_code=_build_reason_code(None, None, None, None),
392
- )
393
- continue
394
- domain_index.setdefault(syntax.domain, []).append(idx)
395
-
396
- # ---- Layers 2 & 3: DNS + MX once per domain (cached) ---------
397
- domains = list(domain_index.keys())
398
- domain_dns: dict[str, DNSResult] = {}
399
- misses: list[str] = []
400
- for domain in domains:
401
- cached_dns = await self.cache.get_dns(domain)
402
- if cached_dns is not None:
403
- domain_dns[domain] = cached_dns
404
- else:
405
- misses.append(domain)
406
-
407
- if misses:
408
- resolved = await asyncio.gather(
409
- *[check_dns(d, settings.dns_timeout) for d in misses],
410
- return_exceptions=True,
411
- )
412
- for domain, dns in zip(misses, resolved):
413
- result = (
414
- dns
415
- if isinstance(dns, DNSResult)
416
- else DNSResult(
417
- domain_exists=False,
418
- has_mx_records=False,
419
- error=str(dns),
420
- error_code="resolver_error",
421
- )
422
- )
423
- domain_dns[domain] = result
424
- # put_dns ignores transient failures internally.
425
- await self.cache.put_dns(domain, result)
426
-
427
- pending: list[tuple[int, str, SyntaxResult, str, DNSResult]] = []
428
- for domain, idxs in domain_index.items():
429
- dns = domain_dns[domain]
430
- inconclusive = dns.error_code in {"timeout", "resolver_error"}
431
- dead = (
432
- not dns.domain_exists
433
- or dns.error_code == "nxdomain"
434
- or (not dns.has_mx_records and not dns.a_fallback)
435
- )
436
- for idx in idxs:
437
- raw, syntax = parsed[idx]
438
- normalized = syntax.normalized_email or raw
439
- if inconclusive:
440
- scoring = calculate_score(syntax, dns, None, None, None)
441
- out[idx] = _stamp(
442
- email=normalized,
443
- status=self._determine_status(scoring),
444
- syntax=syntax,
445
- dns=dns,
446
- scoring=scoring,
447
- checks_run=_build_checks_run(
448
- syntax, dns, None, None, None, scoring
449
- ),
450
- reason_code=_build_reason_code(
451
- dns, None, None, None, early="dns_timeout"
452
- ),
453
- )
454
- elif dead:
455
- scoring = calculate_score(syntax, dns, None, None, None)
456
- out[idx] = _stamp(
457
- email=normalized,
458
- status=ValidationStatus.INVALID,
459
- syntax=syntax,
460
- dns=dns,
461
- scoring=scoring,
462
- checks_run=_build_checks_run(
463
- syntax, dns, None, None, None, scoring
464
- ),
465
- reason_code=_build_reason_code(
466
- dns, None, None, None, early="nxdomain"
467
- ),
468
- )
469
- else:
470
- pending.append((idx, normalized, syntax, domain, dns))
471
-
472
- # ---- Layer 4 fast path (no mailbox probe) --------------------
473
- if pending and not deep:
474
- for idx, normalized, syntax, domain, dns in pending:
475
- disposable = check_disposable(normalized, domain)
476
- scoring = calculate_score(
477
- syntax, dns, disposable, None, None,
478
- mailbox_verification_skipped=True,
479
- )
480
- out[idx] = _stamp(
481
- email=normalized,
482
- status=self._determine_status(scoring),
483
- syntax=syntax,
484
- dns=dns,
485
- disposable=disposable,
486
- scoring=scoring,
487
- checks_run=_build_checks_run(
488
- syntax, dns, disposable, None, None, scoring
489
- ),
490
- reason_code=_build_reason_code(dns, disposable, None, None),
491
- )
492
- pending = []
493
-
494
- # ---- Layers 5 & 6: ONE batched mailbox call ------------------
495
- cached_verdicts: dict[str, tuple[SMTPResult, Optional[bool]]] = {}
496
- catch_all_map: dict[str, Optional[bool]] = {}
497
- if pending:
498
- # Serve anything already answered (definitive verdicts only) so we
499
- # never re-probe an address we already know the answer to.
500
- to_probe: list[tuple[int, str, SyntaxResult, str, DNSResult]] = []
501
- for item in pending:
502
- _idx, normalized, _syntax, _domain, _dns = item
503
- hit = await self.cache.get_verdict(normalized, deep)
504
- if hit is None:
505
- to_probe.append(item)
506
- else:
507
- cached_verdicts[normalized] = hit
508
- if hit[1] is not None:
509
- catch_all_map[item[3]] = hit[1]
510
-
511
- pairs = [
512
- (normalized, dns.mx_records[0]["host"])
513
- for _, normalized, _, _, dns in to_probe
514
- ]
515
- smtp_map: dict[str, SMTPResult] = {}
516
- if pairs:
517
- try:
518
- if hasattr(self.mailbox, "verify_many"):
519
- smtp_map, caught = await self.mailbox.verify_many(pairs)
520
- for domain, value in (caught or {}).items():
521
- catch_all_map.setdefault(domain, value)
522
- else:
523
- # Provider cannot batch: per-address fallback.
524
- for email, mx in pairs:
525
- smtp_map[email] = await self.mailbox.verify(email, mx)
526
- for domain in {e.rsplit("@", 1)[-1].lower() for e, _ in pairs}:
527
- mx = next(
528
- m for e, m in pairs
529
- if e.rsplit("@", 1)[-1].lower() == domain
530
- )
531
- value, _conf = await self.mailbox.catch_all(domain, mx)
532
- catch_all_map.setdefault(domain, value)
533
- except Exception as exc: # one bad stage must not abort the batch
534
- smtp_map = {
535
- e: SMTPResult(verified=None, error=f"mailbox_stage_error: {exc}")
536
- for e, _ in pairs
537
- }
538
-
539
- # ---- ML advisory signal (unknown domains, ONE batched call) ----
540
- # Runs only where the curated lists have no answer - the exact gap
541
- # a static blocklist cannot close. Any failure here is silently
542
- # ignored: the deterministic verdict stands.
543
- # Domains on trusted enterprise MX (Google Workspace / M365 /
544
- # gateways) are excluded: disposable domains essentially never run
545
- # there, and the model's 'unfamiliar string' bias would otherwise
546
- # flag legitimate businesses as disposable.
547
- ml_map: dict[str, tuple[str, float]] = {}
548
- ml_model = await get_model_async()
549
- if ml_model is not None:
550
- unknown_domains = sorted(
551
- {
552
- domain
553
- for _, _, _, domain, dns in pending
554
- if not domain_is_known(domain)
555
- and dns.mx_records
556
- and not mx_is_trusted(dns.mx_records[0]["host"])
557
- }
558
- )
559
- if unknown_domains:
560
- # One batched call, off the event loop (CPU inference inline
561
- # stalls concurrent requests), and fail-open by construction:
562
- # predict_async never raises.
563
- preds = await predict_async(
564
- ml_model, [f"john.smith [SEP] {d}" for d in unknown_domains]
565
- )
566
- if preds:
567
- ml_map = dict(zip(unknown_domains, preds))
568
-
569
- # ---- Layer 7: score each row -----------------------------
570
- for idx, normalized, syntax, domain, dns in pending:
571
- cached = cached_verdicts.get(normalized)
572
- if cached is not None:
573
- smtp, ca_cached = cached
574
- else:
575
- smtp = smtp_map.get(normalized) or SMTPResult(
576
- verified=None, error="engine returned no result"
577
- )
578
- ca_cached = None
579
- # Persist only definitive answers (verified verdict or a
580
- # confirmed catch-all); transient failures are never cached.
581
- await self.cache.put_verdict(
582
- normalized, deep, smtp, catch_all_map.get(domain)
583
- )
584
-
585
- ca_value = (
586
- ca_cached if ca_cached is not None else catch_all_map.get(domain)
587
- )
588
- catch_all = CatchAllResult(
589
- is_catch_all=ca_value,
590
- confidence=0.9 if ca_value is not None else 0.0,
591
- )
592
- disposable = check_disposable(normalized, domain)
593
- ml = ml_map.get(domain)
594
- if ml is not None:
595
- disposable = disposable.model_copy(
596
- update={"ml_label": ml[0], "ml_confidence": ml[1]}
597
- )
598
-
599
- # A 4xx means "come back later", not a verdict. Schedule one
600
- # bounded retry so a real mailbox on a greylisting domain is not
601
- # stranded as permanently risky. The queue never raises.
602
- if cached is None:
603
- try:
604
- if is_greylist_result(smtp):
605
- await self.retry_queue.enqueue(
606
- normalized, dns.mx_records[0]["host"], smtp.smtp_code
607
- )
608
- elif smtp.verified is not None:
609
- # Definitive answer — cancel any pending retry.
610
- await self.retry_queue.resolve_email(normalized)
611
- except Exception:
612
- pass
613
-
614
- scoring = calculate_score(
615
- syntax, dns, disposable, catch_all, smtp
616
- )
617
- out[idx] = _stamp(
618
- email=normalized,
619
- status=self._determine_status(scoring),
620
- syntax=syntax,
621
- dns=dns,
622
- disposable=disposable,
623
- catch_all=catch_all,
624
- smtp=smtp,
625
- scoring=scoring,
626
- checks_run=_build_checks_run(
627
- syntax, dns, disposable, catch_all, smtp, scoring
628
- ),
629
- reason_code=_build_reason_code(
630
- dns, disposable, catch_all, smtp
631
- ),
632
- )
633
-
634
- # Guarantee one result per input, in order.
635
- for idx, result in enumerate(out):
636
- if result is None:
637
- raw = emails[idx]
638
- syntax = parsed[idx][1] if idx < len(parsed) else validate_syntax(raw)
639
- scoring = calculate_score(syntax, None, None, None, None)
640
- out[idx] = _stamp(
641
- email=raw,
642
- status=ValidationStatus.UNKNOWN,
643
- syntax=syntax,
644
- scoring=scoring,
645
- checks_run=_build_checks_run(
646
- syntax, None, None, None, None, scoring
647
- ),
648
- reason_code="engine_missing_result",
649
- )
650
- return [r for r in out if r is not None]
651
-
652
- def _determine_status(self, scoring: ScoringResult) -> ValidationStatus:
653
- if scoring.score >= 75:
654
- return ValidationStatus.VALID
655
- elif scoring.score >= 40:
656
- return ValidationStatus.RISKY
657
- else:
658
- return ValidationStatus.INVALID
659
-
660
-
661
- # Singleton engine
662
- engine = EmailValidationEngine()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
app/validator/i18n.py ADDED
@@ -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
app/validator/mailbox_provider.py CHANGED
@@ -1,19 +1,27 @@
1
  """Mailbox verification providers (Layer 5 catch-all + Layer 6 SMTP).
2
 
3
- Direct SMTP to the recipient MX on port 25 is the classic mailbox probe, but
4
- most cloud hosts — Hugging Face Spaces included — block outbound port 25. When
5
- direct SMTP cannot run, the mailbox verdict is delegated to a third-party
6
- verification API (MillionVerifier) whose status strings map 1:1 onto this
7
- product's taxonomy.
8
-
9
- resolve_provider() picks the provider from settings:
10
- mailbox_provider = "auto" → API when mv_api_key is set, else local SMTP
11
- mailbox_provider = "api" → force the verification API
12
- mailbox_provider = "local" → force direct SMTP
13
-
14
- The circuit breaker on LocalSmtpProvider stops a blocked host from burning the
15
- full SMTP timeout on every address in a bulk job: after `fail_threshold`
16
- consecutive connection failures it fails fast for `cooldown_seconds`.
 
 
 
 
 
 
 
 
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 CatchAllResult, SMTPResult
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 LocalSmtpProvider(MailboxProvider):
194
- """Direct port-25 SMTP with a circuit breaker for blocked egress."""
195
 
196
- name = "local"
 
 
 
 
 
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 provider.
266
 
267
- The validator is autonomous by default: it uses its own SMTP engine and
268
- needs no third-party account. The verification API is opt-in only
269
- (`mailbox_provider="api"`, or `prefer_api_provider=True` with a key set) —
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 == "api":
274
- return MillionVerifierProvider()
 
 
 
 
275
  if mode == "local":
276
  return AutonomousSmtpProvider()
277
- # auto → own engine, unless API use was explicitly preferred and configured.
278
- if settings.mv_api_key and getattr(settings, "prefer_api_provider", False):
279
- return MillionVerifierProvider()
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()
tests/test_autonomous_provider.py CHANGED
@@ -6,7 +6,7 @@ from app.models import SMTPResult
6
  from app.validator.mailbox_provider import (
7
  AutonomousSmtpProvider,
8
  LocalSmtpProvider,
9
- MillionVerifierProvider,
10
  resolve_provider,
11
  )
12
 
@@ -64,9 +64,9 @@ class AutonomousProviderTests(unittest.IsolatedAsyncioTestCase):
64
 
65
 
66
  class DefaultProviderTests(unittest.TestCase):
67
- def test_auto_without_key_resolves_to_autonomous(self):
68
  with patch.object(settings, "mailbox_provider", "auto"), patch.object(
69
- settings, "mv_api_key", ""
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 test_api_forced_still_available(self):
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, "mv_api_key", "k"
94
- ), patch.object(settings, "prefer_api_provider", False):
95
- self.assertIsInstance(resolve_provider(), AutonomousSmtpProvider)
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()
tests/test_dns_concurrency.py CHANGED
@@ -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)
tests/test_dns_correctness.py CHANGED
@@ -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.validator.dns_check import check_dns
8
-
9
-
10
- class ResolverStub:
11
- timeout = 0
12
- lifetime = 0
13
-
14
- def __init__(self, responses):
15
- self.responses = responses
16
-
17
- async def resolve(self, name, record_type):
18
- response = self.responses.get((name, record_type))
19
- if isinstance(response, Exception):
20
- raise response
21
- if response is None:
22
- raise dns.resolver.NoAnswer()
23
- return response
24
-
25
-
26
- class DNSCheckTests(unittest.IsolatedAsyncioTestCase):
27
- async def test_timeout_is_reported_as_transient(self):
28
- resolver = ResolverStub({("example.test", "MX"): dns.exception.Timeout()})
29
- with patch(
30
- "app.validator.dns_check.dns.asyncresolver.Resolver", return_value=resolver
31
- ):
32
- result = await check_dns("example.test")
33
-
34
- self.assertEqual(result.error_code, "timeout")
35
- self.assertFalse(result.domain_exists)
36
-
37
- async def test_no_nameservers_is_reported_as_transient(self):
38
- resolver = ResolverStub(
39
- {("unavailable.test", "MX"): dns.resolver.NoNameservers()}
40
- )
41
- with patch(
42
- "app.validator.dns_check.dns.asyncresolver.Resolver", return_value=resolver
43
- ):
44
- result = await check_dns("unavailable.test")
45
-
46
- self.assertEqual(result.error_code, "resolver_error")
47
-
48
- async def test_nxdomain_is_reported_as_definitive(self):
49
- resolver = ResolverStub({("missing.test", "MX"): dns.resolver.NXDOMAIN()})
50
- with patch(
51
- "app.validator.dns_check.dns.asyncresolver.Resolver", return_value=resolver
52
- ):
53
- result = await check_dns("missing.test")
54
-
55
- self.assertEqual(result.error_code, "nxdomain")
56
- self.assertFalse(result.domain_exists)
57
-
58
- async def test_a_record_fallback_is_preserved(self):
59
- resolver = ResolverStub(
60
- {
61
- ("mail.example.test", "MX"): dns.resolver.NoAnswer(),
62
- ("mail.example.test", "A"): ["192.0.2.10"],
63
- }
64
- )
65
- with patch(
66
- "app.validator.dns_check.dns.asyncresolver.Resolver", return_value=resolver
67
- ):
68
- result = await check_dns("mail.example.test")
69
-
70
- self.assertTrue(result.domain_exists)
71
- self.assertFalse(result.has_mx_records)
72
- self.assertTrue(result.a_fallback)
73
- self.assertEqual(result.mx_records[0]["host"], "mail.example.test")
74
-
75
- async def test_no_mx_or_address_is_distinct_from_transient_dns_error(self):
76
- resolver = ResolverStub(
77
- {
78
- ("no-mail.example.test", "MX"): dns.resolver.NoAnswer(),
79
- ("no-mail.example.test", "A"): dns.resolver.NoAnswer(),
80
- ("no-mail.example.test", "AAAA"): dns.resolver.NoAnswer(),
81
- }
82
- )
83
- with patch(
84
- "app.validator.dns_check.dns.asyncresolver.Resolver", return_value=resolver
85
- ):
86
- result = await check_dns("no-mail.example.test")
87
-
88
- self.assertEqual(result.error_code, "no_mail_records")
89
- self.assertTrue(result.domain_exists)
90
- self.assertFalse(result.has_mx_records)
91
- self.assertFalse(result.a_fallback)
92
-
93
-
94
- if __name__ == "__main__":
95
- unittest.main()
 
 
 
 
 
 
 
 
 
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()
tests/test_doh_resolver.py ADDED
@@ -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()
tests/test_engine_autonomous.py ADDED
@@ -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()
tests/test_hf_scorer.py ADDED
@@ -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()
tests/test_i18n.py ADDED
@@ -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()
tests/test_mailbox_provider.py CHANGED
@@ -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 MapMVResultTests(unittest.TestCase):
17
- """MillionVerifier live schema (verified 2026-10-11 against API_KEY_FOR_TEST):
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
- async def test_catch_all_probe_gibberish_not_real_address(self):
70
- # catch-all must be detected with a gibberish local part, never the
71
- # real address (that would be a false negative).
72
- provider = MillionVerifierProvider(api_key="test-key", timeout=5)
73
- captured = {}
74
 
75
- with patch("app.validator.mailbox_provider.httpx.AsyncClient") as client_cls:
76
- async def fake_get(url, params=None, timeout=None):
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
- self.assertNotIn("example.com", captured["params"]["email"].split("@")[0])
87
- self.assertTrue(is_catch_all)
88
- self.assertGreater(confidence, 0.5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
 
91
  class EngineProviderVerdictTests(unittest.IsolatedAsyncioTestCase):
92
- """End-to-end: a provider-confirmed dead mailbox must hard-fail to score 0
93
- (Invalid) through the whole engine, not be softened to 'risky' (the live
94
- mis-score observed before the adapter supplied an explicit rejection phrase)."""
95
 
96
- async def test_provider_invalid_scores_zero(self):
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 map_mv_result({"result": "invalid", "resultcode": 4})
 
 
 
 
 
106
  async def catch_all(self, domain, mx_host):
107
  return (False, 0.9)
108
 
109
- engine.mailbox = DeadMailboxProvider()
110
  with (
111
- _patch("app.validator.engine.validate_syntax", return_value=SyntaxResult(
112
  passed=True, normalized_email="a@example.test",
113
  domain="example.test", local_part="a",
114
  )),
115
- _patch("app.validator.engine.check_dns", new=AsyncMock(return_value=DNSResult(
116
  domain_exists=True, has_mx_records=True,
117
  mx_records=[{"host": "mx.example.test", "priority": 10}],
118
  ))),
119
- _patch("app.validator.engine.check_disposable", return_value=DisposableResult(
120
  is_disposable=False, is_free_provider=False, is_role_based=False,
121
  )),
122
  ):
123
- result = await engine.validate("a@example.test", deep=True)
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 ResolveProviderTests(unittest.TestCase):
131
- def test_api_forced(self):
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 test_local_forced_is_autonomous(self):
138
- with patch.object(settings, "mailbox_provider", "local"):
139
- self.assertIsInstance(resolve_provider(), AutonomousSmtpProvider)
 
 
140
 
141
- def test_auto_with_key_prefers_api_only_when_opted_in(self):
142
- with patch.object(settings, "mailbox_provider", "auto"), patch.object(
143
- settings, "mv_api_key", "k"
144
- ), patch.object(settings, "prefer_api_provider", True):
145
- self.assertIsInstance(resolve_provider(), MillionVerifierProvider)
 
 
 
 
 
 
 
 
 
 
 
146
 
147
- def test_auto_without_key_is_autonomous(self):
148
- with patch.object(settings, "mailbox_provider", "auto"), patch.object(
149
- settings, "mv_api_key", ""
150
- ):
151
- self.assertIsInstance(resolve_provider(), AutonomousSmtpProvider)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
- def test_provider_satisfies_protocol(self):
154
- # Structural check: both providers expose verify + catch_all.
155
- self.assertTrue(hasattr(MillionVerifierProvider, "verify"))
156
- self.assertTrue(hasattr(MillionVerifierProvider, "catch_all"))
157
- self.assertTrue(hasattr(LocalSmtpProvider, "verify"))
158
- self.assertTrue(hasattr(LocalSmtpProvider, "catch_all"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):
tests/test_worker_endpoints.py ADDED
@@ -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()