"""Plugin: SMTP email verification via MX resolution and raw SMTP conversation.""" from __future__ import annotations import asyncio import json import logging import re import urllib.request from derp.http import create_connection from derp.http import urlopen as _urlopen from derp.plugin import command log = logging.getLogger(__name__) _MAX_BATCH = 5 _SMTP_TIMEOUT = 15 _DOH_PROVIDERS = [ "https://dns.google/resolve", "https://cloudflare-dns.com/dns-query", "https://dns.quad9.net:5053/dns-query", ] _DOH_ATTEMPTS = 5 _EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$") def _doh_query(name: str, qtype: str) -> list[dict]: """Query DNS-over-HTTPS via SOCKS5 proxy with provider rotation. Retries across multiple DoH providers to work around unreliable Tor exit nodes that drop or MITM HTTPS connections. """ last_err = None for attempt in range(_DOH_ATTEMPTS): provider = _DOH_PROVIDERS[attempt % len(_DOH_PROVIDERS)] url = f"{provider}?name={name}&type={qtype}" req = urllib.request.Request(url, headers={ "Accept": "application/dns-json", "User-Agent": "derp-bot", }) try: with _urlopen(req, timeout=10) as resp: data = json.loads(resp.read()) return data.get("Answer", []) except Exception as exc: last_err = exc log.debug("emailcheck: DoH attempt %d/%d failed (%s): %s", attempt + 1, _DOH_ATTEMPTS, provider, exc) raise last_err # type: ignore[misc] def _resolve_mx(domain: str) -> list[str]: """Resolve MX records via DNS-over-HTTPS. Returns list of MX hosts.""" answers = _doh_query(domain, "MX") if not answers: return [] # DoH MX data is "priority host." format mx_entries = [] for ans in answers: val = ans.get("data", "") parts = val.split(None, 1) if len(parts) == 2: mx_entries.append((int(parts[0]), parts[1].rstrip("."))) else: mx_entries.append((0, val.rstrip("."))) mx_entries.sort(key=lambda x: x[0]) return [host for _, host in mx_entries] def _resolve_a(domain: str) -> str | None: """Resolve A record via DNS-over-HTTPS as fallback.""" answers = _doh_query(domain, "A") if answers: return answers[0].get("data", "") return None def _smtp_check(email: str, mx_host: str) -> tuple[int, str]: """Raw SMTP conversation via SOCKS proxy. Returns (response_code, response_text).""" sock = create_connection((mx_host, 25), timeout=_SMTP_TIMEOUT) try: fp = sock.makefile("rb") def _read_reply() -> tuple[int, str]: lines = [] while True: line = fp.readline() if not line: break text = line.decode("utf-8", errors="replace").rstrip() lines.append(text) # Multi-line: "250-..." continues, "250 ..." ends if len(text) >= 4 and text[3] == " ": break if not lines: return 0, "no response" try: code = int(lines[-1][:3]) except (ValueError, IndexError): code = 0 return code, lines[-1][4:] if len(lines[-1]) > 4 else lines[-1] # Read banner code, text = _read_reply() if code != 220: return code, f"banner: {text}" # EHLO sock.sendall(b"EHLO derp.bot\r\n") code, text = _read_reply() if code != 250: return code, f"EHLO: {text}" # MAIL FROM sock.sendall(b"MAIL FROM:<>\r\n") code, text = _read_reply() if code != 250: return code, f"MAIL FROM: {text}" # RCPT TO sock.sendall(f"RCPT TO:<{email}>\r\n".encode()) code, text = _read_reply() # QUIT try: sock.sendall(b"QUIT\r\n") except OSError: pass return code, text finally: sock.close() @command("emailcheck", help="SMTP verify: !emailcheck [email2 ...]", admin=True) async def cmd_emailcheck(bot, message): """Check email deliverability via SMTP RCPT TO verification. Usage: !emailcheck user@example.com Single check !emailcheck user@example.com user2@test.org Batch (max 5) """ parts = message.text.split() if len(parts) < 2: await bot.reply(message, "Usage: !emailcheck [email2 ...]") return emails = parts[1:1 + _MAX_BATCH] # Validate format for email in emails: if not _EMAIL_RE.match(email): await bot.reply(message, f"Invalid email format: {email}") return loop = asyncio.get_running_loop() async def _check_one(email: str) -> str: domain = email.split("@", 1)[1] # Resolve MX try: mx_hosts = await loop.run_in_executor(None, _resolve_mx, domain) except Exception as exc: log.debug("emailcheck: MX lookup failed for %s: %s", domain, exc) mx_hosts = [] # Fallback to A record if not mx_hosts: try: a_record = await loop.run_in_executor(None, _resolve_a, domain) except Exception: a_record = None if a_record: mx_hosts = [domain] else: return f"{email} -- no MX or A record for {domain}" # Try each MX host for mx in mx_hosts: try: code, text = await loop.run_in_executor(None, _smtp_check, email, mx) return f"{email} -- SMTP {code} {text} (mx: {mx})" except Exception as exc: log.debug("emailcheck: SMTP failed for %s via %s: %s", email, mx, exc) continue return f"{email} -- all MX hosts unreachable" if len(emails) > 1: await bot.reply(message, f"Checking {len(emails)} addresses...") results = await asyncio.gather(*[_check_one(e) for e in emails]) for line in results: await bot.reply(message, line)