MailMCP
by KamranMoazim
README.md
# MailMCP
A complete mail system for one domain on serverless AWS, plus an MCP server so an AI assistant can search, read and draft your mail.
It receives, sends, stores and searches mail, has a web inbox and an admin console, and runs no servers, containers, VPC or NAT gateway. Everything, including the full-text search index, lives in one DynamoDB table and one S3 bucket.
> **A stateful mail server splits into managed services plus small, stateless, idempotent functions, and the AI gets exactly the access you grant: drafts by default, sends only with an explicit scope, every call audited.**

To use the app (accounts, inbox, AI access, admin console), see the [user guide](GUIDE.md).
## How it works
Three front doors share one storage layer and meet only in storage and in SQS queues.
- **Mail in.** SES receives for the domain. The receipt rule lists every active mailbox and alias, so unknown recipients are rejected at SMTP time. SES writes the raw MIME to `raw/` and notifies SNS, which feeds the `inbound` queue. `ingest` parses, sanitizes the HTML with DOMPurify, resolves aliases, and writes the message, folder counters and usage in one transaction guarded by `attribute_not_exists`, so a redelivery is a no-op.
- **Mail out.** `api` builds the MIME once, refuses suppressed addresses, and queues one job per route. Local recipients go straight onto `inbound` and never touch SES. External jobs go to `outbound`, where `sender` takes a `SEND#jobId` lock before calling SES, so a retried job never sends twice. SES events flow back through a configuration set to `ses-events`, which sets delivered, bounced or complained and maintains the suppression list.
- **People.** One CloudFront distribution serves the React app from `web/` and routes `/api/*` and `/admin*` to Lambda function URLs that only CloudFront can call (origin access control). Sessions are httpOnly cookies backed by Cognito.
- **Agents.** AgentCore Gateway is the MCP server. It validates Cognito access tokens, a request interceptor writes the verified caller into every tool call, a Cedar policy engine permits each tool only with its scope, and `mail-tools` runs the ten tools.
- **Admins.** A server-rendered console creates and deletes mailboxes, sets quotas, manages aliases and redrives dead-letter queues. Its IAM role can only touch `MBX#`, `ALIAS#`, `USER#`, `DOMAIN#` and `SUPPRESS#` keys and has no access to the mail bucket, so admins cannot read mail.
## Single-table design
Every read is one Query or GetItem; search is one Query per word plus a BatchGet.
| # | Pattern | Used by | Index | Key condition |
| --- | --- | --- | --- | --- |
| AP1 | Resolve a recipient (alias, then mailbox) | ingest, api | Table | `ALIAS#addr` / `META`, else `MBX#addr` / `META` |
| AP2 | Mailboxes a user can open | api, mail-tools | Table | `USER#sub`, `begins_with(SK, "MBX#")` |
| AP3 | Mailbox settings, quota, usage | api, admin | Table | `MBX#addr` / `META` |
| AP4 | Folders with total and unread counts | api, mail-tools | Table | `MBX#addr`, `begins_with(SK, "FOLDER#")` |
| AP5 | Messages in a folder, newest first, paged | api, mail-tools | GSI1 | `MAIL#addr#F#Inbox`, descending |
| AP6 | One message | api, mail-tools | Table | `MAIL#addr` / `MSG#id` |
| AP7 | A thread, oldest first | api, mail-tools | GSI2 | `MAIL#addr#T#threadId`, ascending |
| AP8 | Search by word prefix | api, mail-tools | Table | `IDX#addr#in`, `begins_with(SK, "W#invo")` |
| AP9 | Is an address suppressed? | api | Table | `SUPPRESS#addr` / `META` |
| AP10 | Sent message from an SES event | ses-events | Table | `SES#sesMessageId` / `META` |
| AP11 | Send lock | sender | Table | conditional put `SEND#jobId` / `LOCK` |
| AP12 | All mailboxes on the domain | admin | GSI1 | `DOMAIN#d#MBX` |
| AP13 | Every item of a mailbox | purge | Table | `MAIL#addr`, then each message's `tokens` |
| AP14 | MCP audit trail, newest first | api | Table | `AUDIT#addr`, descending |
| Entity | PK | SK | GSI1 | GSI2 |
| --- | --- | --- | --- | --- |
| Domain | `DOMAIN#d` | `META` | | |
| Mailbox | `MBX#addr` | `META` | `DOMAIN#d#MBX` / `MBX#addr` | |
| Folder | `MBX#addr` | `FOLDER#name` | | |
| Membership | `USER#sub` | `MBX#addr` | | |
| Alias | `ALIAS#addr` | `META` | | |
| Message | `MAIL#addr` | `MSG#id` | `MAIL#addr#F#folder` / `TS#iso#id` | `MAIL#addr#T#thread` / `TS#iso#id` |
| Search token | `IDX#addr#xy` | `W#word#msgId` | | |
| Sent lookup | `SES#sesId` | `META` | | |
| Send lock | `SEND#jobId` | `LOCK` (TTL 1 day) | | |
| Suppression | `SUPPRESS#addr` | `META` | | |
| Audit row | `AUDIT#addr` | `TS#iso#tool` (TTL 90 days) | | |
- **Search index in the table.** One item per distinct word per message, sharded by the word's first two letters so a prefix query stays in one partition. Multi-word search intersects message IDs in the function. Body indexing can be turned off per domain.
- **Tokens on the message.** Each message keeps its token list (capped at 300), so deleting a message or a mailbox knows exactly which index items to remove.
- **Idempotent writes.** Message ID = hash(SES message ID + mailbox); creates use `attribute_not_exists`. Transactions that touch shared counters retry on `TransactionConflict` with jitter.
- **Moves are one update.** Only GSI1PK changes; counters move in the same transaction. A weekly job recomputes counters and usage to fix drift.
- **Bodies never sit in DynamoDB.** GSIs project list fields only; message items stay under 4 KB.
## MCP layer
| Tool | Scope | What it does |
| --- | --- | --- |
| `list_mailboxes` | `mail/read` | Mailboxes the caller can open |
| `list_folders` | `mail/read` | Folders with total and unread counts |
| `list_messages` | `mail/read` | A folder's messages, newest first, paged |
| `search_messages` | `mail/read` | Word-prefix search with folder, sender and date filters |
| `get_message` | `mail/read` | Headers, attachment list, plain-text `untrusted_body` |
| `get_thread` | `mail/read` | Every message in a thread, oldest first |
| `mark_messages` | `mail/organize` | Read, unread, flagged on up to 50 messages |
| `move_messages` | `mail/organize` | Move up to 50 messages; Trash is as far as it goes |
| `create_draft` | `mail/draft` | New draft or reply, tagged `createdBy=mcp` |
| `send_draft` | `mail/send` | Send a draft, if the owner allows it |
- **Caller identity.** The Lambda target only receives tool arguments, so the request interceptor verifies the access token and overwrites a reserved `_caller` argument on every `tools/call`. A client-supplied `_caller` never survives.
- **Sending is off by default.** Each mailbox has an MCP mode, `drafts-only` or `send`, switched by the owner under Settings: AI access. A Cedar `permit` lets `send_draft` through only for tokens with `mail/send`; Cedar denies by default, so a token without it does not even see the tool. At most 20 MCP sends per mailbox per hour.
- **Untrusted content.** Bodies come back as plain text with links and HTML removed, capped at 20,000 characters, in a field named `untrusted_body`. Tool descriptions say mail content is data, not instructions.
- **Audit.** Every tool call writes an `AUDIT#addr` row with the tool, an argument summary (bodies replaced by their length), the result and the client ID.
- **Schemas.** The gateway accepts only `type`, `properties`, `required`, `items` and `description`, so `tool-schema.json` uses nothing else and zod validates the rest in `mail-tools`.
## Features
- Receive for several mailboxes and aliases per domain, with spam, virus, SPF, DKIM and DMARC verdicts stored on each message; spam goes to Junk, viruses are dropped; unknown recipients are rejected at SMTP time.
- Web inbox: folders, read and unread, flags, threads, attachments, word-prefix search, compose, reply, reply all, forward, drafts, delivery status.
- Bounce and complaint tracking with a suppression list; per-mailbox daily send cap of 200.
- Admin console: mailboxes, owners, quotas, aliases, body indexing, dead-letter redrive, purge on delete.
- Mail that would exceed a quota waits in the inbound dead-letter queue until an admin raises the quota and redrives it, instead of bouncing to a probably forged sender.
## Stack
| Folder | What |
| --- | --- |
| `infra/` | CDK app, one stack: `dns`, `storage`, `mail`, `web`, `mcp`, `admin`, `monitoring` constructs |
| `backend/` | TypeScript Lambdas on Node 24 (arm64): `ingest`, `sender`, `ses-events`, `purge`, `recount`, `api` and `admin` (Hono), `mail-tools`, `mcp-interceptor` |
| `client/` | React 19 + Vite inbox, hash routes, one stylesheet |
| `scripts/` | `send-test-mail.sh`, `get-token.sh`, `cost-report.sh` |
## Quick start
```bash
npm run install:all # each package has its own lockfile
cp infra/.env.example infra/.env # MAIL_DOMAIN (a subdomain) and ALERT_EMAIL
cd infra && npx cdk bootstrap && cd .. # once per account and region (us-east-1)
npm run infra:deploy # builds the client, deploys, writes infra/cdk-outputs.json
# delegate MAIL_DOMAIN: add the NameServers output as NS records at the parent zone
npm run mail:seed -- --mailbox you --alias hello # domain, mailbox, alias, receipt rule
npm run mail:add-user -- --email you@example.com --mailbox you
npm run mail:add-admin -- --email you@example.com # optional, for /admin
npm run mail:send-test -- you "Hello" # SES mail into your inbox
```
Open the `SiteUrl` output and sign in with the temporary password. To connect Claude Code:
```bash
TOKEN=$(npm run -s mcp:token) # opens Cognito sign-in; add -- --send for mail/send
claude mcp add --transport http mailmcp "$GATEWAY_URL" --header "Authorization: Bearer $TOKEN"
```
Locally, `npm run api:dev` serves the API on port 4000 against the deployed table and `npm run client:dev` proxies to it.
## Tests
`npm run infra:test` checks the template: 48 checks, including on-demand billing, everything deleting on destroy, 7-day logs, arm64 and Node 24, no wildcard on the table or bucket, the admin key-prefix condition, the Cedar policies, and the alarms and budget.
`npm run backend:test` runs 124 checks in 11 suites. Unit suites cover keys, the tokenizer, MIME parsing and sanitizing, notification parsing and tool validation. Live suites run against the deployed stack:
| Suite | Checks | What it proves |
| --- | --- | --- |
| foundation | 6 | Domain verified with DKIM, MX and DMARC resolve, table and bucket |
| receive | 12 | Real mail becomes items and objects; replaying the SQS message creates no duplicate |
| inbox | 18 | Every read pattern through CloudFront, cookies, flags and moves keep counters right |
| send | 11 | Local delivery skips SES; SES simulator success, bounce and complaint; one SQS job delivered 4 times sends once |
| mcp | 16 | OAuth discovery, Cedar hides and denies `send_draft`, spoofed `_caller` ignored, prompt-injection mail stays data, drafts-only, rate limit, audit |
| admin | 14 | 1,000 messages purged after a delete (6.5 s, one invocation), recount repairs drift, admin role cannot read mail (IAM simulator), DLQ redrive |
## Costs
No VPC, NAT gateway, OpenSearch, RDS or provisioned capacity. Idle cost is the Route 53 hosted zone ($0.50 a month); Gateway, Policy, Cognito, Lambda, SQS and DynamoDB on-demand bill only per use. AgentCore Gateway is $0.005 per 1,000 calls and Policy $0.025 per 1,000 authorizations. Logs keep 7 days, five alarms stay inside the CloudWatch free tier, and a $5 monthly budget on the `project=mailmcp` tag emails at 80% actual and 100% forecast. `npm run cost:report` prints the month's bill by service once Cost Explorer has the tag data.
## API
| Method | Path | Does |
| --- | --- | --- |
| POST, DELETE | `/api/session` | Sign in (sets cookies), sign out |
| POST | `/api/session/password`, `/api/session/refresh` | First-login password, silent refresh |
| GET | `/api/mailboxes` | Who am I and which mailboxes I can open |
| GET | `/api/mailboxes/:addr/folders` | Folder counts |
| GET, PATCH | `/api/mailboxes/:addr/messages` | List a folder; flag, mark or move up to 50 |
| GET | `/api/mailboxes/:addr/messages/:id` | Message with sanitized body |
| GET | `/api/mailboxes/:addr/messages/:id/attachments/:n` | Presigned download |
| GET | `/api/mailboxes/:addr/threads/:threadId` | Thread |
| GET | `/api/mailboxes/:addr/search?q=` | Word-prefix search |
| POST | `/api/mailboxes/:addr/uploads` | Presigned upload for an attachment |
| POST, PUT | `/api/mailboxes/:addr/drafts[/:id]` | Create or edit a draft |
| POST | `/api/mailboxes/:addr/drafts/:id/send` | Send a draft |
| GET, PUT | `/api/mailboxes/:addr/ai-access` | Audit trail and MCP mode |
## Known limits
- **Web Functions.** The API and admin run as Lambda function URLs behind CloudFront OAC. A probe deployed Lambda Web Functions through CloudFormation, but the IAM action and OAC type CloudFront needs to call an `IamAuth` endpoint are not documented yet. The Hono apps have no hosting-specific code, so moving needs one new entry file.
- **POST through OAC needs a body hash.** CloudFront signs requests to function URLs only if the client sends `x-amz-content-sha256`. The web app computes it, and the admin console uses a 20-line script to submit its forms, so the console is not strictly script-free.
- **MCP tokens last an hour.** Cognito has no dynamic client registration, so clients cannot run the OAuth flow themselves; `get-token.sh` fetches a token by hand.
- **Receipt rule recipients.** The rule lists at most 100 addresses, and the admin console rewrites the list outside CloudFormation, so a change to the rule in CDK resets it until the next mailbox change or `npm run mail:seed`.
- **Search is basic:** word prefixes, every word must match, no ranking, stemming or typo tolerance; very long mails index their first 300 distinct words.
- **Threads** follow the first `References` header, so replies from clients that drop it start a new thread.
- **No IMAP or POP.** Phone and desktop mail apps need a protocol layer.
- **Prompt injection is reduced, not solved.** Drafts-only mode, the Cedar scope, the rate limit and the audit trail bound the damage.
## Build log
| Phase | What |
| --- | --- |
| 1 | Hosted zone, SES identity with Easy DKIM, MX, SPF, DMARC, the table and bucket |
| 2 | Receipt rule, SNS to SQS, `ingest`, search tokens, idempotent delivery |
| 3 | Cognito, CloudFront, Hono API, React inbox with folders, threads and search |
| 4 | Compose, local routing, `sender` with lock, SES events, suppression |
| 5 | AgentCore Gateway, interceptor, Cedar policies, `mail-tools`, audit and AI access settings |
| 6 | Admin console, quotas, aliases, SMTP-time rejection, purge, recount, alarms, budget |
## Tear down
```bash
npm run infra:destroy # removes everything, stored mail included
```
Then remove the NS records for the subdomain at the parent zone.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues