Skip to content

Repository files navigation

email-triage-agent

Autonomous email triage: every 30 minutes it polls a Resend receiving inbox, spawns one headless Claude session per email to decide reply / ask-a-human-on-Telegram / ignore, executes the decision, and flags the email processed so it is never re-fed.

Status: running in production under launchd on an always-on macOS host, on a 30-minute interval.

Safety envelope

The interesting part is not the LLM call — it's the envelope around it:

  • Exactly-once sends. Every decision is journaled to state/inflight/ before its side effect and only cleared after. A crash replays the same decision next tick (no re-deciding), and Resend idempotency keys (ea:reply:<emailId>) make replayed sends exactly-once. Journals older than 12 h (half Resend's 24 h idempotency window) alert you instead of blind-replaying.
  • Hard caps. ≤5 replies per run, ≤3 replies per sender per day, ≤5 emails decided per run. A surge of >25 new emails at once (mail loop, spam wave) skips the whole batch and alerts you.
  • Mail-loop firewall. Self-mail, Auto-Submitted, Precedence: bulk/list/junk, and no-reply/mailer-daemon senders are dropped before any LLM call.
  • No recipient choice. The decision schema has no recipient field; the only send path hard-locks the recipient to the original sender's From. Inbound Reply-To is deliberately ignored (spoofable redirection surface).
  • Sender authentication. Autonomous replies go only to senders passing domain-aligned DKIM/DMARC; anything else is downgraded to a clarification. Owner answers by email require the same alignment for the owner's domain.
  • Prompt-injection posture. The decision session runs with zero tools, no MCP, a minimal env (it never sees the Resend or Telegram credentials), and the email body is fenced as untrusted data with a per-call random nonce. AGENT.md — the decision contract — instructs the model to treat in-mail instructions as data.
  • Clarification lifecycle. Unanswered questions are reminded once at 4 h and expire at 48 h with no action taken (you're told).
  • Liveness. Daily Telegram heartbeat; an alert after 3 consecutive failed runs, re-alerted roughly every 6 h during an outage.

How it works

launchd (every 30 min) → bin/tick.sh (lock, log, prune) → src/run.js
  1. replay crashed inflight actions (idempotent — Resend idempotencyKey)
  2. read Telegram answers → resume pending clarifications
  3. remind (4h) / expire (48h) unanswered clarifications
  4. poll Resend Received-Emails API → new mail → skip-classifier → surge guard
  5. per email: claude -p (zero tools, pure JSON decision) → execute → mark processed
  6. daily Telegram heartbeat; alert after 3 consecutive failed runs

The per-email decision is a hermetic claude -p call: AGENT.md (policy) + the fenced email + a strict JSON output contract in, one JSON object out (action, reason, stakes, reply_text / clarify_question). Invalid output gets one corrective retry, then falls back to asking you.

State lives in state/ on the deployment host only — never sync it between machines: processed.jsonl (completed flag), pending/ (awaiting your answer), inflight/ (crash journal), tg-seen.json, rate.json, meta.json.

Quickstart

git clone https://github.com/BaseLayerAI/email-triage-agent
cd email-triage-agent
npm install
cp .env.example .env   # fill in every value
npm test               # unit tests, no network
node src/run.js --dry-run   # full tick, no sends, no state writes

The first real run baselines all pre-existing inbound mail (processes nothing) so the agent never replies to stale threads. Normal processing starts on the second tick.

Inbound mail setup

Inbound uses the Resend account's managed receiving address (anything@<subdomain>.resend.app lands in the account's Receiving inbox — no DNS needed). Outgoing mail sets Reply-To to that address, so replies reach the agent immediately. Optionally, route a nicer public address (e.g. assistant@yourdomain.com) to it via your mail provider's forwarding/routing rules, and add it to EA_SELF_ADDRESSES.

Environment

Variable Required Purpose
RESEND_API_KEY yes Resend receiving poll + outbound sends
EA_FROM_ADDRESS yes Outbound From (Name <addr>); domain must be Resend-verified
EA_OWNER_EMAIL yes Only sender allowed to answer clarifications by email
TELEGRAM_BOT_TOKEN yes* Bot for clarifications/alerts (*falls back to the Claude Code telegram plugin's env file)
TELEGRAM_CHAT_ID yes Your numeric Telegram user id
ANTHROPIC_API_KEY no For headless claude -p under launchd; omit if claude login was run on the host
EA_REPLY_TO_ADDRESS no Where replies land; default = bare EA_FROM_ADDRESS
EA_SELF_ADDRESSES no Extra owned addresses for the loop guard (@domain = wildcard)
EA_SIGNATURE no Override the appended reply signature
EA_MODEL no Decision model (default sonnet)
EA_STATE_DIR, EA_SKIP_TELEGRAM_READ dev Test/dev overrides

Deployment

Target is any always-on macOS machine reachable over ssh (e.g. via Tailscale):

bash scripts/deploy.sh <ssh-alias-or-user@host>

The deploy runs a remote preflight (node ≥20, claude CLI + headless auth smoke test, disk, clock skew, pmset sleep settings) and refuses to proceed on failure. It copies .env once (never overwrites), rsyncs the code (excluding state/, logs/, .env), and installs launchd/com.baselayer.email-triage.plist. launchd plists cannot expand $HOME, so the plist ships with a __HOME__ placeholder that deploy.sh substitutes at install time (installing by hand: sed "s|__HOME__|$HOME|g" first).

Telegram note: with a shared bot token, the agent reads updates non-destructively (never confirms offsets) so it can't steal messages from another consumer — but a dedicated bot (two minutes with @BotFather) eliminates the contention class entirely and is recommended.

Ops details — credentials, rotation, failure signatures, recovery — are in docs/RUNBOOK.md.

About

Autonomous inbox agent: reply, ask-a-human on Telegram, or ignore — with exactly-once sends

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages