From 7972c83541e80e82bcf1bdfc4bd94e72dc18ae3e Mon Sep 17 00:00:00 2001 From: aaronjmars Date: Sun, 6 Sep 2026 12:40:28 -0400 Subject: [PATCH] feat: delivery preflight - check prior sends for bounces before send-email A 200 from POST /emails only means 'accepted'; the delivered/bounced outcome is async, so a send logged email-sent can still have bounced (dead mailbox, spam complaint) invisibly. Add scripts/check_email_bounces.py, run at the START of send-email: it polls Resend GET /emails/{id} for every memory/email-log.json row without a terminal delivery_status, writes the outcome back (delivery_status/ last_event/bounce/delivery_checked_at), flags a hard-bounced draft status: contact-unverified, and prints BOUNCE_ALERT so the skill notifies the operator. Advisory only (unset key / poll error no-ops). No new egress. Regenerated eyebrowlock: send-email (this change) plus two pre-existing stale hashes (skill-health, deploy-uni-hook) the drift gate requires refreshed. --- eyebrowlock.json | 28 +++--- scripts/check_email_bounces.py | 159 +++++++++++++++++++++++++++++++++ skills/send-email/SKILL.md | 2 + 3 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 scripts/check_email_bounces.py diff --git a/eyebrowlock.json b/eyebrowlock.json index e39842e5af5..19359f835e9 100644 --- a/eyebrowlock.json +++ b/eyebrowlock.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-09-04T16:37:07.361139Z", + "generatedAt": "2026-09-06T16:38:59.492182Z", "generator": "eyebrow/0.4.2", "artifacts": [ { @@ -397,7 +397,7 @@ }, { "path": "project/package-lock.json", - "hash": "a0de158bc359e26fcc320dc850358b26313d254eabb0e0100ab535c15dcc6561" + "hash": "c491727498df4bfb43cf230d10070fa84a339c881258b1cfb074555a3ad60685" }, { "path": "project/package.json", @@ -451,7 +451,7 @@ "explanation": "contains consent-bypass or prompt-injection language" } ], - "contentHash": "sha256-dbaef1867bc84f9117c2e84c5bcb897f8737bc9aa5c82d89688ecf605f381c46", + "contentHash": "sha256-951f973d8b89f0fcf8b5ca0022e189451f3a2a68f38e59df7767f1ce8110c7f2", "discoveredFrom": "skills/remotion/SKILL.md" }, { @@ -556,10 +556,10 @@ "files": [ { "path": "SKILL.md", - "hash": "dfd812e4832da6958646746980e56d48f28a8a33bd2878cc0450ee84ebab69d4" + "hash": "86c10bfc5c4066d9bcbdd004459c4c957508f7ba2cbe2166074ed7e163b7f67b" } ], - "contentHash": "sha256-439f8e6953ca04fcf91b4d877bb66c5c09ec3ca20002adfe7b638f3374b43a42", + "contentHash": "sha256-02c5b0552fb2bfa8c70854abc72092db6f08bc00809491f8680581baf70db755", "discoveredFrom": "skills/skill-health/SKILL.md" }, { @@ -1489,7 +1489,7 @@ "files": [ { "path": "SKILL.md", - "hash": "605b296d976df6631c7d9d5310442a9f933794ba32188136b7e342bb635b4dfc" + "hash": "8e88807e17f976c18caddc876a66b010b35288508e424827501e5acfb46362d1" }, { "path": "hook-deploy.sh", @@ -1497,7 +1497,7 @@ }, { "path": "templates/DeployHook.s.sol", - "hash": "c97173a47b3bf4aeddb75e901033ed2d01a6903987d47cbdff94544dbd9e2f95" + "hash": "e72397dbc455abde1687451dc368cd1417dd6021075ca1134932d198a3ec6785" }, { "path": "templates/DynamicFeeHook.sol", @@ -1509,7 +1509,7 @@ }, { "path": "templates/Hook.t.sol", - "hash": "f563b5261e35565939b9ad10e9ebd1eb3838cf3fd85beb3872de58a704e1656b" + "hash": "fb7c84d1c76ac4cb850a3652f7404b8865d34e914db10bd124d6832773955756" }, { "path": "templates/HookFeeHook.sol", @@ -1551,7 +1551,7 @@ "severity": "high", "owasp": "ASK-06", "file": "SKILL.md", - "line": 116, + "line": 118, "snippet": "- **Freeform mode** (anything else): write the whole hook into `$HOOKBUILD_DIR/src/Hook.sol` — replace the `// --- AEO…", "explanation": "references sensitive credential or secret paths" }, @@ -1610,7 +1610,7 @@ "explanation": "references sensitive credential or secret paths" } ], - "contentHash": "sha256-aa6b3cb711be49025443c10a749f3a4f11a5c7468f8cc9125f09009f9033c2b6", + "contentHash": "sha256-e749b80ea9b00de2689e829fdd5fe37e6e3e6e53c484c25b534150e648c0c4f6", "discoveredFrom": "skills/deploy-uni-hook/SKILL.md" }, { @@ -2091,7 +2091,7 @@ }, { "path": "references/secrets.md", - "hash": "76b8105069a91632daba058cfdd7afec0e3b6ee9542b083b99b4601a73fbcd96" + "hash": "41aa375c84cea1b0190bd7291176651d335dc1c41e2b8067900f164d4c4df670" }, { "path": "references/skill-anatomy.md", @@ -2102,7 +2102,7 @@ "hash": "0edc7445542a21cb5518e813a6ae462a1d469636601a799efc1bec86b4058c5a" } ], - "contentHash": "sha256-c43ac760993f8c41c7aa85b20ce6b53b948607d61d767f21d321f3b9853aa5e5", + "contentHash": "sha256-4e9c576b6d6c7cad1e17df815ac9519e4af1dd7a827e3b06a5314675ca1478ce", "discoveredFrom": ".claude/skills/aeon/SKILL.md" }, { @@ -2147,10 +2147,10 @@ "files": [ { "path": "SKILL.md", - "hash": "67d4c34d7fcd2f87f70fa8462289898f05499575444f34587dabb73f307a7f4f" + "hash": "da2ac8b88d22076c227bee9fcd7b2ea1c7be407a866f35a8a64e316599bfd110" } ], - "contentHash": "sha256-fde5b3b54f16d08354f3d8df0e30f44b68180bce3470257e90405d376e032dd4", + "contentHash": "sha256-9666675528c0e0c8913c8fd4499eece477e9a8108e571ae16eedbb79d5138a67", "discoveredFrom": "skills/send-email/SKILL.md" }, { diff --git a/scripts/check_email_bounces.py b/scripts/check_email_bounces.py new file mode 100644 index 00000000000..ec08a656c16 --- /dev/null +++ b/scripts/check_email_bounces.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Delivery preflight for aeon's Resend senders (send-email, disclosure-emailer). + +Run at the START of every Resend-backed skill. A 200 from POST /emails only means +"accepted for delivery" - the actual delivered/bounced outcome is decided async, +seconds to minutes later, so a send logged as email-sent can still have bounced. +This closes that blind spot: it polls Resend GET /emails/{id} for every recent +send in memory/email-log.json that has no terminal delivery status yet, records +the result back into the ledger, flags any hard-bounced disclosure draft so it is +never re-sent to a dead address, and prints a summary the caller ./notify's when a +new bounce/complaint appears. + +Reads RESEND_API_KEY from os.environ (never argv - keeps the secret off the +analyzed command line, same as scripts/email_payload.py). Advisory only: no-ops +(exit 0) if the key is unset, the ledger is missing/empty, or a poll errors - it +must never block the skill's own send. + +Output: a short human summary on stdout. When a NEW bounce/complaint is found this +run, the first line is exactly "BOUNCE_ALERT" so the caller knows to notify. +""" +import os +import sys +import json +import re +import urllib.request +from datetime import datetime, timezone, timedelta + +LEDGER = "memory/email-log.json" +API = "https://api.resend.com/emails/" +MAX_AGE_DAYS = int(os.environ.get("EMAIL_BOUNCE_LOOKBACK_DAYS", "30")) + +# Resend last_event -> our terminal delivery_status bucket. +GOOD = {"delivered", "opened", "clicked"} +BAD = {"bounced", "complained", "failed", "canceled", "cancelled"} +# Anything else (sent, scheduled, queued, delivery_delayed, ...) is still pending. + + +def now_iso(): + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def parse_dt(s): + try: + return datetime.fromisoformat((s or "").replace("Z", "+00:00")) + except (ValueError, AttributeError): + return None + + +def load_ledger(): + try: + with open(LEDGER) as f: + data = json.load(f) + return data if isinstance(data, list) else None + except (FileNotFoundError, json.JSONDecodeError): + return None + + +def get_email(eid, key): + req = urllib.request.Request(API + eid, headers={"Authorization": "Bearer " + key}) + with urllib.request.urlopen(req, timeout=15) as r: + return json.loads(r.read().decode()) + + +def flag_draft(path): + """After a hard bounce, mark a disclosure draft's contact unverified so the + emailer's eligibility gate never retries a dead address.""" + try: + with open(path) as f: + txt = f.read() + except (FileNotFoundError, OSError): + return + if not re.search(r"(?m)^status:\s*email-sent\b", txt): + return + txt = re.sub(r"(?m)^status:\s*email-sent\b.*$", "status: contact-unverified", txt, count=1) + if not re.search(r"(?m)^deliverability:", txt): + txt = txt.replace("status: contact-unverified", "status: contact-unverified\ndeliverability: bounced", 1) + try: + with open(path, "w") as f: + f.write(txt) + except OSError: + return + + +def main(): + key = os.environ.get("RESEND_API_KEY") + if not key: + print("bounce-check: RESEND_API_KEY unset - skipped") + return 0 + rows = load_ledger() + if not rows: + print("bounce-check: no ledger - skipped") + return 0 + + cutoff = datetime.now(timezone.utc) - timedelta(days=MAX_AGE_DAYS) + changed = False + checked = 0 + alerts = [] + + for row in rows: + if not isinstance(row, dict): + continue + if row.get("delivery_status") in GOOD or row.get("delivery_status") in BAD: + continue # already terminal + eid = row.get("resend_id") + if not eid: + continue + sent = parse_dt(row.get("sent_at")) + if sent and sent < cutoff: + continue # too old to keep polling + checked += 1 + try: + data = get_email(eid, key) + except Exception: + continue # advisory: a poll error must not block the skill + last_event = (data.get("last_event") or "").lower() + if not last_event: + continue + + was = row.get("delivery_status") + row["last_event"] = last_event + row["delivery_checked_at"] = now_iso() + if last_event in GOOD: + row["delivery_status"] = "delivered" + changed = True + elif last_event in BAD: + bucket = "canceled" if last_event == "cancelled" else last_event + row["delivery_status"] = bucket + bounce = data.get("bounce") or {} + if bounce: + row["bounce"] = {k: bounce[k] for k in ("type", "subType", "message") if bounce.get(k)} + changed = True + if was not in BAD: + alerts.append(row) + if bucket == "bounced" and row.get("draft_path"): + flag_draft(row["draft_path"]) + else: + row["delivery_status"] = "pending" + changed = True + + if changed: + with open(LEDGER, "w") as f: + json.dump(rows, f, indent=1) + f.write("\n") + + if alerts: + print("BOUNCE_ALERT") + for r in alerts: + b = r.get("bounce") or {} + detail = " ({})".format(b.get("subType") or b.get("type")) if b else "" + print("- {}{}: {} - {} [{}]".format( + r.get("delivery_status"), detail, r.get("to"), + (r.get("subject") or "")[:70], r.get("resend_id"))) + else: + print("bounce-check: {} polled, no new bounces".format(checked)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/send-email/SKILL.md b/skills/send-email/SKILL.md index 1cbb80c8a89..e7c12722862 100644 --- a/skills/send-email/SKILL.md +++ b/skills/send-email/SKILL.md @@ -50,6 +50,8 @@ This is **not** a bulk or cold-outreach tool. One deliberate recipient per run, Otherwise (no `revise:` prefix), run the normal flow: +0. **Delivery preflight (bounce check on prior sends).** Before composing, reconcile earlier Resend sends - a `200` from the send API only means "accepted", so a bounce is invisible until checked. Run `python3 scripts/check_email_bounces.py`: it polls Resend `GET /emails/{id}` (using `RESEND_API_KEY` from the env) for every `memory/email-log.json` row without a terminal `delivery_status`, writes the outcome back (`delivery_status`, `last_event`, `bounce`, `delivery_checked_at`), and on a hard bounce flags the source draft `status: contact-unverified`. It is advisory (an unset key or poll error just no-ops - never blocks this send). If its first line is `BOUNCE_ALERT`, `./notify` the operator the listed bounces so a human can fix the contact, then continue. Commit the updated `memory/email-log.json` with this run. + 1. **Parse the request** from `${var}`: `to` (required — one valid email address), optional `cc`, optional `subject`, and the `about` (the goal / what to say). If `to` or the purpose is missing, check `memory/outreach.md` for a queued request; if still nothing, log `SEND_EMAIL_SKIP: no recipient/purpose` and stop. 2. **Sanity-check the recipient.** A single, plausible, individual address with a real reason to be contacted. Refuse scraped addresses, list blasts, or anything spam-shaped → `SEND_EMAIL_REFUSED`.