sitcod3 commited on
Commit
45aeb2f
·
verified ·
1 Parent(s): f5afa05

Upload folder using huggingface_hub

Browse files
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
- tasks = [engine.validate(email, deep=deep) for email in chunk]
36
- chunk_results = await asyncio.gather(*tasks, return_exceptions=True)
37
-
38
- for email, result in zip(chunk, chunk_results):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  if isinstance(result, ValidationResult):
40
  results.append(result.model_dump())
41
  else:
42
- # Handle exceptions
43
- results.append({
44
- "email": email,
45
- "error": str(result) if result else "Unknown error",
46
- "status": "error"
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
- for i, email in enumerate(emails):
 
 
 
 
56
  try:
57
- result = await eng.validate(email, deep=deep)
58
- results.append(result.model_dump())
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 item {i + 1}/{total}: "
67
- f"{e.__class__.__name__}",
68
  file=sys.stderr,
69
  )
70
- results.append({
71
- "email": email,
72
- "error": str(e),
73
- "status": "error"
74
- })
 
 
75
 
76
  # Persist incremental progress to Supabase (best-effort)
77
- if (i + 1) % 10 == 0 or i + 1 == total:
78
- try:
79
- repo.update_bulk_job_progress(self.request.id, i + 1, total)
80
- except Exception as e:
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
- captured = []
 
43
 
44
- async def fake_validate(email, deep=True):
45
- captured.append(deep)
46
- from app.models import ValidationStatus, ScoringResult, RiskLevel
47
 
48
- return type("R", (), {"model_dump": lambda self: {"email": email}})()
 
49
 
50
- with (
51
- patch("app.api.routes.engine.validate", new=AsyncMock(side_effect=lambda e, deep=True: fake_validate(e, deep))),
52
- patch("app.api.routes.settings.bulk_sync_chunk_size", 50),
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
- return FakeResult()
63
-
64
- with patch("app.api.routes.engine") as eng:
65
- eng.validate = AsyncMock(side_effect=capture_validate)
66
- await process_bulk_sync(["a@example.com", "b@example.com"])
67
 
68
- self.assertTrue(len(captured) >= 1)
69
- self.assertTrue(all(d is True for d in captured), f"bulk ran with deep flags: {captured}")
 
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()