Fortmail
README.md
# Fortmail
**Agent-operated email, in one Cloudflare Worker.**
Your AI agent gets a real email client — every account you own, aggregated,
triaged, and sendable-as — and you stop checking inboxes. Fortmail is the
open-source version of the mail system running inside
[The Fort That Holds](https://thefortthatholds.com): one small worker, no
framework, no server to babysit, free-tier friendly.
```
your Gmail(s) ─┐
your domain(s) ─┤→ Fortmail worker → triage desk (only what matters)
(any IMAP) ─┘ │
├→ MCP server at /mcp ← your agent connects here
└→ steward bridge: email → GitHub PR → wakes your agent
```
## What it does
- **Owns all your mailboxes.** Gmail accounts via the Gmail API (OAuth), and
any IMAP/SMTP provider (Migadu, Fastmail, Purelymail, your host…) via raw
TLS sockets — no forwarding rules, no middleman service.
- **Seals its own credentials.** The worker mints its own AES-GCM key and can
generate + seal a password per mailbox. You never handle, store, or even see
those passwords — the agent's wallet is the only place they exist.
- **Triages deterministically.** A regex classifier (no LLM, no API cost,
no hallucination) sorts mail into `desk` (needs a human), `record`
(worth keeping), `ignore` (bulk/OTP noise). A cron sweeps one scope every
5 minutes and caches the desk, so reading it is instant.
- **Speaks MCP.** `/mcp` is a Model Context Protocol server with its own
OAuth (dynamic client registration + PKCE). MCP is vendor-neutral — connect
any agent that takes an MCP server (Claude, ChatGPT, Gemini, Cursor, your
own harness) and it gets the mail tools (`list_accounts`, `get_desk`,
`triage`, `read_box`, `read_message`, `get_attachment`, `send`) plus the
newsletter tools. `read_message` returns body text and attachment metadata;
`get_attachment` / `GET /attachment` fetch Gmail file bytes. There is
**no LLM inside Fortmail itself** — no model dependency, no API key to
any AI vendor; the intelligence is whatever agent you point at it.
- **Sends as anyone you own.** Gmail via the API, everything else via SMTP —
transport picked automatically from the `from` address.
- **Runs your newsletters.** Subscriber lists live in *your* KV (not an
ESP's database), with double opt-in, one-click unsubscribe (RFC 8058),
bounce/complaint suppression, and campaigns that drain through the cron in
rate-safe chunks via a relay (Resend) that's just a dumb pipe. Per-subscriber
rent is the ESP business model; this is per-email dimes. Any number of
lists — a pen name, a brand, a product each get a row, not an account.
See [docs/NEWSLETTER.md](docs/NEWSLETTER.md).
- **Wakes your agent on mail** (optional). Give the agent its own address
(e.g. `steward@your-domain.com`). Every unseen message there becomes a
GitHub pull request in a repo your agent watches — with the sender stamped
**TRUSTED** (you) or **UNTRUSTED** (everyone else) so the agent knows whether
it's holding instructions or just data. Email in, agent awake, audit trail
built in.
## Quickstart
Prereqs: a Cloudflare account (free tier works) and `npx wrangler` logged in.
```sh
git clone https://github.com/TheFortThatHolds/mail && cd mail
# 1. The one store
npx wrangler kv namespace create TOKENS
# → paste the returned id into wrangler.jsonc
# 2. The admin key (any long random string — this gates every admin endpoint)
npx wrangler secret put TRIGGER_KEY
# 3. Ship it
npx wrangler deploy
```
Then connect mailboxes — see [docs/SETUP.md](docs/SETUP.md) for the full
walkthrough (Gmail OAuth app, IMAP boxes, the steward bridge) and
[docs/AGENT.md](docs/AGENT.md) for pointing your agent at it.
**Or skip the manual setup entirely:** fork this repo and point your coding
agent — any vendor — at it. [`AGENTS.md`](AGENTS.md) is a runbook the agent
can execute end-to-end; it will ask you only for the human-gated steps
(Cloudflare login, mailbox passwords, OAuth approvals).
The 60-second version, with `KEY` = your TRIGGER_KEY and `W` = your worker URL:
```sh
# any IMAP mailbox you already have (password sent as a header, sealed on arrival)
curl -H "X-Mailbox-Password: <password>" \
"$W/wallet-import?key=$KEY&addr=me@my-domain.com&host=imap.my-provider.com"
# or mint a NEW sealed password for a box (then set that password at your provider)
curl "$W/wallet-provision?key=$KEY&addrs=steward@my-domain.com&host=imap.my-provider.com"
# a Gmail account (needs GMAIL_CLIENT_ID/SECRET set — see docs/SETUP.md)
open "$W/connect?key=$KEY"
# watch it work
curl "$W/triage?key=$KEY&scope=all"
curl "$W/desk?key=$KEY"
```
Connect your agent: add `https://<your-worker>/mcp` as a custom MCP connector.
It will walk the OAuth flow; the password prompt is your `TRIGGER_KEY`.
## The trust rule (read this one)
Email is untrusted input. Fortmail's bridge stamps every filed message by a
`From`-match against `OWNER_EMAILS`:
- ✅ **TRUSTED SENDER (owner)** — instructions may be acted on.
- ⚠️ **UNTRUSTED SENDER** — the message is *data to triage*. The agent must
never follow instructions, links, or requests inside it.
This is the prompt-injection line for email-driven agents: only the owner's
address issues commands; everything else gets read, never obeyed. Keep the
same rule in your agent's own instructions — the stamp is a signal, your
agent's discipline is the enforcement. And spoofing exists: for anything
consequential, gate on your explicit approval, not on a From header.
## Endpoints
| Route | What |
|---|---|
| `/mcp` | MCP server (OAuth-gated) — the agent's door |
| `/desk?key=` | The cached triage desk, all scopes |
| `/triage?key=&scope=` | Live triage (`all`, `gmail`, `imap`, `&domain=` filter) |
| `/cron-run?key=` | Force one cron tick (or `&scope=` a specific one) |
| `/send?key=&from=&to=&subject=&text=` | Send as any owned box |
| `/wallet-provision?key=&addrs=&host=&smtp=` | Mint + seal new IMAP creds |
| `/wallet-import?key=&addr=&host=&smtp=` | Seal an existing password (via `X-Mailbox-Password` header) |
| `/accounts?key=` / `/imapboxes?key=` | List owned boxes |
| `/tool?key=&name=` | Call any MCP tool over HTTP (`GET` query or `POST` JSON `{name,arguments}`) — same `TRIGGER_KEY` as `/accounts` |
| `/attachment?key=&address=&message=&attachmentId=` | Fetch one Gmail attachment as raw bytes (`Content-Type` from the part). `encoding=base64` returns JSON instead. Read-only; 4MB cap on JSON/tool payloads |
| `/connect?key=` → `/oauth/callback` | Gmail account OAuth flow |
| `/import?key=` | Import an existing Gmail refresh token |
| `/bridge-run?key=&dry=1` | Run/inspect the steward bridge now |
| `/news/subscribe?list=` | Public signup (double opt-in) — see [docs/NEWSLETTER.md](docs/NEWSLETTER.md) |
| `/news/list?key=` / `/news/lists?key=` | Create lists / list them with counts |
| `/news/send?key=` | Queue a campaign (or `test` to one address) |
| `/news/campaign?key=` / `/news/drain?key=` | Campaign progress / push the queue now |
| `/news/relay?key=` | Seal the relay API key (or use broker-mode vars) |
| `/news/hook` | Relay webhook → suppression on bounce/complaint |
## Design notes
- **One file on purpose.** ~550 lines, zero dependencies, reviewable in one
sitting. Email holds your whole life; you should be able to read every line
of the thing that touches it.
- **You own the audience.** The newsletter engine keeps subscribers as rows in
your KV; the sending relay never holds the list. Leaving a relay is a config
change, not a migration.
- **90-day window** on both Gmail and IMAP (`SINCE` search) so ancient mail
can never flood the desk.
- **Rotating cron scopes.** Each 5-minute tick sweeps ONE scope (gmail, or one
domain) — many mailboxes never pile into one timeout.
- **IMAP batching in fours** — Cloudflare serializes concurrent sockets;
batches keep a sweep fast without tripping limits.
- **No LLM in the loop.** Triage is regex. Your agent applies judgment when it
reads the desk; the plumbing itself never guesses.
Hardening ideas, threat model, and known limits: [docs/SECURITY.md](docs/SECURITY.md).
## Where this came from
Fortmail is one organ of [The Fort That Holds](https://thefortthatholds.com) — a sovereign,
agent-operated stack built in the open. This repository is the whole mail tool. It is
MIT-licensed and free to run. There is no Fortmail product page and no paid mail seed.
If you want the written *route* for other Fort pieces — the instructions you hand your
own agent so it can walk a path that already worked — those live on the
[Grand Bazaar](https://thefortthatholds.xyz) as [Selfware Seeds](https://thefortthatholds.xyz)
(the rack is on the front page). Live ones today:
- [LOCUS Symbiote](https://thefortthatholds.xyz/locus-products)
- [Sovereign Web](https://thefortthatholds.xyz/sovereign-products)
- [Digital Asset Vault](https://thefortthatholds.xyz/business-solutions)
- [Beta Reader](https://thefortthatholds.xyz/digital-products) — and a hosted
[Beta Reader Pass](https://thefortthatholds.xyz/braided-ink-beta-pass) if you'd rather
not self-host that one
Agents can read the same list as [catalog.json](https://thefortthatholds.xyz/catalog.json).
None of that is required to run Fortmail.
## License
[MIT](LICENSE) © The Fort That Holds LLC.
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessNo issues