Files
derp/plugins/emailcheck.py
user 35acc744ac fix: use DNS-over-HTTPS with provider rotation for emailcheck
Tor exit nodes poison plain DNS on port 53 and MITM some HTTPS
connections. Replace raw TCP DNS with DoH (Google, Cloudflare, Quad9)
and retry up to 5 times across providers to find a clean exit node.
MX results are now sorted by priority.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:40:43 +01:00

198 lines
6.1 KiB
Python

"""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 <email> [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 <email> [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)