"""Plugin: SMTP email verification via MX resolution and raw SMTP conversation.""" from __future__ import annotations import asyncio import logging import re import socket from derp.dns import QTYPES, build_query, parse_response from derp.http import create_connection from derp.plugin import command log = logging.getLogger(__name__) _MAX_BATCH = 5 _SMTP_TIMEOUT = 15 _EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$") # Tor DNS resolver for MX lookups _DNS_ADDR = "10.200.1.13" _DNS_PORT = 9053 def _resolve_mx(domain: str) -> list[str]: """Resolve MX records for a domain via Tor DNS. Returns list of MX hosts.""" query = build_query(domain, QTYPES["MX"]) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(5) try: sock.sendto(query, (_DNS_ADDR, _DNS_PORT)) data, _ = sock.recvfrom(4096) finally: sock.close() rcode, results = parse_response(data) if rcode != 0 or not results: return [] # MX results are "priority host" format mx_hosts = [] for r in results: parts = r.split(None, 1) if len(parts) == 2: mx_hosts.append(parts[1].rstrip(".")) else: mx_hosts.append(r.rstrip(".")) # Sort by priority (already parsed as "prio host") return mx_hosts def _resolve_a(domain: str) -> str | None: """Resolve A record for a domain as fallback. Returns first IP or None.""" query = build_query(domain, QTYPES["A"]) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(5) try: sock.sendto(query, (_DNS_ADDR, _DNS_PORT)) data, _ = sock.recvfrom(4096) finally: sock.close() rcode, results = parse_response(data) if rcode == 0 and results: return results[0] 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)