File size: 12,998 Bytes
fd7800b de745bf fd7800b de745bf fd7800b f5afa05 45aeb2f 03c7b2c 45aeb2f f5afa05 de745bf fd7800b de745bf fd7800b de745bf fd7800b de745bf fd7800b de745bf fd7800b de745bf f5afa05 de745bf f5afa05 fd7800b de745bf fd7800b f5afa05 de745bf f5afa05 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 | """Mailbox verification providers (Layer 5 catch-all + Layer 6 SMTP).
The engine is FULLY AUTONOMOUS (v3.0.0): mailbox verdicts come from its own
SMTP probing engine β no third-party verification API, no vendor key, no
per-check fee. The former MillionVerifier delegation was removed by design
decision: a stray key must never be able to re-route verification data (and
the full recipient list) through a vendor.
resolve_provider() picks the mailbox layer from settings:
mailbox_provider = "auto" β remote SMTP worker when SMTP_WORKER_URL is
configured, else the built-in engine
mailbox_provider = "local" β built-in SMTP engine (this host)
mailbox_provider = "remote" β the remote SMTP probe worker (spec Β§3.3)
RemoteSmtpWorkerProvider implements the four-worker architecture: the
orchestrator (this app) delegates the port-25 dialogue to a separate
deployment of the same image running on a host whose egress allows it
(see POST /api/v1/smtp/probe in routes.py). A shared secret
(SMTP_WORKER_TOKEN) guards the worker route.
Both providers embed a circuit breaker so a host with blocked port-25 egress
(or a dead worker) fails fast instead of burning the full SMTP timeout on
every address in a bulk job.
"""
from __future__ import annotations
import asyncio
import random
import time
from typing import Optional, Tuple
import httpx
from ..config import settings
from ..models import SMTPResult
from .smtp_verify import GIBBERISH_LOCAL, smtp_verifier
class MailboxProvider:
"""Common interface every mailbox provider implements."""
name = "base"
async def verify(self, email: str, mx_host: str) -> SMTPResult:
raise NotImplementedError
async def catch_all(self, domain: str, mx_host: str) -> Tuple[Optional[bool], float]:
raise NotImplementedError
class AutonomousSmtpProvider(MailboxProvider):
"""The self-contained engine: own SMTP probing, no third-party service.
Wraps the MX-grouped batch verifier (reused sessions, per-domain catch-all
caching, greylist awareness). This is the default provider β the validator
must work with no vendor account, key, or per-check fee.
"""
name = "autonomous"
def __init__(self, batch=None):
if batch is None:
from .mx_batch import CatchAllCache, MxBatchVerifier
batch = MxBatchVerifier(
timeout=settings.smtp_timeout,
helo_domain=settings.smtp_helo_domain,
mail_from=settings.smtp_mail_from,
catch_all_cache=CatchAllCache(
ttl_seconds=settings.catch_all_ttl_seconds
),
)
self.batch = batch
async def verify(self, email: str, mx_host: str) -> SMTPResult:
results = self.batch.verify_batch([(email, mx_host)])
return results.get(email) or SMTPResult(
verified=None, error="engine returned no result"
)
async def catch_all(
self, domain: str, mx_host: str
) -> Tuple[Optional[bool], float]:
"""Probe a gibberish local part to learn whether the domain accepts all."""
gibberish = f"{GIBBERISH_LOCAL}@{domain}"
results = self.batch.verify_batch([(gibberish, mx_host)])
probe = results.get(gibberish)
if probe is None:
return None, 0.0
if probe.verified is True:
# The domain accepted a random address -> catch-all.
return True, 0.9
if probe.verified is False:
return False, 0.9
# Inconclusive: distinguish a detected catch-all from a dead probe.
err = (probe.error or "").lower()
if "catch_all" in err:
return True, 0.95
return None, 0.0
async def verify_many(self, pairs):
"""Batched mailbox stage: one call for the whole list.
Returns (email -> SMTPResult, domain -> catch-all flag). This is what
lets the engine do one TCP session per domain instead of one per address.
"""
pairs = [(e, mx) for e, mx in pairs if e and mx]
if not pairs:
return {}, {}
# Prefer the concurrent path when the batch engine supports it.
if hasattr(self.batch, "verify_batch_async"):
results = await self.batch.verify_batch_async(pairs)
else:
results = self.batch.verify_batch(pairs)
domains = {e.rsplit("@", 1)[-1].lower(): mx for e, mx in pairs}
catch_all = {
domain: self.batch.catch_all_cache.get(domain) for domain in domains
}
return results, catch_all
class RemoteSmtpWorkerProvider(MailboxProvider):
"""Delegates the port-25 dialogue to a remote SMTP probe worker (Β§3.3).
The worker is any deployment of this same image whose SMTP_WORKER_URL this
orchestrator can reach. Egress requirement moves to the worker host; the
orchestrator stays PaaS-deployable.
"""
name = "remote"
def __init__(
self,
url: Optional[str] = None,
token: Optional[str] = None,
timeout: Optional[int] = None,
fail_threshold: Optional[int] = None,
cooldown_seconds: Optional[int] = None,
):
self.url = (url if url is not None else settings.smtp_worker_url).rstrip("/")
self.token = token if token is not None else settings.smtp_worker_token
self.timeout = timeout if timeout is not None else settings.smtp_worker_timeout
self.fail_threshold = (
fail_threshold if fail_threshold is not None else settings.smtp_fail_threshold
)
self.cooldown = (
cooldown_seconds
if cooldown_seconds is not None
else settings.smtp_cooldown_seconds
)
self._consecutive_failures = 0
self._open_until = 0.0
# ---- circuit breaker (same contract as LocalSmtpProvider) --------------
@property
def circuit_open(self) -> bool:
return time.monotonic() < self._open_until
def record_failure(self) -> None:
self._consecutive_failures += 1
if self._consecutive_failures >= self.fail_threshold:
self._open_until = time.monotonic() + self.cooldown
def record_success(self) -> None:
self._consecutive_failures = 0
self._open_until = 0.0
def _headers(self) -> dict:
h = {"Content-Type": "application/json"}
if self.token:
h["X-Worker-Token"] = self.token
return h
async def _post(self, path: str, payload: dict):
async with httpx.AsyncClient(timeout=self.timeout + 5) as client:
resp = await client.post(
f"{self.url}/smtp{path}", json=payload, headers=self._headers()
)
resp.raise_for_status()
return resp.json()
async def verify(self, email: str, mx_host: str) -> SMTPResult:
if self.circuit_open:
return SMTPResult(
verified=None, error="SMTP worker unavailable (circuit open)"
)
try:
data = await self._post(
"/probe", {"email": email, "mx_host": mx_host}
)
result = SMTPResult(**data)
except Exception as exc:
self.record_failure()
return SMTPResult(
verified=None, error=f"smtp_worker_unreachable: {exc}"
)
self.record_success()
return result
async def catch_all(
self, domain: str, mx_host: str
) -> Tuple[Optional[bool], float]:
if self.circuit_open:
return None, 0.0
try:
data = await self._post(
"/probe", {"domain": domain, "mx_host": mx_host, "probe": "catch_all"}
)
except Exception:
self.record_failure()
return None, 0.0
self.record_success()
value = data.get("is_catch_all")
confidence = float(data.get("confidence", 0.0) or 0.0)
if value is None or confidence == 0.0:
return None, 0.0
return bool(value), confidence
async def verify_many(self, pairs):
pairs = [(e, mx) for e, mx in pairs if e and mx]
if not pairs:
return {}, {}
if self.circuit_open:
return (
{
e: SMTPResult(
verified=None, error="SMTP worker unavailable (circuit open)"
)
for e, _ in pairs
},
{},
)
try:
data = await self._post(
"/probe/batch", {"pairs": [[e, mx] for e, mx in pairs]}
)
results = {
e: SMTPResult(**row) for e, row in (data.get("results") or {}).items()
}
catch_all = {d: v for d, v in (data.get("catch_all") or {}).items()}
self.record_success()
return results, catch_all
except Exception as exc:
self.record_failure()
return (
{
e: SMTPResult(
verified=None, error=f"smtp_worker_unreachable: {exc}"
)
for e, _ in pairs
},
{},
)
class LocalSmtpProvider(MailboxProvider):
"""Direct port-25 SMTP (one dialogue per call) with a circuit breaker.
Kept as the building block for single-probe paths and tests; bulk traffic
goes through AutonomousSmtpProvider's session-reusing batch engine.
"""
name = "local"
def __init__(
self,
fail_threshold: Optional[int] = None,
cooldown_seconds: Optional[int] = None,
):
self.fail_threshold = (
fail_threshold if fail_threshold is not None else settings.smtp_fail_threshold
)
self.cooldown = (
cooldown_seconds if cooldown_seconds is not None else settings.smtp_cooldown_seconds
)
self._consecutive_failures = 0
self._open_until = 0.0
@property
def circuit_open(self) -> bool:
return time.monotonic() < self._open_until
def circuit_open_after_cooldown(self) -> bool:
"""True only while still within the cooldown window (0s β closed)."""
return time.monotonic() < self._open_until
def record_failure(self) -> None:
self._consecutive_failures += 1
if self._consecutive_failures >= self.fail_threshold:
self._open_until = time.monotonic() + self.cooldown
def record_success(self) -> None:
self._consecutive_failures = 0
self._open_until = 0.0
async def verify(self, email: str, mx_host: str) -> SMTPResult:
if self.circuit_open:
return SMTPResult(
verified=None, error="SMTP unavailable (egress circuit open)"
)
result = await smtp_verifier.verify(email, mx_host, settings.use_proxies)
# A connection-class failure (timeout/refused/unreachable) counts toward
# opening the circuit; a real SMTP dialogue does not.
if result.verified is None and result.error and any(
k in result.error.lower()
for k in ("timeout", "timed out", "refused", "unreachable", "unavailable")
):
self.record_failure()
elif result.smtp_code is not None or result.verified is not None:
self.record_success()
return result
async def catch_all(self, domain: str, mx_host: str) -> Tuple[Optional[bool], float]:
if self.circuit_open:
return None, 0.0
try:
is_catch_all, confidence = await smtp_verifier.check_catch_all(domain, mx_host)
except Exception:
return None, 0.0
# check_catch_all returns (False, 0.0) when the probe is inconclusive β
# that means UNKNOWN, not "not catch-all". Confidence 0.0 is the signal.
if confidence == 0.0:
self.record_failure()
return None, 0.0
self.record_success()
return is_catch_all, confidence
def resolve_provider() -> MailboxProvider:
"""Pick the mailbox layer.
Fully autonomous by default: the built-in SMTP engine answers with no
external dependency. A configured SMTP_WORKER_URL shifts the port-25
dialogue to the dedicated worker host β nothing else changes.
"""
mode = (settings.mailbox_provider or "auto").strip().lower()
if mode == "remote":
if settings.smtp_worker_url:
return RemoteSmtpWorkerProvider()
# Misconfiguration must not crash the API: degrade to the built-in
# engine, which reports honest transport failures.
return AutonomousSmtpProvider()
if mode == "local":
return AutonomousSmtpProvider()
# auto β remote worker when configured, else built-in.
if settings.smtp_worker_url:
return RemoteSmtpWorkerProvider()
return AutonomousSmtpProvider()
|