Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions eyebrowlock.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand Down Expand Up @@ -397,7 +397,7 @@
},
{
"path": "project/package-lock.json",
"hash": "a0de158bc359e26fcc320dc850358b26313d254eabb0e0100ab535c15dcc6561"
"hash": "c491727498df4bfb43cf230d10070fa84a339c881258b1cfb074555a3ad60685"
},
{
"path": "project/package.json",
Expand Down Expand Up @@ -451,7 +451,7 @@
"explanation": "contains consent-bypass or prompt-injection language"
}
],
"contentHash": "sha256-dbaef1867bc84f9117c2e84c5bcb897f8737bc9aa5c82d89688ecf605f381c46",
"contentHash": "sha256-951f973d8b89f0fcf8b5ca0022e189451f3a2a68f38e59df7767f1ce8110c7f2",
"discoveredFrom": "skills/remotion/SKILL.md"
},
{
Expand Down Expand Up @@ -556,10 +556,10 @@
"files": [
{
"path": "SKILL.md",
"hash": "dfd812e4832da6958646746980e56d48f28a8a33bd2878cc0450ee84ebab69d4"
"hash": "86c10bfc5c4066d9bcbdd004459c4c957508f7ba2cbe2166074ed7e163b7f67b"
}
],
"contentHash": "sha256-439f8e6953ca04fcf91b4d877bb66c5c09ec3ca20002adfe7b638f3374b43a42",
"contentHash": "sha256-02c5b0552fb2bfa8c70854abc72092db6f08bc00809491f8680581baf70db755",
"discoveredFrom": "skills/skill-health/SKILL.md"
},
{
Expand Down Expand Up @@ -1489,15 +1489,15 @@
"files": [
{
"path": "SKILL.md",
"hash": "605b296d976df6631c7d9d5310442a9f933794ba32188136b7e342bb635b4dfc"
"hash": "8e88807e17f976c18caddc876a66b010b35288508e424827501e5acfb46362d1"
},
{
"path": "hook-deploy.sh",
"hash": "923ac9765f435569d322c24cf4de4ccafb1ecd190901ecc75a5538900cc072b5"
},
{
"path": "templates/DeployHook.s.sol",
"hash": "c97173a47b3bf4aeddb75e901033ed2d01a6903987d47cbdff94544dbd9e2f95"
"hash": "e72397dbc455abde1687451dc368cd1417dd6021075ca1134932d198a3ec6785"
},
{
"path": "templates/DynamicFeeHook.sol",
Expand All @@ -1509,7 +1509,7 @@
},
{
"path": "templates/Hook.t.sol",
"hash": "f563b5261e35565939b9ad10e9ebd1eb3838cf3fd85beb3872de58a704e1656b"
"hash": "fb7c84d1c76ac4cb850a3652f7404b8865d34e914db10bd124d6832773955756"
},
{
"path": "templates/HookFeeHook.sol",
Expand Down Expand Up @@ -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"
},
Expand Down Expand Up @@ -1610,7 +1610,7 @@
"explanation": "references sensitive credential or secret paths"
}
],
"contentHash": "sha256-aa6b3cb711be49025443c10a749f3a4f11a5c7468f8cc9125f09009f9033c2b6",
"contentHash": "sha256-e749b80ea9b00de2689e829fdd5fe37e6e3e6e53c484c25b534150e648c0c4f6",
"discoveredFrom": "skills/deploy-uni-hook/SKILL.md"
},
{
Expand Down Expand Up @@ -2091,7 +2091,7 @@
},
{
"path": "references/secrets.md",
"hash": "76b8105069a91632daba058cfdd7afec0e3b6ee9542b083b99b4601a73fbcd96"
"hash": "41aa375c84cea1b0190bd7291176651d335dc1c41e2b8067900f164d4c4df670"
},
{
"path": "references/skill-anatomy.md",
Expand All @@ -2102,7 +2102,7 @@
"hash": "0edc7445542a21cb5518e813a6ae462a1d469636601a799efc1bec86b4058c5a"
}
],
"contentHash": "sha256-c43ac760993f8c41c7aa85b20ce6b53b948607d61d767f21d321f3b9853aa5e5",
"contentHash": "sha256-4e9c576b6d6c7cad1e17df815ac9519e4af1dd7a827e3b06a5314675ca1478ce",
"discoveredFrom": ".claude/skills/aeon/SKILL.md"
},
{
Expand Down Expand Up @@ -2147,10 +2147,10 @@
"files": [
{
"path": "SKILL.md",
"hash": "67d4c34d7fcd2f87f70fa8462289898f05499575444f34587dabb73f307a7f4f"
"hash": "da2ac8b88d22076c227bee9fcd7b2ea1c7be407a866f35a8a64e316599bfd110"
}
],
"contentHash": "sha256-fde5b3b54f16d08354f3d8df0e30f44b68180bce3470257e90405d376e032dd4",
"contentHash": "sha256-9666675528c0e0c8913c8fd4499eece477e9a8108e571ae16eedbb79d5138a67",
"discoveredFrom": "skills/send-email/SKILL.md"
},
{
Expand Down
159 changes: 159 additions & 0 deletions scripts/check_email_bounces.py
Original file line number Diff line number Diff line change
@@ -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())
2 changes: 2 additions & 0 deletions skills/send-email/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down