modul-x / app /validator /smtp_client.py
sitcod3's picture
Upload folder using huggingface_hub
f5afa05 verified
Raw History Blame Contribute Delete
6.96 kB
"""Low-level SMTP client for mailbox probing (RFC 5321).
This is the core of the autonomous engine. Unlike a one-shot probe that opens a
TCP connection per address, `SmtpConnection` keeps a single session to a
receiving MX open and issues many `RCPT TO` probes over it (RSET between each).
Remote mail servers limit how often you may connect, and the TCP+TLS handshake
dominates cost — so connection reuse is what makes deep verification of a 10k
list feasible in minutes instead of hours.
No message is ever transmitted: the dialogue stops after RCPT and issues RSET,
so nothing can be delivered or marked as spam.
Every failure mode is returned as data (`RcptOutcome`), never raised, so one
dead host cannot abort a bulk job.
"""
from __future__ import annotations
import socket
from dataclasses import dataclass, field
from typing import Callable, Optional
from .mx_intel import is_greylist_code, is_permanent_reject, parse_rcpt_code
CRLF = "\r\n"
@dataclass
class RcptOutcome:
"""Result of one RCPT TO probe."""
code: Optional[int] = None
message: str = ""
transport_error: bool = False
error: Optional[str] = None
@property
def accepted(self) -> bool:
return self.code == 250
@property
def permanent_reject(self) -> bool:
return is_permanent_reject(self.code)
@property
def retryable(self) -> bool:
return self.transport_error or is_greylist_code(self.code)
def _default_connect(host: str, port: int, timeout: float):
sock = socket.create_connection((host, port), timeout=timeout)
sock.settimeout(timeout)
return sock
class SmtpConnection:
"""A single reusable SMTP session against one receiving host."""
def __init__(
self,
host: str,
*,
port: int = 25,
timeout: float = 10.0,
helo_domain: str = "validator.local",
mail_from: str = "probe@validator.local",
connect: Optional[Callable[[str, int, float], object]] = None,
):
self.host = host
self.port = port
self.timeout = timeout
self.helo_domain = helo_domain
self.mail_from = mail_from
self._connect = connect or _default_connect
self._sock = None
self.ready = False
self.server_code: Optional[int] = None
self.banner: str = ""
self._supports_pipelining = False
# ---- transport -------------------------------------------------
def open(self) -> bool:
"""Connect + banner + EHLO. Returns True when the session is usable."""
try:
self._sock = self._connect(self.host, self.port, self.timeout)
except Exception as exc: # connection refused / blocked / DNS
self._sock = None
self.banner = f"connect failed: {exc}"
self.ready = False
return False
try:
code, text = self._read_response()
self.banner = text
if code != 220:
self.ready = False
return False
self._send(f"EHLO {self.helo_domain}")
code, text = self._read_response()
if code != 250:
# Fall back to HELO for ancient servers.
self._send(f"HELO {self.helo_domain}")
code, text = self._read_response()
if code != 250:
self.ready = False
return False
self._supports_pipelining = "PIPELINING" in text.upper()
self.server_code = code
self.ready = True
return True
except Exception as exc:
self.banner = f"handshake failed: {exc}"
self.ready = False
return False
def _send(self, line: str) -> None:
if self._sock is None:
raise OSError("socket closed")
self._sock.sendall((line + CRLF).encode("utf-8"))
def _read_response(self) -> tuple[Optional[int], str]:
"""Read one (possibly multiline) SMTP response.
Multiline replies use `250-first`, `250-more`, `250 last` — the code is
repeated on every line and the final line has a space after the code.
Reading only one line would desynchronise the whole session.
"""
if self._sock is None:
raise OSError("socket closed")
lines: list[str] = []
while True:
raw = self._sock.recv(4096)
if not raw:
raise OSError("connection closed by peer")
text = raw.decode("utf-8", "replace")
for line in text.split(CRLF):
if not line:
continue
lines.append(line)
# "250-last" continues, "250 last" or a short line ends.
if len(line) >= 4 and line[3] == "-":
continue
joined = "\n".join(lines)
return parse_rcpt_code(line), joined
# A chunk may end mid-line; keep reading.
if lines and len(lines[-1]) >= 4 and lines[-1][3] == " ":
joined = "\n".join(lines)
return parse_rcpt_code(lines[-1]), joined
# ---- probing ---------------------------------------------------
def probe(self, email: str) -> RcptOutcome:
"""Ask the receiving server whether `email` is accepted."""
if not self.ready or self._sock is None:
return RcptOutcome(
transport_error=True, error="session not ready", retryable=True
)
try:
self._send(f"MAIL FROM:<{self.mail_from}>")
code, text = self._read_response()
if code is None or code >= 400:
return RcptOutcome(
code=code,
message=text,
transport_error=True,
error=f"MAIL FROM rejected: {text[:120]}",
)
self._send(f"RCPT TO:<{email}>")
code, text = self._read_response()
# Always reset so the next probe starts clean; never send DATA.
try:
self._send("RSET")
self._read_response()
except Exception:
pass
return RcptOutcome(code=code, message=text)
except Exception as exc:
return RcptOutcome(transport_error=True, error=str(exc))
def quit(self) -> None:
try:
if self._sock is not None:
self._send("QUIT")
except Exception:
pass
finally:
self.close()
def close(self) -> None:
if self._sock is not None:
try:
self._sock.close()
except Exception:
pass
self._sock = None
self.ready = False
def __enter__(self) -> "SmtpConnection":
self.open()
return self
def __exit__(self, *exc) -> None:
self.quit()