English | 日本語
An email inbox for AI agents. One Cloudflare Worker = one email address, with two clients on top: a CLI for agents, and a web UI for humans.
Your agent gets a real address it can read, search, reply from, and triage — entirely from the shell, with --json output on every read/write command. Everything runs on your Cloudflare account: no third-party inbox service, no OAuth into a personal mailbox, no IMAP polling. Incoming mail is parsed and stored in D1/R2, classified by Workers AI (spam verdict + one-line summary), and indexed twice: BM25 full-text (Japanese-aware) and semantic search over bodies and PDF/image attachments via AI Search.
And when you want to look at the same mailbox: ainbox Web UI is a hosted 3-pane mail client (shadcn/ui) that signs in to your own Worker straight from the browser — nothing to install, and the hosted UI has no backend of its own, so nothing is stored server-side (Part 3).
Reality check before you start: this is a self-hosted, single-tenant tool. You need a Cloudflare account with a domain on it (a zone), the Workers Paid plan is recommended, and initial setup takes roughly 15 minutes including a few Cloudflare dashboard steps (AI Search instance, Access policy, Email Routing rule). If you want a hosted inbox in five minutes, this is not that.
receive: Email Routing ─► Worker email() ─► parse (postal-mime)
├─► D1: metadata + text body (Drizzle ORM)
├─► R2: raw .eml / HTML / attachments / search doc
├─► Workers AI: spam verdict + summary (async, never blocks mail)
└─► search index: FTS5 (BM25) + AI Search (pushed in near-realtime)
send: ainbox CLI / Web UI ─► Cloudflare Access ─► Worker /api ─► Email Sending
└─► sent copy stored & searchable
ask: ainbox ask "…" ─► AI Search RAG over the whole mailbox, with sources
Beta, but the full pipeline is verified end-to-end in production: send → receive loop, Japanese/English BM25 search, AI spam classification, semantic search + RAG, the whole CLI, and Web UI sign-in (browser SSO) against a production Worker. Known limitations:
- Receiving attachment-heavy mail is code-complete but less battle-tested than the text path.
- Multi-message operations (
mark,delete,classifywith many ids) issue one request per id. - Single-tenant by design: anyone who passes Cloudflare Access has full mailbox access.
- Cloudflare account with your domain as a zone (Email Routing and Email Sending both attach to a zone)
- Workers Paid plan recommended (~$5/mo) — D1/R2/Workers AI/AI Search are usage-based on top; personal mail volume typically lands in the cents-to-few-dollars range, with AI Search indexing and Workers AI classification as the main variable costs (pricing)
- Node.js 20+ and pnpm (for deploying the Worker)
- Total email size (body + attachments) up to 25 MiB per send
git clone https://github.com/inaridiy/ainbox && cd ainbox
pnpm install
cp packages/worker/wrangler.jsonc.example packages/worker/wrangler.jsonc
pnpm build # root build (shared → worker → cli) — building only the worker fails on a fresh clone
cd packages/workerThe wrangler commands below run against your Cloudflare account — run pnpm exec wrangler login first if this machine isn't authenticated yet.
1. Enable Email Sending for your domain (sets up SPF/DKIM automatically):
pnpm exec wrangler email sending enable yourdomain.com2. Create the database and bucket:
pnpm exec wrangler d1 create ainbox-db # paste the printed database_id into wrangler.jsonc
pnpm exec wrangler r2 bucket create ainbox-mail3. Create the AI Search instance (indexes only the search corpus, not raw MIME):
pnpm exec wrangler ai-search create ainbox-search --type r2 --source ainbox-mail \
--include-items "mail/**" --include-items "att/**"4. Edit wrangler.jsonc — set EMAIL_ADDRESS (e.g. agent@yourdomain.com), SENDER_NAME, and the D1 database_id from step 2. Then:
pnpm db:migrate # applies migrations to the remote D1
pnpm deploy # note: deploys immediately, no confirmation promptNote the Worker URL the deploy prints (https://ainbox.<your-subdomain>.workers.dev) — Parts 2 and 3 both need it.
5. Protect the Worker with Cloudflare Access. Four dashboard steps, then one config change and a redeploy:
- Cloudflare dashboard → Compute (Workers) → your Worker → Settings → Domains & Routes → on the workers.dev row: … → Enable Cloudflare Access. Copy the AUD and team domain shown in the modal (this auto-creates an Access application for the URL).
- Zero Trust dashboard → Access controls → Service credentials → Service Tokens → Create Service Token. Copy the Client ID and Client Secret — the secret is shown only once.
- Zero Trust → Access controls → Applications → the auto-created app → Policies → add a policy with Action: Service Auth and Include: Service Token = the token you just created. (Without this, CLI requests get redirected to a login page — the default Allow action only works for browser SSO.)
- Same app → settings (optional — only needed for the Web UI; skip if you'll only ever use the CLI): under Advanced settings, enable Bypass OPTIONS requests to origin — required by both Web UI auth modes, since CORS preflights carry no credentials. Under Cookie settings, set SameSite Attribute to None — required for browser SSO only, so the Access session cookie rides on cross-site requests from the UI's origin.
Put the AUD and team domain into wrangler.jsonc (ACCESS_AUD, ACCESS_TEAM_DOMAIN — the full …/cdn-cgi/access/certs URL is accepted too) and pnpm deploy again. The API refuses all requests (503) until Access is configured — it fails closed, never open.
6. Enable Email Routing and route your address to the Worker:
pnpm exec wrangler email routing enable yourdomain.com # one-time: adds the zone's MX records
pnpm exec wrangler email routing rules create yourdomain.com \
--name "ainbox" --match-type literal --match-field to --match-value agent@yourdomain.com \
--action-type worker --action-value ainboxnpm i -g ainbox
ainbox auth login # interactive: URL + service token → verifies & saves
ainbox auth login --browser # humans: browser SSO instead (requires cloudflared installed locally)
ainbox statusProfiles support multiple inboxes (ainbox auth login work, ainbox -p work list). Config lives in ~/.config/ainbox/config.json by default (mode 0600; AINBOX_CONFIG overrides the path, AINBOX_PROFILE selects a profile).
ainbox send --to agent@yourdomain.com -s "hello" -t "from my agent" # your own address — closes the send→receive loop
ainbox list --unread # arrives within seconds via Email Routing
ainbox show <id> # body + attachments; spam verdict + AI summary appear shortly
ainbox reply <id> -t "thanks!" # threading headers handled automatically
ainbox search invoice # BM25 full-text, CJK-aware
ainbox search --ai "contract renewal" # semantic, includes PDF/image attachments
ainbox ask "when is the next invoice due?" # RAG answer with source message idsYou need three things from whoever deployed the Worker (that's you, if you just did Part 1): the Worker's URL, the Access settings from Part 1 step 5-4, and either an Access policy that allows your identity (for browser SSO) or a service token (for token mode). Note the UI is currently Japanese-only.
Open https://ainbox-ui.inaridiy.workers.dev and add an account (アカウントを追加): enter a display name and the Worker's URL, then pick an auth mode — both require step 5-4's Bypass OPTIONS toggle:
- Browser SSO (ブラウザSSO) — a popup runs the normal Cloudflare Access login; the UI detects the session and closes the popup automatically. Additionally requires the SameSite: None cookie setting from step 5-4.
- Service token (サービストークン) — paste an Access service token's Client ID/Secret (the same mechanism the CLI uses). Use this on Safari and other browsers that block third-party cookies, where SSO cookies can't cross origins.
The UI is a stateless static SPA (packages/web: Vite + React + TanStack Router/Query + Tailwind v4 + shadcn/ui, in the visual language of the shadcn Mail example). One public deployment serves everyone; accounts live in your browser's localStorage, and you can add several Workers and switch between them like mail accounts. Local dev, self-hosting your own copy, and the UI_ORIGINS allowlist: packages/web/README.md.
Every read/write command takes --json (or set AINBOX_JSON=1; the binary downloads raw and attachment write files instead); errors exit non-zero with a message on stderr. Authentication can come entirely from environment variables — no config file needed:
export AINBOX_URL=https://ainbox.yourname.workers.dev
export AINBOX_ACCESS_CLIENT_ID=xxxx.access
export AINBOX_ACCESS_CLIENT_SECRET=yyyy
ainbox list --unread --json | jq -r '.messages[] | "\(.id)\t\(.subject)\t\(.aiSummary)"'
ainbox show msg_xxx --json | jq .text
ainbox send --to a@b.com -s "Re: order" --body-file reply.txt --jsonFor Claude Code and compatible agents, a ready-made skill ships in skills/ainbox/ — copy the folder into your agent's skills directory and it knows the whole CLI, including triage etiquette (e.g. confirm before delete).
| Command | Description |
|---|---|
ainbox status |
connectivity check + unread count |
ainbox list (ls) [-u] [-n 20] [--direction in|out] [--from x] [--since ISO] [--archived] [--spam exclude|include|only] [--cursor c] |
newest first, cursor pagination |
ainbox show <id> [--keep-unread] |
full message (marks read by default) |
ainbox raw <id> [-o file | -o -] |
download original MIME (.eml) |
ainbox attachment <id> <index|filename|attId> [-o path] |
download attachment |
ainbox send --to a@b[,c@d] -s subj [-t text | --body-file f | stdin] [--cc x] [--bcc x] [--reply-to a@b] [--html-file f] [--attach f]… |
send |
ainbox reply <id> [-t text | --body-file f | stdin] [-a] |
reply / reply-all with threading |
ainbox search <query> [--ai] [-n 10] |
BM25 full-text / --ai semantic |
ainbox ask <question> |
RAG answer over the mailbox |
ainbox mark <id…> --read|--unread|--archive|--unarchive|--spam|--not-spam |
update flags |
ainbox classify <id…> |
re-run AI spam verdict + summary (needs SPAM_FILTER=true) |
ainbox delete <id…> (rm) [-y] |
permanent delete (asks first on a TTY) |
ainbox auth login|list|use|logout|status |
profile management |
Global flags: -p/--profile <name>, -j/--json.
All /api/* routes require a Cloudflare Access JWT (Cf-Access-Jwt-Assertion); the Worker verifies signature, issuer, and audience itself (keys cached 1h) — it never trusts the header's mere presence.
GET /api/status
GET /api/messages?limit&cursor&unread&archived&direction&from&since&spam
GET /api/messages/:id?read=true
GET /api/messages/:id/raw
GET /api/messages/:id/attachments/:attId
PATCH /api/messages/:id {read?, archived?, spam?}
DELETE /api/messages/:id
POST /api/messages/:id/reply {text, html?, all?}
POST /api/messages/:id/classify
POST /api/send {to[], cc?, bcc?, subject, text, html?, replyTo?, attachments?}
GET /api/search?q=&limit= FTS5 BM25 (LIKE fallback)
GET /api/search/ai?q=&limit= AI Search (semantic)
POST /api/ask {question}
- Perimeter: Cloudflare Access sits in front of the Worker. Agents authenticate with service tokens (
CF-Access-Client-Id/Secretheaders); humans via browser SSO. Access mints a JWT that the Worker independently verifies (signature + issuer + audience). - Fail closed: if
ACCESS_TEAM_DOMAIN/ACCESS_AUDare unset, the API returns 503 for everything.DEV_ALLOW_UNAUTHENTICATED=truedisables auth — it only takes effect while those two Access vars are empty, and exists for localwrangler devonly; never set it on a deployed Worker. - Blast radius: this is single-tenant. Any principal that passes Access can read, send, and delete all mail. A leaked service token = full mailbox access until you revoke it in Zero Trust.
- On disk: the CLI stores profiles (including the service token secret, in plaintext) at
~/.config/ainbox/config.jsonwith mode 0600. - Web UI: the hosted UI is a static SPA with no server-side state; accounts — including service-token secrets, if you use token mode — live in the browser's
localStorage. The Worker accepts cross-origin browser calls only from the built-in UI origins (plus any you add viaUI_ORIGINS). - Data flow: mail bodies and attachments are processed by Workers AI (classification) and AI Search (indexing) within your Cloudflare account.
cd packages/worker
cp .dev.vars.example .dev.vars # allows unauthenticated requests locally: blanks the Access vars + sets DEV_ALLOW_UNAUTHENTICATED
pnpm db:migrate:local
pnpm devThe send_email / ai_search bindings are remote-only: wrangler dev proxies them to the real services and won't start until the AI Search instance exists. Binary attachments can't be sent through the remote binding in local dev — test those against a deployed Worker.
Deploy another copy of the Worker with a different name, EMAIL_ADDRESS, D1 database, R2 bucket, and AI Search instance in wrangler.jsonc, then ainbox auth login <profile> for it. 1 worker = 1 address is the design, not a workaround — each inbox keeps its own data stores and auth boundary.
- D1 (Drizzle ORM) holds metadata and text; R2 holds raw MIME (
raw/), HTML (html/), attachments (att/), and a Markdown rendition per message (mail/) that serves as the search corpus. - Japanese-ready full-text search: FTS5 with
Intl.Segmenterword segmentation applied identically at index and query time; queries become adjacency-preserving phrases, with an automatic LIKE fallback (the response reports whichmodeserved the hits). - AI Search in near-realtime: new mail is pushed into the index via
items.upload()at ingest instead of waiting for the periodic R2 bucket sync (which remains as reconciliation). Attachment files are indexed too, so PDFs are semantically searchable. - Spam filtering never risks mail: classification runs after durable storage via
ctx.waitUntil; on failure the message simply stays unclassified (ainbox classifyre-runs it). - End-to-end types: both clients consume the Worker's routes through Hono RPC (
hc<AppType>), so paths, params, and bodies are compile-time checked across the wire. - A web client with no backend of its own: the SPA calls the Worker's
/apidirectly through Cloudflare Access (session cookie or service token); the Worker pins CORS to known UI origins, and theainbox-uiWorker serves static assets only.
See AGENTS.md for contributor setup and repo-specific rules.
MIT © inaridiy. Issues and PRs welcome — please run pnpm test and pnpm typecheck before submitting.
