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()