mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-16 14:32:34 +00:00
unbroker finds where a consenting person's info is exposed across data brokers and people-search sites and files the removals, running as far as each site allows and handing only genuinely human-only steps (hard CAPTCHA, gov-ID, phone, fax) back as an end-of-run digest. - Deterministic stdlib CLI (scripts/pdd.py) owns config, dossiers+consent, the broker DB, tier planning, the ledger, email, and the autonomous action queue; the agent scans/submits with native tools (web_extract, browser_*, delegate_task, cronjob, terminal). - Verify-before-disclose, least-disclosure (never volunteers SSN), consent gate, opaque ids, optional age-at-rest encryption, file-locked ledger. - Jurisdiction-aware (CCPA/CPRA, GDPR, generic); CA DROP one-shot covers the state registry (~545) in a single request; BADBOOL + curated people-search coverage; scheduled re-scan for re-listing. - No CAPTCHA-solving services or anti-bot bypass; browser email mode needs no stored password. - 85 hermetic tests (tests/skills/test_unbroker_skill.py; SMTP/IMAP via injected fakes, registry via CSV fixtures). Ships placeholder data only. Broker dataset adapted from BADBOOL (Yael Grauer, CC BY-NC-SA 4.0).
342 lines
14 KiB
Python
342 lines
14 KiB
Python
"""Programmatic email (Mode B) via stdlib smtplib/imaplib - no human in the loop.
|
|
|
|
This is what turns email opt-outs autonomous: `send()` delivers the rendered
|
|
request straight to the broker's known opt-out address, and `find_verification_link()`
|
|
polls the inbox for the broker's confirmation email and extracts the link (scored
|
|
by email_modes.extract_verification_link, so arbitrary/phishing links are ignored).
|
|
The agent still OPENS the link with its own browser - several brokers bind the
|
|
verification session to the browser that opens it (see the intelius record).
|
|
|
|
Configuration comes from the same env vars the Hermes email gateway uses:
|
|
EMAIL_ADDRESS / EMAIL_PASSWORD (required for Mode B)
|
|
EMAIL_SMTP_HOST / EMAIL_SMTP_PORT (optional; inferred for common providers)
|
|
EMAIL_IMAP_HOST / EMAIL_IMAP_PORT (optional; inferred for common providers)
|
|
|
|
Anti-misuse: `send()` refuses a recipient that is not the broker record's own
|
|
opt-out/privacy address - this module cannot be repurposed to email arbitrary people.
|
|
All network calls live behind small functions that the hermetic tests monkeypatch.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import email as _email
|
|
import email.utils
|
|
import imaplib
|
|
import json
|
|
import os
|
|
import re
|
|
import smtplib
|
|
import time
|
|
from email.message import EmailMessage
|
|
from pathlib import Path
|
|
|
|
import email_modes
|
|
import paths
|
|
|
|
# provider domain -> (smtp_host, smtp_port, imap_host, imap_port)
|
|
PROVIDERS = {
|
|
"gmail.com": ("smtp.gmail.com", 587, "imap.gmail.com", 993),
|
|
"googlemail.com": ("smtp.gmail.com", 587, "imap.gmail.com", 993),
|
|
"outlook.com": ("smtp-mail.outlook.com", 587, "outlook.office365.com", 993),
|
|
"hotmail.com": ("smtp-mail.outlook.com", 587, "outlook.office365.com", 993),
|
|
"live.com": ("smtp-mail.outlook.com", 587, "outlook.office365.com", 993),
|
|
"yahoo.com": ("smtp.mail.yahoo.com", 587, "imap.mail.yahoo.com", 993),
|
|
"icloud.com": ("smtp.mail.me.com", 587, "imap.mail.me.com", 993),
|
|
"me.com": ("smtp.mail.me.com", 587, "imap.mail.me.com", 993),
|
|
"fastmail.com": ("smtp.fastmail.com", 587, "imap.fastmail.com", 993),
|
|
}
|
|
|
|
|
|
def _domain(address: str) -> str:
|
|
return address.rsplit("@", 1)[-1].lower() if "@" in address else ""
|
|
|
|
|
|
def smtp_settings(env: dict | None = None) -> dict | None:
|
|
"""SMTP connection settings, or None when sending is not configured."""
|
|
env = os.environ if env is None else env
|
|
address, password = env.get("EMAIL_ADDRESS"), env.get("EMAIL_PASSWORD")
|
|
if not (address and password):
|
|
return None
|
|
inferred = PROVIDERS.get(_domain(address))
|
|
host = env.get("EMAIL_SMTP_HOST") or (inferred[0] if inferred else None)
|
|
if not host:
|
|
return None # unknown provider and no explicit host
|
|
port = int(env.get("EMAIL_SMTP_PORT") or (inferred[1] if inferred else 587))
|
|
return {"host": host, "port": port, "address": address, "password": password}
|
|
|
|
|
|
def imap_settings(env: dict | None = None) -> dict | None:
|
|
"""IMAP connection settings, or None when inbox reading is not configured."""
|
|
env = os.environ if env is None else env
|
|
address, password = env.get("EMAIL_ADDRESS"), env.get("EMAIL_PASSWORD")
|
|
if not (address and password):
|
|
return None
|
|
inferred = PROVIDERS.get(_domain(address))
|
|
host = env.get("EMAIL_IMAP_HOST") or (inferred[2] if inferred else None)
|
|
if not host:
|
|
return None
|
|
port = int(env.get("EMAIL_IMAP_PORT") or (inferred[3] if inferred else 993))
|
|
return {"host": host, "port": port, "address": address, "password": password}
|
|
|
|
|
|
def available(env: dict | None = None) -> dict:
|
|
return {"smtp": smtp_settings(env) is not None, "imap": imap_settings(env) is not None}
|
|
|
|
|
|
# --- sending ------------------------------------------------------------------
|
|
|
|
def broker_addresses(broker: dict) -> list[str]:
|
|
"""Every address the broker record itself declares (the ONLY valid recipients).
|
|
|
|
Includes the primary opt-out email, the right-to-delete lane's email
|
|
(optout.deletion.email), and any mailto: links parsed from BADBOOL.
|
|
"""
|
|
opt = broker.get("optout") or {}
|
|
out = [a for a in [opt.get("email"), (opt.get("deletion") or {}).get("email")] if a]
|
|
for link in opt.get("links") or []:
|
|
url = (link.get("url") or "")
|
|
if url.lower().startswith("mailto:"):
|
|
out.append(url[7:].split("?")[0])
|
|
seen: set[str] = set()
|
|
deduped = []
|
|
for a in out:
|
|
if a.lower() not in seen:
|
|
seen.add(a.lower())
|
|
deduped.append(a)
|
|
return deduped
|
|
|
|
|
|
def _split_subject_body(text: str) -> tuple[str, str]:
|
|
"""Templates start with a 'Subject: ...' line; split it out for the MIME header."""
|
|
lines = text.splitlines()
|
|
if lines and lines[0].lower().startswith("subject:"):
|
|
return lines[0].split(":", 1)[1].strip(), "\n".join(lines[1:]).lstrip("\n")
|
|
return "Data removal request", text
|
|
|
|
|
|
def browser_send_payload(broker: dict, body_text: str, to: str | None = None) -> dict:
|
|
"""Build a recipient-locked {to, subject, body} for the agent to send via browser webmail.
|
|
|
|
No network and no credentials: the deterministic part (recipient-lock to the broker's own
|
|
declared address, subject/body split) happens here; the agent then composes and sends it in
|
|
the operator's logged-in webmail with browser_* tools. Same recipient guard as `send()`, so
|
|
the browser lane cannot be pointed at an arbitrary person either.
|
|
"""
|
|
allowed = broker_addresses(broker)
|
|
if not allowed:
|
|
raise RuntimeError(f"broker {broker.get('id')!r} declares no opt-out email address")
|
|
recipient = to or allowed[0]
|
|
if recipient.lower() not in {a.lower() for a in allowed}:
|
|
raise PermissionError(
|
|
f"refusing to target {recipient!r}: not an address the broker record declares "
|
|
f"(allowed: {allowed})"
|
|
)
|
|
subject, body = _split_subject_body(body_text)
|
|
return {"to": recipient, "subject": subject, "body": body}
|
|
|
|
|
|
def _rate_limit_path() -> Path:
|
|
return paths.data_dir() / "email-rate.json"
|
|
|
|
|
|
def _respect_rate_limit(min_interval: float, sleep, now, state_path=None) -> None:
|
|
"""Pace sends across CLI invocations so a run can't torch the sending account.
|
|
|
|
Persists the last-send wall-clock time; if the next send is too soon, sleep the
|
|
remainder. Cross-process because each `send-email` is a separate invocation.
|
|
"""
|
|
if min_interval <= 0:
|
|
return
|
|
p = state_path or _rate_limit_path()
|
|
last = 0.0
|
|
try:
|
|
last = float(json.loads(p.read_text(encoding="utf-8")).get("last", 0.0))
|
|
except (OSError, ValueError, TypeError):
|
|
last = 0.0
|
|
wait = min_interval - (now() - last)
|
|
if wait > 0:
|
|
sleep(min(wait, min_interval))
|
|
try:
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(json.dumps({"last": now()}), encoding="utf-8")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# SMTP errors that are permanent (don't retry) vs transient (retry with backoff).
|
|
_SMTP_PERMANENT = (smtplib.SMTPAuthenticationError, smtplib.SMTPRecipientsRefused,
|
|
smtplib.SMTPSenderRefused, smtplib.SMTPDataError)
|
|
|
|
|
|
def send(broker: dict, body_text: str, to: str | None = None,
|
|
env: dict | None = None, _smtp_factory=None,
|
|
min_interval: float = 0.0, max_retries: int = 3,
|
|
_sleep=time.sleep, _now=time.time, _rate_state=None) -> dict:
|
|
"""Send an opt-out/legal request to the broker's own opt-out address.
|
|
|
|
Recipient is locked to an address the broker record declares (PermissionError
|
|
otherwise). `min_interval` paces sends across invocations (deliverability /
|
|
account-safety); transient SMTP/socket failures retry with exponential backoff,
|
|
permanent ones (auth, recipient refused) raise immediately. NOTE: a successful
|
|
SMTP handoff is NOT proof of delivery - real bounces arrive later as inbound mail;
|
|
in programmatic mode `poll-verification`/inbox review surfaces them, and the
|
|
due-queue re-scan is the true confirmation. Returns send metadata.
|
|
"""
|
|
settings = smtp_settings(env)
|
|
if not settings:
|
|
raise RuntimeError(
|
|
"programmatic email not configured (need EMAIL_ADDRESS + EMAIL_PASSWORD, and "
|
|
"EMAIL_SMTP_HOST for non-mainstream providers); fall back to `render-email` drafts"
|
|
)
|
|
allowed = broker_addresses(broker)
|
|
if not allowed:
|
|
raise RuntimeError(f"broker {broker.get('id')!r} declares no opt-out email address")
|
|
recipient = to or allowed[0]
|
|
if recipient.lower() not in {a.lower() for a in allowed}:
|
|
raise PermissionError(
|
|
f"refusing to send to {recipient!r}: not an address the broker record declares "
|
|
f"(allowed: {allowed})"
|
|
)
|
|
|
|
subject, body = _split_subject_body(body_text)
|
|
msg = EmailMessage()
|
|
msg["From"] = settings["address"]
|
|
msg["To"] = recipient
|
|
msg["Subject"] = subject
|
|
msg["Date"] = email.utils.formatdate(localtime=True)
|
|
msg["Message-ID"] = email.utils.make_msgid()
|
|
msg.set_content(body)
|
|
|
|
_respect_rate_limit(min_interval, _sleep, _now, _rate_state)
|
|
|
|
factory = _smtp_factory or smtplib.SMTP
|
|
attempts = 0
|
|
while True:
|
|
attempts += 1
|
|
try:
|
|
with factory(settings["host"], settings["port"], timeout=30) as smtp:
|
|
smtp.ehlo()
|
|
try:
|
|
smtp.starttls()
|
|
smtp.ehlo()
|
|
except smtplib.SMTPNotSupportedError:
|
|
pass # already-TLS ports / test doubles
|
|
smtp.login(settings["address"], settings["password"])
|
|
smtp.send_message(msg)
|
|
break
|
|
except _SMTP_PERMANENT:
|
|
raise # auth / recipient refused: retrying won't help
|
|
except (smtplib.SMTPException, OSError) as exc:
|
|
if attempts > max_retries:
|
|
raise RuntimeError(f"SMTP send failed after {attempts} attempts: {exc}") from exc
|
|
_sleep(min(2 ** (attempts - 1), 30)) # 1s, 2s, 4s... capped
|
|
return {"to": recipient, "subject": subject, "message_id": msg["Message-ID"],
|
|
"from": settings["address"], "attempts": attempts,
|
|
"delivery_note": "SMTP accepted; not proof of delivery - a bounce would arrive as "
|
|
"inbound mail. The due-queue re-scan is the real confirmation."}
|
|
|
|
|
|
# --- inbox polling ------------------------------------------------------------
|
|
|
|
def _decode_part(part) -> str:
|
|
try:
|
|
payload = part.get_payload(decode=True)
|
|
if payload is None:
|
|
return ""
|
|
charset = part.get_content_charset() or "utf-8"
|
|
return payload.decode(charset, errors="replace")
|
|
except Exception: # noqa: BLE001 - malformed MIME must not kill the poll
|
|
return ""
|
|
|
|
|
|
def message_text(msg) -> str:
|
|
"""All text/plain + text/html content of a parsed email message."""
|
|
chunks: list[str] = []
|
|
if msg.is_multipart():
|
|
for part in msg.walk():
|
|
if part.get_content_type() in ("text/plain", "text/html"):
|
|
chunks.append(_decode_part(part))
|
|
else:
|
|
chunks.append(_decode_part(msg))
|
|
return "\n".join(c for c in chunks if c)
|
|
|
|
|
|
def _broker_domains(broker: dict) -> list[str]:
|
|
"""Domains this broker legitimately mails from (site domains + optout email domain)."""
|
|
domains: list[str] = []
|
|
for section in ("optout", "search"):
|
|
url = ((broker.get(section) or {}).get("url")) or ""
|
|
m = re.search(r"https?://([^/]+)", url)
|
|
if m:
|
|
domains.append(m.group(1).lower().removeprefix("www."))
|
|
opt_email = (broker.get("optout") or {}).get("email")
|
|
if opt_email and "@" in opt_email:
|
|
domains.append(_domain(opt_email))
|
|
# strip subdomains to the registrable-ish tail (mailer.intelius.com -> intelius.com)
|
|
tails = {".".join(d.split(".")[-2:]) for d in domains if d}
|
|
return sorted(tails)
|
|
|
|
|
|
def fetch_recent(env: dict | None = None, since_days: int = 3, limit: int = 30,
|
|
_imap_factory=None) -> list[dict]:
|
|
"""Fetch recent inbox messages: [{from, subject, date, text}], newest first."""
|
|
settings = imap_settings(env)
|
|
if not settings:
|
|
raise RuntimeError("IMAP not configured (need EMAIL_ADDRESS + EMAIL_PASSWORD, and "
|
|
"EMAIL_IMAP_HOST for non-mainstream providers)")
|
|
import datetime as _dt
|
|
since = (_dt.date.today() - _dt.timedelta(days=max(0, since_days))).strftime("%d-%b-%Y")
|
|
|
|
factory = _imap_factory or imaplib.IMAP4_SSL
|
|
conn = factory(settings["host"], settings["port"])
|
|
try:
|
|
conn.login(settings["address"], settings["password"])
|
|
conn.select("INBOX", readonly=True)
|
|
_typ, data = conn.search(None, "SINCE", since)
|
|
ids = (data[0].split() if data and data[0] else [])[-limit:]
|
|
out: list[dict] = []
|
|
for mid in reversed(ids): # newest first
|
|
_typ, msg_data = conn.fetch(mid, "(RFC822)")
|
|
raw = next((p[1] for p in msg_data or [] if isinstance(p, tuple)), None)
|
|
if not raw:
|
|
continue
|
|
msg = _email.message_from_bytes(raw)
|
|
out.append({
|
|
"from": msg.get("From", ""),
|
|
"subject": msg.get("Subject", ""),
|
|
"date": msg.get("Date", ""),
|
|
"text": message_text(msg),
|
|
})
|
|
return out
|
|
finally:
|
|
try:
|
|
conn.logout()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
|
|
def link_from_messages(messages: list[dict], broker: dict) -> dict | None:
|
|
"""Pure: find the broker's verification link in already-fetched messages.
|
|
|
|
A message is only considered if its From domain OR any contained link matches
|
|
the broker's own domains; the link itself must pass the anti-phishing scorer.
|
|
"""
|
|
domains = _broker_domains(broker)
|
|
for m in messages:
|
|
sender = (m.get("from") or "").lower()
|
|
text = m.get("text") or ""
|
|
sender_match = any(d in sender for d in domains)
|
|
body_match = any(d in text.lower() for d in domains)
|
|
if not (sender_match or body_match):
|
|
continue
|
|
link = email_modes.extract_verification_link(text, broker)
|
|
if link:
|
|
return {"link": link, "from": m.get("from"), "subject": m.get("subject"),
|
|
"date": m.get("date")}
|
|
return None
|
|
|
|
|
|
def find_verification_link(broker: dict, env: dict | None = None, since_days: int = 3,
|
|
_imap_factory=None) -> dict | None:
|
|
"""Poll the inbox and return the broker's verification link (or None yet)."""
|
|
messages = fetch_recent(env, since_days=since_days, _imap_factory=_imap_factory)
|
|
return link_from_messages(messages, broker)
|