scripts/emailer.py
scripts/emailer.pyBrowse 56 files
3,579 tokens
14,757 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1"""Programmatic email (Mode B) via stdlib smtplib/imaplib - no human in the loop.2 3This is what turns email opt-outs autonomous: `send()` delivers the rendered4request straight to the broker's known opt-out address, and `find_verification_link()`5polls the inbox for the broker's confirmation email and extracts the link (scored6by email_modes.extract_verification_link, so arbitrary/phishing links are ignored).7The agent still OPENS the link with its own browser - several brokers bind the8verification session to the browser that opens it (see the intelius record).9 10Configuration comes from the same env vars the Hermes email gateway uses:11 EMAIL_ADDRESS / EMAIL_PASSWORD (required for Mode B)12 EMAIL_SMTP_HOST / EMAIL_SMTP_PORT (optional; inferred for common providers)13 EMAIL_IMAP_HOST / EMAIL_IMAP_PORT (optional; inferred for common providers)14 15Anti-misuse: `send()` refuses a recipient that is not the broker record's own16opt-out/privacy address - this module cannot be repurposed to email arbitrary people.17All network calls live behind small functions that the hermetic tests monkeypatch.18"""19from __future__ import annotations20 21import email as _email22import email.utils23import imaplib24import json25import os26import re27import smtplib28import time29from email.message import EmailMessage30from pathlib import Path31 32import email_modes33import paths34 35# provider domain -> (smtp_host, smtp_port, imap_host, imap_port)36PROVIDERS = {37 "gmail.com": ("smtp.gmail.com", 587, "imap.gmail.com", 993),38 "googlemail.com": ("smtp.gmail.com", 587, "imap.gmail.com", 993),39 "outlook.com": ("smtp-mail.outlook.com", 587, "outlook.office365.com", 993),40 "hotmail.com": ("smtp-mail.outlook.com", 587, "outlook.office365.com", 993),41 "live.com": ("smtp-mail.outlook.com", 587, "outlook.office365.com", 993),42 "yahoo.com": ("smtp.mail.yahoo.com", 587, "imap.mail.yahoo.com", 993),43 "icloud.com": ("smtp.mail.me.com", 587, "imap.mail.me.com", 993),44 "me.com": ("smtp.mail.me.com", 587, "imap.mail.me.com", 993),45 "fastmail.com": ("smtp.fastmail.com", 587, "imap.fastmail.com", 993),46}47 48 49def _domain(address: str) -> str:50 return address.rsplit("@", 1)[-1].lower() if "@" in address else ""51 52 53def smtp_settings(env: dict | None = None) -> dict | None:54 """SMTP connection settings, or None when sending is not configured."""55 env = os.environ if env is None else env56 address, password = env.get("EMAIL_ADDRESS"), env.get("EMAIL_PASSWORD")57 if not (address and password):58 return None59 inferred = PROVIDERS.get(_domain(address))60 host = env.get("EMAIL_SMTP_HOST") or (inferred[0] if inferred else None)61 if not host:62 return None # unknown provider and no explicit host63 port = int(env.get("EMAIL_SMTP_PORT") or (inferred[1] if inferred else 587))64 return {"host": host, "port": port, "address": address, "password": password}65 66 67def imap_settings(env: dict | None = None) -> dict | None:68 """IMAP connection settings, or None when inbox reading is not configured."""69 env = os.environ if env is None else env70 address, password = env.get("EMAIL_ADDRESS"), env.get("EMAIL_PASSWORD")71 if not (address and password):72 return None73 inferred = PROVIDERS.get(_domain(address))74 host = env.get("EMAIL_IMAP_HOST") or (inferred[2] if inferred else None)75 if not host:76 return None77 port = int(env.get("EMAIL_IMAP_PORT") or (inferred[3] if inferred else 993))78 return {"host": host, "port": port, "address": address, "password": password}79 80 81def available(env: dict | None = None) -> dict:82 return {"smtp": smtp_settings(env) is not None, "imap": imap_settings(env) is not None}83 84 85# --- sending ------------------------------------------------------------------86 87def broker_addresses(broker: dict) -> list[str]:88 """Every address the broker record itself declares (the ONLY valid recipients).89 90 Includes the primary opt-out email, the right-to-delete lane's email91 (optout.deletion.email), and any mailto: links parsed from BADBOOL.92 """93 opt = broker.get("optout") or {}94 out = [a for a in [opt.get("email"), (opt.get("deletion") or {}).get("email")] if a]95 for link in opt.get("links") or []:96 url = (link.get("url") or "")97 if url.lower().startswith("mailto:"):98 out.append(url[7:].split("?")[0])99 seen: set[str] = set()100 deduped = []101 for a in out:102 if a.lower() not in seen:103 seen.add(a.lower())104 deduped.append(a)105 return deduped106 107 108def _split_subject_body(text: str) -> tuple[str, str]:109 """Templates start with a 'Subject: ...' line; split it out for the MIME header."""110 lines = text.splitlines()111 if lines and lines[0].lower().startswith("subject:"):112 return lines[0].split(":", 1)[1].strip(), "\n".join(lines[1:]).lstrip("\n")113 return "Data removal request", text114 115 116def browser_send_payload(broker: dict, body_text: str, to: str | None = None) -> dict:117 """Build a recipient-locked {to, subject, body} for the agent to send via browser webmail.118 119 No network and no credentials: the deterministic part (recipient-lock to the broker's own120 declared address, subject/body split) happens here; the agent then composes and sends it in121 the operator's logged-in webmail with browser_* tools. Same recipient guard as `send()`, so122 the browser lane cannot be pointed at an arbitrary person either.123 """124 allowed = broker_addresses(broker)125 if not allowed:126 raise RuntimeError(f"broker {broker.get('id')!r} declares no opt-out email address")127 recipient = to or allowed[0]128 if recipient.lower() not in {a.lower() for a in allowed}:129 raise PermissionError(130 f"refusing to target {recipient!r}: not an address the broker record declares "131 f"(allowed: {allowed})"132 )133 subject, body = _split_subject_body(body_text)134 return {"to": recipient, "subject": subject, "body": body}135 136 137def _rate_limit_path() -> Path:138 return paths.data_dir() / "email-rate.json"139 140 141def _respect_rate_limit(min_interval: float, sleep, now, state_path=None) -> None:142 """Pace sends across CLI invocations so a run can't torch the sending account.143 144 Persists the last-send wall-clock time; if the next send is too soon, sleep the145 remainder. Cross-process because each `send-email` is a separate invocation.146 """147 if min_interval <= 0:148 return149 p = state_path or _rate_limit_path()150 last = 0.0151 try:152 last = float(json.loads(p.read_text(encoding="utf-8")).get("last", 0.0))153 except (OSError, ValueError, TypeError):154 last = 0.0155 wait = min_interval - (now() - last)156 if wait > 0:157 sleep(min(wait, min_interval))158 try:159 p.parent.mkdir(parents=True, exist_ok=True)160 p.write_text(json.dumps({"last": now()}), encoding="utf-8")161 except OSError:162 pass163 164 165# SMTP errors that are permanent (don't retry) vs transient (retry with backoff).166_SMTP_PERMANENT = (smtplib.SMTPAuthenticationError, smtplib.SMTPRecipientsRefused,167 smtplib.SMTPSenderRefused, smtplib.SMTPDataError)168 169 170def send(broker: dict, body_text: str, to: str | None = None,171 env: dict | None = None, _smtp_factory=None,172 min_interval: float = 0.0, max_retries: int = 3,173 _sleep=time.sleep, _now=time.time, _rate_state=None) -> dict:174 """Send an opt-out/legal request to the broker's own opt-out address.175 176 Recipient is locked to an address the broker record declares (PermissionError177 otherwise). `min_interval` paces sends across invocations (deliverability /178 account-safety); transient SMTP/socket failures retry with exponential backoff,179 permanent ones (auth, recipient refused) raise immediately. NOTE: a successful180 SMTP handoff is NOT proof of delivery - real bounces arrive later as inbound mail;181 in programmatic mode `poll-verification`/inbox review surfaces them, and the182 due-queue re-scan is the true confirmation. Returns send metadata.183 """184 settings = smtp_settings(env)185 if not settings:186 raise RuntimeError(187 "programmatic email not configured (need EMAIL_ADDRESS + EMAIL_PASSWORD, and "188 "EMAIL_SMTP_HOST for non-mainstream providers); fall back to `render-email` drafts"189 )190 allowed = broker_addresses(broker)191 if not allowed:192 raise RuntimeError(f"broker {broker.get('id')!r} declares no opt-out email address")193 recipient = to or allowed[0]194 if recipient.lower() not in {a.lower() for a in allowed}:195 raise PermissionError(196 f"refusing to send to {recipient!r}: not an address the broker record declares "197 f"(allowed: {allowed})"198 )199 200 subject, body = _split_subject_body(body_text)201 msg = EmailMessage()202 msg["From"] = settings["address"]203 msg["To"] = recipient204 msg["Subject"] = subject205 msg["Date"] = email.utils.formatdate(localtime=True)206 msg["Message-ID"] = email.utils.make_msgid()207 msg.set_content(body)208 209 _respect_rate_limit(min_interval, _sleep, _now, _rate_state)210 211 factory = _smtp_factory or smtplib.SMTP212 attempts = 0213 while True:214 attempts += 1215 try:216 with factory(settings["host"], settings["port"], timeout=30) as smtp:217 smtp.ehlo()218 try:219 smtp.starttls()220 smtp.ehlo()221 except smtplib.SMTPNotSupportedError:222 pass # already-TLS ports / test doubles223 smtp.login(settings["address"], settings["password"])224 smtp.send_message(msg)225 break226 except _SMTP_PERMANENT:227 raise # auth / recipient refused: retrying won't help228 except (smtplib.SMTPException, OSError) as exc:229 if attempts > max_retries:230 raise RuntimeError(f"SMTP send failed after {attempts} attempts: {exc}") from exc231 _sleep(min(2 ** (attempts - 1), 30)) # 1s, 2s, 4s... capped232 return {"to": recipient, "subject": subject, "message_id": msg["Message-ID"],233 "from": settings["address"], "attempts": attempts,234 "delivery_note": "SMTP accepted; not proof of delivery - a bounce would arrive as "235 "inbound mail. The due-queue re-scan is the real confirmation."}236 237 238# --- inbox polling ------------------------------------------------------------239 240def _decode_part(part) -> str:241 try:242 payload = part.get_payload(decode=True)243 if payload is None:244 return ""245 charset = part.get_content_charset() or "utf-8"246 return payload.decode(charset, errors="replace")247 except Exception: # noqa: BLE001 - malformed MIME must not kill the poll248 return ""249 250 251def message_text(msg) -> str:252 """All text/plain + text/html content of a parsed email message."""253 chunks: list[str] = []254 if msg.is_multipart():255 for part in msg.walk():256 if part.get_content_type() in ("text/plain", "text/html"):257 chunks.append(_decode_part(part))258 else:259 chunks.append(_decode_part(msg))260 return "\n".join(c for c in chunks if c)261 262 263def _broker_domains(broker: dict) -> list[str]:264 """Domains this broker legitimately mails from (site domains + optout email domain)."""265 domains: list[str] = []266 for section in ("optout", "search"):267 url = ((broker.get(section) or {}).get("url")) or ""268 m = re.search(r"https?://([^/]+)", url)269 if m:270 domains.append(m.group(1).lower().removeprefix("www."))271 opt_email = (broker.get("optout") or {}).get("email")272 if opt_email and "@" in opt_email:273 domains.append(_domain(opt_email))274 # strip subdomains to the registrable-ish tail (mailer.intelius.com -> intelius.com)275 tails = {".".join(d.split(".")[-2:]) for d in domains if d}276 return sorted(tails)277 278 279def fetch_recent(env: dict | None = None, since_days: int = 3, limit: int = 30,280 _imap_factory=None) -> list[dict]:281 """Fetch recent inbox messages: [{from, subject, date, text}], newest first."""282 settings = imap_settings(env)283 if not settings:284 raise RuntimeError("IMAP not configured (need EMAIL_ADDRESS + EMAIL_PASSWORD, and "285 "EMAIL_IMAP_HOST for non-mainstream providers)")286 import datetime as _dt287 since = (_dt.date.today() - _dt.timedelta(days=max(0, since_days))).strftime("%d-%b-%Y")288 289 factory = _imap_factory or imaplib.IMAP4_SSL290 conn = factory(settings["host"], settings["port"])291 try:292 conn.login(settings["address"], settings["password"])293 conn.select("INBOX", readonly=True)294 _typ, data = conn.search(None, "SINCE", since)295 ids = (data[0].split() if data and data[0] else [])[-limit:]296 out: list[dict] = []297 for mid in reversed(ids): # newest first298 _typ, msg_data = conn.fetch(mid, "(RFC822)")299 raw = next((p[1] for p in msg_data or [] if isinstance(p, tuple)), None)300 if not raw:301 continue302 msg = _email.message_from_bytes(raw)303 out.append({304 "from": msg.get("From", ""),305 "subject": msg.get("Subject", ""),306 "date": msg.get("Date", ""),307 "text": message_text(msg),308 })309 return out310 finally:311 try:312 conn.logout()313 except Exception: # noqa: BLE001314 pass315 316 317def link_from_messages(messages: list[dict], broker: dict) -> dict | None:318 """Pure: find the broker's verification link in already-fetched messages.319 320 A message is only considered if its From domain OR any contained link matches321 the broker's own domains; the link itself must pass the anti-phishing scorer.322 """323 domains = _broker_domains(broker)324 for m in messages:325 sender = (m.get("from") or "").lower()326 text = m.get("text") or ""327 sender_match = any(d in sender for d in domains)328 body_match = any(d in text.lower() for d in domains)329 if not (sender_match or body_match):330 continue331 link = email_modes.extract_verification_link(text, broker)332 if link:333 return {"link": link, "from": m.get("from"), "subject": m.get("subject"),334 "date": m.get("date")}335 return None336 337 338def find_verification_link(broker: dict, env: dict | None = None, since_days: int = 3,339 _imap_factory=None) -> dict | None:340 """Poll the inbox and return the broker's verification link (or None yet)."""341 messages = fetch_recent(env, since_days=since_days, _imap_factory=_imap_factory)342 return link_from_messages(messages, broker)343