Upload folder using huggingface_hub
Browse files- app/api/routes.py +33 -10
- app/tasks/celery_tasks.py +22 -20
- app/validator/engine.py +211 -0
- app/validator/mailbox_provider.py +16 -0
- tests/test_bulk_depth.py +19 -23
- tests/test_engine_batch.py +219 -0
app/api/routes.py
CHANGED
|
@@ -25,6 +25,10 @@ async def process_bulk_sync(emails: list, webhook_url: str = None, deep: bool =
|
|
| 25 |
`deep` defaults to True so bulk runs the full 7-layer pipeline (catch-all +
|
| 26 |
SMTP) exactly like single verification — the product charges 1 credit/email
|
| 27 |
and must not silently degrade to a syntax+DNS-only check.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
"""
|
| 29 |
results = []
|
| 30 |
total = len(emails)
|
|
@@ -32,19 +36,38 @@ async def process_bulk_sync(emails: list, webhook_url: str = None, deep: bool =
|
|
| 32 |
|
| 33 |
for i in range(0, total, chunk_size):
|
| 34 |
chunk = emails[i:i + chunk_size]
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
if isinstance(result, ValidationResult):
|
| 40 |
results.append(result.model_dump())
|
| 41 |
else:
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
| 48 |
|
| 49 |
# Webhook notification if provided
|
| 50 |
if webhook_url:
|
|
|
|
| 25 |
`deep` defaults to True so bulk runs the full 7-layer pipeline (catch-all +
|
| 26 |
SMTP) exactly like single verification — the product charges 1 credit/email
|
| 27 |
and must not silently degrade to a syntax+DNS-only check.
|
| 28 |
+
|
| 29 |
+
Chunks feed the engine's *batched* path: DNS/MX resolves once per domain and
|
| 30 |
+
the mailbox stage takes the whole chunk in one call, so the SMTP engine reuses
|
| 31 |
+
a session per receiving host instead of opening a TCP connection per address.
|
| 32 |
"""
|
| 33 |
results = []
|
| 34 |
total = len(emails)
|
|
|
|
| 36 |
|
| 37 |
for i in range(0, total, chunk_size):
|
| 38 |
chunk = emails[i:i + chunk_size]
|
| 39 |
+
try:
|
| 40 |
+
chunk_results = await engine.validate_batch(chunk, deep=deep)
|
| 41 |
+
except Exception as exc: # never lose a chunk to one bad stage
|
| 42 |
+
results.extend(
|
| 43 |
+
{
|
| 44 |
+
"email": email,
|
| 45 |
+
"error": str(exc) or "Unknown error",
|
| 46 |
+
"status": "error",
|
| 47 |
+
}
|
| 48 |
+
for email in chunk
|
| 49 |
+
)
|
| 50 |
+
continue
|
| 51 |
+
|
| 52 |
+
# validate_batch guarantees one result per input, in input order.
|
| 53 |
+
if len(chunk_results) == len(chunk):
|
| 54 |
+
pairs = zip(chunk, chunk_results)
|
| 55 |
+
else:
|
| 56 |
+
# Defensive: never lose or mis-attribute a row.
|
| 57 |
+
by_email = {getattr(r, "email", "").lower(): r for r in chunk_results}
|
| 58 |
+
pairs = ((email, by_email.get(email.lower())) for email in chunk)
|
| 59 |
+
|
| 60 |
+
for email, result in pairs:
|
| 61 |
if isinstance(result, ValidationResult):
|
| 62 |
results.append(result.model_dump())
|
| 63 |
else:
|
| 64 |
+
results.append(
|
| 65 |
+
{
|
| 66 |
+
"email": email,
|
| 67 |
+
"error": "engine returned no result for this address",
|
| 68 |
+
"status": "error",
|
| 69 |
+
}
|
| 70 |
+
)
|
| 71 |
|
| 72 |
# Webhook notification if provided
|
| 73 |
if webhook_url:
|
app/tasks/celery_tasks.py
CHANGED
|
@@ -47,38 +47,40 @@ def bulk_validate_task(self, emails: list, webhook_url: str = None, deep: bool =
|
|
| 47 |
from ..db import repo
|
| 48 |
import sys
|
| 49 |
|
|
|
|
|
|
|
| 50 |
async def run():
|
| 51 |
eng = EmailValidationEngine()
|
| 52 |
results = []
|
| 53 |
total = len(emails)
|
| 54 |
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
try:
|
| 57 |
-
|
| 58 |
-
results.
|
| 59 |
-
# Update progress
|
| 60 |
-
self.update_state(
|
| 61 |
-
state="PROGRESS",
|
| 62 |
-
meta={"current": i + 1, "total": total}
|
| 63 |
-
)
|
| 64 |
except Exception as e:
|
| 65 |
print(
|
| 66 |
-
f"[CELERY TASK] Validation failed for
|
| 67 |
-
f"{e.__class__.__name__}",
|
| 68 |
file=sys.stderr,
|
| 69 |
)
|
| 70 |
-
results.
|
| 71 |
-
"email": email,
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
|
| 76 |
# Persist incremental progress to Supabase (best-effort)
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
print(f"[CELERY TASK] Failed to update progress: {str(e)}", file=sys.stderr)
|
| 82 |
|
| 83 |
return results
|
| 84 |
|
|
|
|
| 47 |
from ..db import repo
|
| 48 |
import sys
|
| 49 |
|
| 50 |
+
CHUNK = 200
|
| 51 |
+
|
| 52 |
async def run():
|
| 53 |
eng = EmailValidationEngine()
|
| 54 |
results = []
|
| 55 |
total = len(emails)
|
| 56 |
|
| 57 |
+
# Process in chunks through the engine's batched path: DNS/MX once per
|
| 58 |
+
# domain and one mailbox call per chunk, so the SMTP engine reuses a
|
| 59 |
+
# session per receiving host instead of a TCP connection per address.
|
| 60 |
+
for start in range(0, total, CHUNK):
|
| 61 |
+
chunk = emails[start:start + CHUNK]
|
| 62 |
try:
|
| 63 |
+
chunk_results = await eng.validate_batch(chunk, deep=deep)
|
| 64 |
+
results.extend(r.model_dump() for r in chunk_results)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
except Exception as e:
|
| 66 |
print(
|
| 67 |
+
f"[CELERY TASK] Validation failed for chunk "
|
| 68 |
+
f"{start + 1}-{start + len(chunk)}/{total}: {e.__class__.__name__}",
|
| 69 |
file=sys.stderr,
|
| 70 |
)
|
| 71 |
+
results.extend(
|
| 72 |
+
{"email": email, "error": str(e), "status": "error"}
|
| 73 |
+
for email in chunk
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
done = min(start + len(chunk), total)
|
| 77 |
+
self.update_state(state="PROGRESS", meta={"current": done, "total": total})
|
| 78 |
|
| 79 |
# Persist incremental progress to Supabase (best-effort)
|
| 80 |
+
try:
|
| 81 |
+
repo.update_bulk_job_progress(self.request.id, done, total)
|
| 82 |
+
except Exception as e:
|
| 83 |
+
print(f"[CELERY TASK] Failed to update progress: {str(e)}", file=sys.stderr)
|
|
|
|
| 84 |
|
| 85 |
return results
|
| 86 |
|
app/validator/engine.py
CHANGED
|
@@ -299,6 +299,217 @@ class EmailValidationEngine:
|
|
| 299 |
validator_version=settings.app_version,
|
| 300 |
)
|
| 301 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
def _determine_status(self, scoring: ScoringResult) -> ValidationStatus:
|
| 303 |
if scoring.score >= 75:
|
| 304 |
return ValidationStatus.VALID
|
|
|
|
| 299 |
validator_version=settings.app_version,
|
| 300 |
)
|
| 301 |
|
| 302 |
+
async def validate_batch(
|
| 303 |
+
self, emails: list[str], deep: bool = True
|
| 304 |
+
) -> list[ValidationResult]:
|
| 305 |
+
"""Validate many addresses in one pass, reusing per-domain work.
|
| 306 |
+
|
| 307 |
+
Bulk throughput comes from doing DNS/MX **once per domain** and handing
|
| 308 |
+
every (email, mx_host) pair to the mailbox provider in a **single** call,
|
| 309 |
+
so the SMTP engine can reuse one session per receiving host instead of a
|
| 310 |
+
fresh TCP handshake per address. Providers that cannot batch fall back to
|
| 311 |
+
the per-address path, so behaviour is never worse than before.
|
| 312 |
+
|
| 313 |
+
Results are returned in the same order as `emails`.
|
| 314 |
+
"""
|
| 315 |
+
if not emails:
|
| 316 |
+
return []
|
| 317 |
+
|
| 318 |
+
run_id = uuid.uuid4().hex[:12]
|
| 319 |
+
out: list[ValidationResult | None] = [None] * len(emails)
|
| 320 |
+
|
| 321 |
+
def _stamp(**kw) -> ValidationResult:
|
| 322 |
+
return ValidationResult(
|
| 323 |
+
processing_time_ms=0.0,
|
| 324 |
+
validated_at=datetime.now(timezone.utc).isoformat(),
|
| 325 |
+
run_id=run_id,
|
| 326 |
+
validator_version=settings.app_version,
|
| 327 |
+
**kw,
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
# ---- Layer 1: syntax (per address) ---------------------------
|
| 331 |
+
parsed: list[tuple[str, SyntaxResult]] = []
|
| 332 |
+
domain_index: dict[str, list[int]] = {}
|
| 333 |
+
for idx, raw in enumerate(emails):
|
| 334 |
+
syntax = validate_syntax(raw)
|
| 335 |
+
parsed.append((raw, syntax))
|
| 336 |
+
if not syntax.passed:
|
| 337 |
+
scoring = calculate_score(syntax, None, None, None, None)
|
| 338 |
+
out[idx] = _stamp(
|
| 339 |
+
email=raw,
|
| 340 |
+
status=ValidationStatus.INVALID,
|
| 341 |
+
syntax=syntax,
|
| 342 |
+
scoring=scoring,
|
| 343 |
+
checks_run=_build_checks_run(syntax, None, None, None, None, scoring),
|
| 344 |
+
reason_code=_build_reason_code(None, None, None, None),
|
| 345 |
+
)
|
| 346 |
+
continue
|
| 347 |
+
domain_index.setdefault(syntax.domain, []).append(idx)
|
| 348 |
+
|
| 349 |
+
# ---- Layers 2 & 3: DNS + MX once per domain ------------------
|
| 350 |
+
domains = list(domain_index.keys())
|
| 351 |
+
dns_list = await asyncio.gather(
|
| 352 |
+
*[check_dns(d, settings.dns_timeout) for d in domains],
|
| 353 |
+
return_exceptions=True,
|
| 354 |
+
)
|
| 355 |
+
domain_dns: dict[str, DNSResult] = {}
|
| 356 |
+
for domain, dns in zip(domains, dns_list):
|
| 357 |
+
domain_dns[domain] = (
|
| 358 |
+
dns
|
| 359 |
+
if isinstance(dns, DNSResult)
|
| 360 |
+
else DNSResult(
|
| 361 |
+
domain_exists=False,
|
| 362 |
+
has_mx_records=False,
|
| 363 |
+
error=str(dns),
|
| 364 |
+
error_code="resolver_error",
|
| 365 |
+
)
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
pending: list[tuple[int, str, SyntaxResult, str, DNSResult]] = []
|
| 369 |
+
for domain, idxs in domain_index.items():
|
| 370 |
+
dns = domain_dns[domain]
|
| 371 |
+
inconclusive = dns.error_code in {"timeout", "resolver_error"}
|
| 372 |
+
dead = (
|
| 373 |
+
not dns.domain_exists
|
| 374 |
+
or dns.error_code == "nxdomain"
|
| 375 |
+
or (not dns.has_mx_records and not dns.a_fallback)
|
| 376 |
+
)
|
| 377 |
+
for idx in idxs:
|
| 378 |
+
raw, syntax = parsed[idx]
|
| 379 |
+
normalized = syntax.normalized_email or raw
|
| 380 |
+
if inconclusive:
|
| 381 |
+
scoring = calculate_score(syntax, dns, None, None, None)
|
| 382 |
+
out[idx] = _stamp(
|
| 383 |
+
email=normalized,
|
| 384 |
+
status=self._determine_status(scoring),
|
| 385 |
+
syntax=syntax,
|
| 386 |
+
dns=dns,
|
| 387 |
+
scoring=scoring,
|
| 388 |
+
checks_run=_build_checks_run(
|
| 389 |
+
syntax, dns, None, None, None, scoring
|
| 390 |
+
),
|
| 391 |
+
reason_code=_build_reason_code(
|
| 392 |
+
dns, None, None, None, early="dns_timeout"
|
| 393 |
+
),
|
| 394 |
+
)
|
| 395 |
+
elif dead:
|
| 396 |
+
scoring = calculate_score(syntax, dns, None, None, None)
|
| 397 |
+
out[idx] = _stamp(
|
| 398 |
+
email=normalized,
|
| 399 |
+
status=ValidationStatus.INVALID,
|
| 400 |
+
syntax=syntax,
|
| 401 |
+
dns=dns,
|
| 402 |
+
scoring=scoring,
|
| 403 |
+
checks_run=_build_checks_run(
|
| 404 |
+
syntax, dns, None, None, None, scoring
|
| 405 |
+
),
|
| 406 |
+
reason_code=_build_reason_code(
|
| 407 |
+
dns, None, None, None, early="nxdomain"
|
| 408 |
+
),
|
| 409 |
+
)
|
| 410 |
+
else:
|
| 411 |
+
pending.append((idx, normalized, syntax, domain, dns))
|
| 412 |
+
|
| 413 |
+
# ---- Layer 4 fast path (no mailbox probe) --------------------
|
| 414 |
+
if pending and not deep:
|
| 415 |
+
for idx, normalized, syntax, domain, dns in pending:
|
| 416 |
+
disposable = check_disposable(normalized, domain)
|
| 417 |
+
scoring = calculate_score(
|
| 418 |
+
syntax, dns, disposable, None, None,
|
| 419 |
+
mailbox_verification_skipped=True,
|
| 420 |
+
)
|
| 421 |
+
out[idx] = _stamp(
|
| 422 |
+
email=normalized,
|
| 423 |
+
status=self._determine_status(scoring),
|
| 424 |
+
syntax=syntax,
|
| 425 |
+
dns=dns,
|
| 426 |
+
disposable=disposable,
|
| 427 |
+
scoring=scoring,
|
| 428 |
+
checks_run=_build_checks_run(
|
| 429 |
+
syntax, dns, disposable, None, None, scoring
|
| 430 |
+
),
|
| 431 |
+
reason_code=_build_reason_code(dns, disposable, None, None),
|
| 432 |
+
)
|
| 433 |
+
pending = []
|
| 434 |
+
|
| 435 |
+
# ---- Layers 5 & 6: ONE batched mailbox call ------------------
|
| 436 |
+
if pending:
|
| 437 |
+
pairs = [
|
| 438 |
+
(normalized, dns.mx_records[0]["host"])
|
| 439 |
+
for _, normalized, _, _, dns in pending
|
| 440 |
+
]
|
| 441 |
+
catch_all_map: dict[str, Optional[bool]] = {}
|
| 442 |
+
try:
|
| 443 |
+
if hasattr(self.mailbox, "verify_many"):
|
| 444 |
+
smtp_map, caught = await self.mailbox.verify_many(pairs)
|
| 445 |
+
catch_all_map = dict(caught or {})
|
| 446 |
+
else:
|
| 447 |
+
# Provider cannot batch: per-address fallback.
|
| 448 |
+
smtp_map = {}
|
| 449 |
+
for email, mx in pairs:
|
| 450 |
+
smtp_map[email] = await self.mailbox.verify(email, mx)
|
| 451 |
+
for domain in {e.rsplit("@", 1)[-1].lower() for e, _ in pairs}:
|
| 452 |
+
mx = next(
|
| 453 |
+
m for e, m in pairs
|
| 454 |
+
if e.rsplit("@", 1)[-1].lower() == domain
|
| 455 |
+
)
|
| 456 |
+
value, _conf = await self.mailbox.catch_all(domain, mx)
|
| 457 |
+
catch_all_map[domain] = value
|
| 458 |
+
except Exception as exc: # one bad stage must not abort the batch
|
| 459 |
+
smtp_map = {
|
| 460 |
+
e: SMTPResult(verified=None, error=f"mailbox_stage_error: {exc}")
|
| 461 |
+
for e, _ in pairs
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
# ---- Layer 7: score each row -----------------------------
|
| 465 |
+
for idx, normalized, syntax, domain, dns in pending:
|
| 466 |
+
smtp = smtp_map.get(normalized) or SMTPResult(
|
| 467 |
+
verified=None, error="engine returned no result"
|
| 468 |
+
)
|
| 469 |
+
ca_value = catch_all_map.get(domain)
|
| 470 |
+
catch_all = CatchAllResult(
|
| 471 |
+
is_catch_all=ca_value,
|
| 472 |
+
confidence=0.9 if ca_value is not None else 0.0,
|
| 473 |
+
)
|
| 474 |
+
disposable = check_disposable(normalized, domain)
|
| 475 |
+
scoring = calculate_score(
|
| 476 |
+
syntax, dns, disposable, catch_all, smtp
|
| 477 |
+
)
|
| 478 |
+
out[idx] = _stamp(
|
| 479 |
+
email=normalized,
|
| 480 |
+
status=self._determine_status(scoring),
|
| 481 |
+
syntax=syntax,
|
| 482 |
+
dns=dns,
|
| 483 |
+
disposable=disposable,
|
| 484 |
+
catch_all=catch_all,
|
| 485 |
+
smtp=smtp,
|
| 486 |
+
scoring=scoring,
|
| 487 |
+
checks_run=_build_checks_run(
|
| 488 |
+
syntax, dns, disposable, catch_all, smtp, scoring
|
| 489 |
+
),
|
| 490 |
+
reason_code=_build_reason_code(
|
| 491 |
+
dns, disposable, catch_all, smtp
|
| 492 |
+
),
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
# Guarantee one result per input, in order.
|
| 496 |
+
for idx, result in enumerate(out):
|
| 497 |
+
if result is None:
|
| 498 |
+
raw = emails[idx]
|
| 499 |
+
syntax = parsed[idx][1] if idx < len(parsed) else validate_syntax(raw)
|
| 500 |
+
scoring = calculate_score(syntax, None, None, None, None)
|
| 501 |
+
out[idx] = _stamp(
|
| 502 |
+
email=raw,
|
| 503 |
+
status=ValidationStatus.UNKNOWN,
|
| 504 |
+
syntax=syntax,
|
| 505 |
+
scoring=scoring,
|
| 506 |
+
checks_run=_build_checks_run(
|
| 507 |
+
syntax, None, None, None, None, scoring
|
| 508 |
+
),
|
| 509 |
+
reason_code="engine_missing_result",
|
| 510 |
+
)
|
| 511 |
+
return [r for r in out if r is not None]
|
| 512 |
+
|
| 513 |
def _determine_status(self, scoring: ScoringResult) -> ValidationStatus:
|
| 514 |
if scoring.score >= 75:
|
| 515 |
return ValidationStatus.VALID
|
app/validator/mailbox_provider.py
CHANGED
|
@@ -169,6 +169,22 @@ class AutonomousSmtpProvider(MailboxProvider):
|
|
| 169 |
return True, 0.95
|
| 170 |
return None, 0.0
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
class LocalSmtpProvider(MailboxProvider):
|
| 174 |
"""Direct port-25 SMTP with a circuit breaker for blocked egress."""
|
|
|
|
| 169 |
return True, 0.95
|
| 170 |
return None, 0.0
|
| 171 |
|
| 172 |
+
async def verify_many(self, pairs):
|
| 173 |
+
"""Batched mailbox stage: one call for the whole list.
|
| 174 |
+
|
| 175 |
+
Returns (email -> SMTPResult, domain -> catch-all flag). This is what
|
| 176 |
+
lets the engine do one TCP session per domain instead of one per address.
|
| 177 |
+
"""
|
| 178 |
+
pairs = [(e, mx) for e, mx in pairs if e and mx]
|
| 179 |
+
if not pairs:
|
| 180 |
+
return {}, {}
|
| 181 |
+
results = self.batch.verify_batch(pairs)
|
| 182 |
+
domains = {e.rsplit("@", 1)[-1].lower(): mx for e, mx in pairs}
|
| 183 |
+
catch_all = {
|
| 184 |
+
domain: self.batch.catch_all_cache.get(domain) for domain in domains
|
| 185 |
+
}
|
| 186 |
+
return results, catch_all
|
| 187 |
+
|
| 188 |
|
| 189 |
class LocalSmtpProvider(MailboxProvider):
|
| 190 |
"""Direct port-25 SMTP with a circuit breaker for blocked egress."""
|
tests/test_bulk_depth.py
CHANGED
|
@@ -39,34 +39,30 @@ class BulkDepthTests(unittest.IsolatedAsyncioTestCase):
|
|
| 39 |
Bulk must run the same deep pipeline as single verification."""
|
| 40 |
|
| 41 |
async def test_process_bulk_sync_runs_deep_layers(self):
|
| 42 |
-
|
|
|
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
|
| 48 |
-
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
# patch engine.validate directly to capture the deep flag
|
| 55 |
-
async def capture_validate(email, deep=True):
|
| 56 |
-
captured.append(deep)
|
| 57 |
-
|
| 58 |
-
class FakeResult:
|
| 59 |
-
def model_dump(self):
|
| 60 |
-
return {"email": email}
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
|
| 68 |
-
self.
|
| 69 |
-
self.assertTrue(
|
|
|
|
| 70 |
|
| 71 |
async def test_engine_deep_runs_catch_all_and_smtp(self):
|
| 72 |
engine = EmailValidationEngine()
|
|
|
|
| 39 |
Bulk must run the same deep pipeline as single verification."""
|
| 40 |
|
| 41 |
async def test_process_bulk_sync_runs_deep_layers(self):
|
| 42 |
+
"""Bulk must run deep, via the batched path (one call for the chunk)."""
|
| 43 |
+
captured = {}
|
| 44 |
|
| 45 |
+
class FakeResult:
|
| 46 |
+
def __init__(self, email):
|
| 47 |
+
self.email = email
|
| 48 |
|
| 49 |
+
def model_dump(self):
|
| 50 |
+
return {"email": self.email}
|
| 51 |
|
| 52 |
+
async def capture_batch(emails, deep=True):
|
| 53 |
+
captured["emails"] = list(emails)
|
| 54 |
+
captured["deep"] = deep
|
| 55 |
+
return [FakeResult(e) for e in emails]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
+
with patch(
|
| 58 |
+
"app.api.routes.engine.validate_batch",
|
| 59 |
+
new=AsyncMock(side_effect=capture_batch),
|
| 60 |
+
):
|
| 61 |
+
out = await process_bulk_sync(["a@example.com", "b@example.com"])
|
| 62 |
|
| 63 |
+
self.assertEqual(captured["emails"], ["a@example.com", "b@example.com"])
|
| 64 |
+
self.assertTrue(captured["deep"], "bulk must run deep")
|
| 65 |
+
self.assertEqual(len(out), 2)
|
| 66 |
|
| 67 |
async def test_engine_deep_runs_catch_all_and_smtp(self):
|
| 68 |
engine = EmailValidationEngine()
|
tests/test_engine_batch.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from unittest.mock import AsyncMock, patch
|
| 3 |
+
|
| 4 |
+
from app.models import (
|
| 5 |
+
DNSResult,
|
| 6 |
+
DisposableResult,
|
| 7 |
+
SMTPResult,
|
| 8 |
+
SyntaxResult,
|
| 9 |
+
ValidationStatus,
|
| 10 |
+
)
|
| 11 |
+
from app.validator.engine import EmailValidationEngine
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _dns_ok(host="mx.example.test"):
|
| 15 |
+
return DNSResult(
|
| 16 |
+
domain_exists=True,
|
| 17 |
+
has_mx_records=True,
|
| 18 |
+
mx_records=[{"host": host, "priority": 10}],
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _disposable_clean():
|
| 23 |
+
return DisposableResult(
|
| 24 |
+
is_disposable=False, is_free_provider=False, is_role_based=False
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class FakeBatchProvider:
|
| 29 |
+
"""Provider exposing the bulk capability the engine should exploit.
|
| 30 |
+
|
| 31 |
+
`verify_many` is the whole point: one call for N addresses lets the SMTP
|
| 32 |
+
engine reuse a single session per domain instead of one TCP handshake each.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
def __init__(self, results=None, catch_all=None):
|
| 36 |
+
self._results = results or {}
|
| 37 |
+
self._catch_all = catch_all or {}
|
| 38 |
+
self.batch_calls: list[list[tuple[str, str]]] = []
|
| 39 |
+
self.single_calls = 0
|
| 40 |
+
|
| 41 |
+
async def verify_many(self, pairs):
|
| 42 |
+
self.batch_calls.append(list(pairs))
|
| 43 |
+
return dict(self._results), dict(self._catch_all)
|
| 44 |
+
|
| 45 |
+
async def verify(self, email, mx_host):
|
| 46 |
+
self.single_calls += 1
|
| 47 |
+
return self._results.get(email, SMTPResult(verified=None, error="n/a"))
|
| 48 |
+
|
| 49 |
+
async def catch_all(self, domain, mx_host):
|
| 50 |
+
value = self._catch_all.get(domain)
|
| 51 |
+
return (value, 0.9) if value is not None else (None, 0.0)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class EngineBatchTests(unittest.IsolatedAsyncioTestCase):
|
| 55 |
+
"""Bulk must go through ONE batched mailbox stage per run, not one
|
| 56 |
+
connection per address (the throughput requirement)."""
|
| 57 |
+
|
| 58 |
+
async def test_batch_resolves_dns_once_per_domain(self):
|
| 59 |
+
provider = FakeBatchProvider(
|
| 60 |
+
results={
|
| 61 |
+
"a@corp.test": SMTPResult(verified=True, smtp_code=250),
|
| 62 |
+
"b@corp.test": SMTPResult(verified=False, smtp_code=550,
|
| 63 |
+
smtp_message="550 mailbox does not exist"),
|
| 64 |
+
"c@other.test": SMTPResult(verified=True, smtp_code=250),
|
| 65 |
+
},
|
| 66 |
+
catch_all={"corp.test": False, "other.test": False},
|
| 67 |
+
)
|
| 68 |
+
engine = EmailValidationEngine(mailbox=provider)
|
| 69 |
+
with (
|
| 70 |
+
patch(
|
| 71 |
+
"app.validator.engine.check_dns",
|
| 72 |
+
new=AsyncMock(return_value=_dns_ok()),
|
| 73 |
+
) as dns_mock,
|
| 74 |
+
patch(
|
| 75 |
+
"app.validator.engine.check_disposable",
|
| 76 |
+
return_value=_disposable_clean(),
|
| 77 |
+
),
|
| 78 |
+
):
|
| 79 |
+
results = await engine.validate_batch(
|
| 80 |
+
["a@corp.test", "b@corp.test", "c@other.test"], deep=True
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Two unique domains -> two DNS resolutions, not three.
|
| 84 |
+
self.assertEqual(dns_mock.await_count, 2)
|
| 85 |
+
self.assertEqual(len(results), 3)
|
| 86 |
+
|
| 87 |
+
async def test_batch_calls_provider_verify_many_once(self):
|
| 88 |
+
provider = FakeBatchProvider(
|
| 89 |
+
results={
|
| 90 |
+
"a@corp.test": SMTPResult(verified=True, smtp_code=250),
|
| 91 |
+
"b@corp.test": SMTPResult(verified=True, smtp_code=250),
|
| 92 |
+
},
|
| 93 |
+
catch_all={"corp.test": False},
|
| 94 |
+
)
|
| 95 |
+
engine = EmailValidationEngine(mailbox=provider)
|
| 96 |
+
with (
|
| 97 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns_ok())),
|
| 98 |
+
patch("app.validator.engine.check_disposable", return_value=_disposable_clean()),
|
| 99 |
+
):
|
| 100 |
+
await engine.validate_batch(["a@corp.test", "b@corp.test"], deep=True)
|
| 101 |
+
|
| 102 |
+
self.assertEqual(len(provider.batch_calls), 1)
|
| 103 |
+
self.assertEqual(
|
| 104 |
+
sorted(provider.batch_calls[0]),
|
| 105 |
+
[("a@corp.test", "mx.example.test"), ("b@corp.test", "mx.example.test")],
|
| 106 |
+
)
|
| 107 |
+
# The per-address path must NOT be used when batching is available.
|
| 108 |
+
self.assertEqual(provider.single_calls, 0)
|
| 109 |
+
|
| 110 |
+
async def test_batch_results_carry_provenance(self):
|
| 111 |
+
provider = FakeBatchProvider(
|
| 112 |
+
results={"a@corp.test": SMTPResult(verified=True, smtp_code=250)},
|
| 113 |
+
catch_all={"corp.test": False},
|
| 114 |
+
)
|
| 115 |
+
engine = EmailValidationEngine(mailbox=provider)
|
| 116 |
+
with (
|
| 117 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns_ok())),
|
| 118 |
+
patch("app.validator.engine.check_disposable", return_value=_disposable_clean()),
|
| 119 |
+
):
|
| 120 |
+
results = await engine.validate_batch(["a@corp.test"], deep=True)
|
| 121 |
+
|
| 122 |
+
r = results[0]
|
| 123 |
+
self.assertEqual(
|
| 124 |
+
r.checks_run,
|
| 125 |
+
["syntax", "dns", "mx", "disposable", "catch_all", "smtp", "scoring"],
|
| 126 |
+
)
|
| 127 |
+
self.assertTrue(r.run_id)
|
| 128 |
+
self.assertEqual(r.status, ValidationStatus.VALID)
|
| 129 |
+
|
| 130 |
+
async def test_batch_catch_all_rows_are_risky_not_valid(self):
|
| 131 |
+
provider = FakeBatchProvider(
|
| 132 |
+
results={
|
| 133 |
+
"x@catchall.test": SMTPResult(
|
| 134 |
+
verified=None,
|
| 135 |
+
error="catch_all: catchall.test accepts every recipient",
|
| 136 |
+
)
|
| 137 |
+
},
|
| 138 |
+
catch_all={"catchall.test": True},
|
| 139 |
+
)
|
| 140 |
+
engine = EmailValidationEngine(mailbox=provider)
|
| 141 |
+
with (
|
| 142 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns_ok())),
|
| 143 |
+
patch("app.validator.engine.check_disposable", return_value=_disposable_clean()),
|
| 144 |
+
):
|
| 145 |
+
results = await engine.validate_batch(["x@catchall.test"], deep=True)
|
| 146 |
+
|
| 147 |
+
self.assertEqual(results[0].status, ValidationStatus.RISKY)
|
| 148 |
+
self.assertEqual(results[0].reason_code, "catch_all_detected")
|
| 149 |
+
|
| 150 |
+
async def test_batch_preserves_input_order_and_handles_syntax_failures(self):
|
| 151 |
+
provider = FakeBatchProvider(
|
| 152 |
+
results={"ok@corp.test": SMTPResult(verified=True, smtp_code=250)},
|
| 153 |
+
catch_all={"corp.test": False},
|
| 154 |
+
)
|
| 155 |
+
engine = EmailValidationEngine(mailbox=provider)
|
| 156 |
+
with (
|
| 157 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns_ok())),
|
| 158 |
+
patch("app.validator.engine.check_disposable", return_value=_disposable_clean()),
|
| 159 |
+
):
|
| 160 |
+
results = await engine.validate_batch(
|
| 161 |
+
["not-an-email", "ok@corp.test"], deep=True
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
self.assertEqual(len(results), 2)
|
| 165 |
+
self.assertEqual(results[0].email, "not-an-email")
|
| 166 |
+
self.assertEqual(results[0].status, ValidationStatus.INVALID)
|
| 167 |
+
self.assertEqual(results[1].email, "ok@corp.test")
|
| 168 |
+
self.assertEqual(results[1].status, ValidationStatus.VALID)
|
| 169 |
+
|
| 170 |
+
async def test_batch_falls_back_to_single_when_provider_cannot_batch(self):
|
| 171 |
+
class SingleOnlyProvider:
|
| 172 |
+
def __init__(self):
|
| 173 |
+
self.calls = 0
|
| 174 |
+
|
| 175 |
+
async def verify(self, email, mx_host):
|
| 176 |
+
self.calls += 1
|
| 177 |
+
return SMTPResult(verified=True, smtp_code=250)
|
| 178 |
+
|
| 179 |
+
async def catch_all(self, domain, mx_host):
|
| 180 |
+
return (False, 0.9)
|
| 181 |
+
|
| 182 |
+
provider = SingleOnlyProvider()
|
| 183 |
+
engine = EmailValidationEngine(mailbox=provider)
|
| 184 |
+
with (
|
| 185 |
+
patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns_ok())),
|
| 186 |
+
patch("app.validator.engine.check_disposable", return_value=_disposable_clean()),
|
| 187 |
+
):
|
| 188 |
+
results = await engine.validate_batch(["a@corp.test", "b@corp.test"], deep=True)
|
| 189 |
+
|
| 190 |
+
self.assertEqual(len(results), 2)
|
| 191 |
+
self.assertEqual(provider.calls, 2)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class BulkRouteBatchTests(unittest.IsolatedAsyncioTestCase):
|
| 195 |
+
async def test_process_bulk_sync_uses_the_batched_path(self):
|
| 196 |
+
from app.api import routes
|
| 197 |
+
|
| 198 |
+
called = {}
|
| 199 |
+
|
| 200 |
+
async def fake_batch(emails, deep=True):
|
| 201 |
+
called["emails"] = list(emails)
|
| 202 |
+
called["deep"] = deep
|
| 203 |
+
|
| 204 |
+
class R:
|
| 205 |
+
def model_dump(self):
|
| 206 |
+
return {"email": "x"}
|
| 207 |
+
|
| 208 |
+
return [R() for _ in emails]
|
| 209 |
+
|
| 210 |
+
with patch.object(routes.engine, "validate_batch", new=AsyncMock(side_effect=fake_batch)):
|
| 211 |
+
out = await routes.process_bulk_sync(["a@b.com", "c@d.com"], deep=True)
|
| 212 |
+
|
| 213 |
+
self.assertEqual(called["emails"], ["a@b.com", "c@d.com"])
|
| 214 |
+
self.assertTrue(called["deep"])
|
| 215 |
+
self.assertEqual(len(out), 2)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
if __name__ == "__main__":
|
| 219 |
+
unittest.main()
|