Shoot Email
An MCP server that provides AI agents with a persistent, agent-native email mailbox for sending, receiving, and managing emails through bounded-context retrieval, idempotent operations, and explicit acknowledgement.
README
Shoot Email
A real email inbox for AI agents.
Current AI assistants can help read email and generate a polished reply, but many connected-email workflows still stop at the draft. A human has to send the message, watch for a response, decide what should happen next, and keep the conversation moving manually.
Shoot Email explores a different question: what would email look like if it were designed from the ground up for AI agents instead of human inboxes?
Shoot Email is an LLM-first email service and MCP server that gives ChatGPT and other AI agents a persistent mailbox. An agent can send text email, receive replies through webhooks, retrieve complete messages, preserve conversation history, and explicitly acknowledge the messages it has successfully handled. The same service contract is available through MCP and a terminal CLI.
Demo
Watch the Shoot Email demo on YouTube
Why Agent-Native Email
Traditional email clients optimize for a person scanning folders, opening one message at a time, and marking messages read. Shoot Email optimizes for an LLM that needs bounded context, reliable retries, and explicit state transitions:
- Full message bodies are returned in bounded batches so an agent can summarize and act without making a second call for every email.
- Retrieval and acknowledgement are separate, so a failed model invocation does not silently lose work.
- Inbound email is labeled as untrusted external content so email text cannot silently become authorization for additional tool calls.
- Outbound requests are idempotent, preventing an agent retry from sending the same message twice.
- Stable mailbox identities and message history persist beyond one chat session.
- Configurable quotas, recipient limits, reserved aliases, and a global kill switch protect the shared sending domain from obvious abuse.
The result is not another inbox UI. It is a small, structured communication layer that an AI agent can reason over and operate safely.
What Works Today
The project currently includes:
- A local CLI and a 10-tool MCP interface over the same versioned service contract.
- Stable generated mailbox aliases with optional registered custom aliases.
- Real inbound delivery through Cloudflare Email Routing and an Email Worker.
- Text-only outbound delivery through Cloudflare Email Sending, with a mock provider for safe local and judge testing.
- PostgreSQL persistence with pgvector enabled for future semantic search.
- A hosted Cloudflare Worker connected to Neon through Hyperdrive.
- Idempotent inbound ingestion, idempotent outbound requests, message history, bounded inbox retrieval, and explicit acknowledgement.
- Database-backed integration coverage for the CLI, MCP transports, identity, message lifecycle, provider behavior, and abuse controls.
- A restricted hosted Streamable HTTP MCP demo with isolated synthetic mailboxes and real outbound delivery disabled.
Architecture
ChatGPT / Codex / another MCP client
|
v
Shoot Email MCP
|
v
Cloudflare backend Worker ---- Hyperdrive ---- Neon Postgres
|
+------+------+
| |
v v
Email Sending inbound webhook
^
|
Cloudflare Email Worker
^
|
Email Routing
The production path uses Cloudflare for DNS, routing, edge processing, and outbound delivery, while Neon stores identities, sessions, messages, delivery state, and future embeddings. The hosted judge demo exercises the real remote MCP and database path but deliberately uses the mock provider and disables real outbound delivery.
Hosted Demo
The judge demo is available at:
https://shoot-email-backend.powersurge.workers.dev/mcp
Each judge receives an isolated synthetic mailbox containing three landlord
replies for the apartment-hunting demo. The bearer secret is supplied privately
with the submission. See
docs/hackathon/TESTING.md for the exact setup,
black-box prompts, and security boundary.
Supported Platforms
- The hosted MCP works with clients that support Streamable HTTP MCP and bearer tokens, including Codex.
- Local CLI and MCP development are tested with Node.js, npm, Docker Desktop, and a POSIX-compatible shell on macOS.
- The application code is portable to Linux, but the documented local workflow has not yet been independently tested on Linux or Windows.
Setup
Install dependencies:
npm install
Create environment config:
cp .env.example .env
Start Postgres with pgvector:
docker compose up -d postgres
If your Docker install does not include Compose, run an equivalent container:
docker run --name shoot-email-postgres \
-e POSTGRES_USER=shoot_email \
-e POSTGRES_PASSWORD=shoot_email \
-e POSTGRES_DB=shoot_email \
-p 5432:5432 \
-d pgvector/pgvector:pg16
Apply migrations:
npm run db:migrate
Reset the local database and reapply all migrations:
npm run db:reset
This drops and recreates the local public schema, so only use it for local development data.
Hosted Backend
The selected hosted architecture is Cloudflare Workers plus Neon Postgres,
connected through Cloudflare Hyperdrive. See
docs/adr/001-cloudflare-workers-neon-hyperdrive.md for the decision and
tradeoffs.
The backend Worker currently exposes:
GET /health
GET /ready
POST /webhooks/email/inbound
POST /mcp
/health verifies the Worker is running. /ready also verifies the Hyperdrive
database path. The local-only /apps/openai/context route is hidden on the
Worker unless ENABLE_UNAUTHENTICATED_OPENAI_CONTEXT=true; never enable that
flag in a public environment.
Build the Worker bundle without deploying:
npm run backend:build
Before deployment, create Neon Postgres, run npm run db:migrate with its
direct DATABASE_URL, and create a Hyperdrive configuration with SQL query
caching disabled. Add the resulting binding to
workers/backend/wrangler.jsonc as HYPERDRIVE. Store the webhook token with
wrangler secret put INBOUND_WEBHOOK_TOKEN; do not add it to the Wrangler
configuration file.
The application reads its least-privilege Email Sending token from
CLOUDFLARE_EMAIL_API_TOKEN. Do not name that application secret
CLOUDFLARE_API_TOKEN, because Wrangler reserves the generic name for its own
deployment authentication.
The staging backend is deployed at:
https://shoot-email-backend.powersurge.workers.dev
It uses the shoot-email Neon project in AWS Oregon through the
shoot-email-neon Hyperdrive configuration. Hyperdrive query caching is
disabled and its origin connection limit is five. Staging keeps
MAIL_PROVIDER=mock and OUTBOUND_SENDING_ENABLED=false. The hosted demo may
exercise the outbound contract through simulated mock sends; the disabled flag
still prevents real-provider delivery.
The /mcp route is a restricted Build Week judge demo protected by a bearer
credential. It maps each credential suffix to an isolated, server-controlled
principal and seeds three synthetic landlord replies on first initialization. It never
trusts caller-supplied _meta identity. This is not the production ChatGPT
authentication design; OAuth 2.1 remains the required follow-up. Complete judge
instructions are in docs/hackathon/TESTING.md.
Demo principals are accepted by send_text_email only while the configured
provider is mock. They are rejected directly if the deployment is ever
switched to a real provider, independently of the environment-level outbound
kill switch.
The production Email Routing Worker now posts inbound messages to this hosted
backend. Both a synthetic webhook smoke test and a real Gmail-to-Email-Routing
test have passed. The latter stored and acknowledged the marker
HOSTED-ROUTING-001 in Neon.
Run the hosted synthetic smoke test with a direct Neon migration connection:
DATABASE_URL="$NEON_DATABASE_URL" \
HOSTED_BACKEND_URL="https://shoot-email-backend.powersurge.workers.dev" \
npm run smoke:hosted
CLI Usage
Create or reuse a local mailbox:
npx shoot-email init
All non-compact user commands return a versioned JSON envelope with
contractVersion, ok, and error. Successful operations return
"error": null; failures return a stable error code, retryable, and
retryAt when a later retry is appropriate. The current contract version is
2.0.
Show the current address:
npx shoot-email address
Inspect the active provider before any side effect:
npx shoot-email status
status identifies the environment and server time, simulation versus
production mode, sender identity, outbound availability, account tier, current
quota usage and reset times, and the enforced request and retrieval limits.
When invoked through MCP with a conversation identity, it also reports that
session's hourly quota. Internal user UUIDs are not exposed. The mock provider
is explicitly reported as simulated and does not consume abuse-control quotas.
Show or configure the structured sender identity:
npx shoot-email identity show
npx shoot-email identity set --display-name "Serguei"
npx shoot-email identity clear
npx shoot-email identity alias set --alias serguei
Configured names are sent as Serguei via Shoot Email <u_example@yoyowza.com>. Without one, the default is Shoot Email <u_example@yoyowza.com>.
Custom aliases require a registered account. They use lowercase ASCII local parts between 3 and 32 characters, reject reserved system/role/brand names, and have a configurable 30-day change cooldown. Previously used addresses remain valid for inbound delivery and can never be assigned to another user.
Send a text email with the mock provider:
npx shoot-email send \
--request-id 550e8400-e29b-41d4-a716-446655440000 \
--to someone@example.com \
--subject "Hi" \
--text "Hello"
send returns structured JSON. Reusing the same request ID with identical
content returns the existing result without contacting the provider again.
Reusing it with different content returns an idempotency_key_reused error.
Requests with an ambiguous provider outcome are stored as unknown and are not
retried automatically.
The response fields distinguish the current attempt from an existing request:
providerCalledsays whether this invocation contacted the provider.idempotentReplayis true only for an identical retry.existingRequestsays the request ID was already reserved.- A conflicting retry returns
message: nulland puts the unchanged original record inexistingMessage. simulatedis true for mock outcomes; mock delivery details also carrysimulated: true.
Outbound timestamps have fixed meanings: createdAt is local reservation
time, providerAttemptedAt is the first provider-attempt time, and
providerResultAt is when a known provider result was persisted.
Query prior sends or a single persisted result without resending:
npx shoot-email outbound list
npx shoot-email outbound status --request-id <request-id>
npx shoot-email outbound status --message-id <message-id>
The sender name and address are persisted with each outbound message and are part of its idempotent content. Changing the sender identity requires a new request ID.
Outbound Abuse Controls
Outbound sending enforces configurable guest, registered-user, session, and global quotas before contacting the provider. The defaults are intentionally conservative for a new sending domain: guests receive 3 sends/hour and 10/day; registered users receive 10/hour and 50/day; the application is capped at 20/hour and 100/day. New-recipient and minimum-interval limits also apply. Hourly and daily counters use fixed UTC calendar buckets. The built-in mock provider is exempt because it does not send external mail.
Show the current mailbox policy and usage:
npx shoot-email abuse status
Administer a user by internal ID:
npx shoot-email abuse tier <user-id> --tier registered
npx shoot-email abuse suspend <user-id> --reason "Repeated abusive sends"
npx shoot-email abuse reactivate <user-id>
Set OUTBOUND_SENDING_ENABLED=false as the global emergency kill switch. All
policy rejections are persisted by request ID and returned as structured JSON;
retrying a rejected request neither reevaluates it nor consumes quota.
Return up to 50 oldest pending messages with full text bodies as structured JSON:
npx shoot-email inbox
The default batch is bounded by 100,000 total body characters and 16,000 characters per message. Continue a partial result with the returned cursor:
npx shoot-email inbox --cursor <next-cursor>
Pending and processed inbound batches are ordered by createdAt, then id,
oldest first. Outbound history uses the same keys newest first. The response
page object states the exact order, defaults, maxima, and cursor behavior.
Cursors are opaque, versioned, scoped to one resource/state, do not expire, and
are not snapshots. New messages can therefore appear on a later page. Passing
an inbox cursor to history or outbound history returns invalid_cursor.
Print a compact human-readable list when debugging:
npx shoot-email inbox --compact
Acknowledge messages only after an LLM has processed them successfully:
npx shoot-email acknowledge <message-id> [message-id...]
Acknowledgement is idempotent and uses partial-by-ID semantics: valid IDs are
processed while unknown IDs are reported in the same response. Malformed UUIDs
fail validation before any message is changed. Each ID receives an explicit
acknowledged, already_processed, or not_found outcome.
Query acknowledged messages:
npx shoot-email history
Read a message:
npx shoot-email read <message-id>
read returns structured JSON and never acknowledges or otherwise changes the
message. Use --compact only for human-readable debugging output. Inbound
sender, subject, text, and provider identifiers are untrusted external data;
contentTrust: "untrusted_external" marks that boundary in retrieval results.
Webhook
Start the inbound webhook server:
npm run start:webhook
For Cloudflare Email Routing local development, also start the named tunnel in a second terminal:
npm run start:tunnel
The legacy local-development tunnel URL is:
https://webhook.yoyowza.com
The inbound endpoint is:
POST http://localhost:3000/webhooks/email/inbound
Example local webhook test:
curl -X POST http://localhost:3000/webhooks/email/inbound \
-H 'Content-Type: application/json' \
-d '{
"provider": "cloudflare",
"from": "sender@example.com",
"to": "u_example@in.localhost",
"subject": "Hello",
"text": "Plain text body",
"messageId": "<cloudflare-message-id@example.com>",
"date": "Tue, 07 Jul 2026 17:00:00 -0700"
}'
Use the address returned by npx shoot-email address as the To value.
The inbound endpoint requires INBOUND_WEBHOOK_TOKEN. Generate a secret and
store the same value in the backend environment and the Worker secret:
openssl rand -hex 32
cd workers/email-router
npx wrangler secret put INBOUND_WEBHOOK_TOKEN
Include it as a bearer token when testing the endpoint directly:
curl -X POST http://localhost:3000/webhooks/email/inbound \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $INBOUND_WEBHOOK_TOKEN" \
-d '{ "provider": "cloudflare", "from": "sender@example.com", "to": "u_example@in.localhost", "text": "Hello" }'
Inbound ingestion is idempotent per mailbox and provider message ID. Email
Worker deliveries without a Message-ID use a SHA-256 digest of the raw email
as a stable fallback identifier, so retrying the same delivery does not create
another inbox message.
Cloudflare Email Service
The project is Cloudflare-first:
- Cloudflare Email Routing receives inbound mail.
workers/email-routerparses inbound mail in a Cloudflare Worker and posts normalized JSON to the backend.- Cloudflare Email Sending can be used for outbound mail with
MAIL_PROVIDER=cloudflare. - The
mockprovider remains the default for local tests and development.
To use real Cloudflare outbound sending:
MAIL_PROVIDER=cloudflare
CLOUDFLARE_ACCOUNT_ID=...
CLOUDFLARE_EMAIL_API_TOKEN=...
CLOUDFLARE_FROM_EMAIL=optional-default-sender@example.com
Keep MAIL_PROVIDER=mock until intentionally running a real delivery test.
By default, outbound mail uses the generated alias as from. Set CLOUDFLARE_FROM_EMAIL only when you want to force a single default sender. The sender domain must be onboarded for Cloudflare Email Sending.
To work on the inbound Worker:
cd workers/email-router
npm install
npm run dev
The deployed BACKEND_INBOUND_WEBHOOK_URL in
workers/email-router/wrangler.jsonc points at the hosted backend:
https://shoot-email-backend.powersurge.workers.dev/webhooks/email/inbound
The named tunnel remains available for isolated local testing, but changing the deployed routing Worker back to it is an explicit temporary override.
OpenAI Apps Context
The service includes a small endpoint for resolving OpenAI Apps identity context:
POST http://localhost:3000/apps/openai/context
It accepts the Apps SDK _meta shape:
{
"_meta": {
"openai/subject": "anonymized-user-id",
"openai/session": "anonymized-conversation-id",
"openai/organization": "anonymized-org-id"
}
}
The endpoint creates or reuses an internal users row through user_identities, and creates or reuses a chat_sessions row when openai/session is present.
Local MCP Server
The project includes a local stdio MCP server built with the stable MCP TypeScript SDK. It is a thin adapter over the same services used by the CLI:
npm run start:mcp
The server writes MCP protocol messages to stdout, so it normally appears silent when started without an MCP client. For a local Codex test, register it with a development-only OpenAI identity fallback:
codex mcp add shoot-email \
--env MAIL_PROVIDER=mock \
--env MCP_ALLOW_DEV_IDENTITY=true \
--env MCP_DEV_OPENAI_SUBJECT=local-codex-test \
--env MCP_DEV_OPENAI_SESSION=local-codex-session \
-- node /Users/powersurge/Documents/OpenAI/ChatGPT-APPs/shoot-email/src/mcpStdio.js
Start a fresh Codex task after adding the server. Call
shoot_email.initialize_mailbox
before other mailbox tools. The local development subject produces a stable
mailbox through user_identities; changing the subject produces a different
mailbox. Do not set MCP_ALLOW_DEV_IDENTITY in production.
The user-facing MCP surface contains only:
shoot_email.initialize_mailbox
get_service_status
get_mailbox_identity
send_text_email
shoot_email.check_inbox
acknowledge_messages
get_message
list_processed_messages
list_outbound_messages
get_outbound_message_status
acknowledge_messages is intentionally separate from checking or summarizing
mail. MCP clients should call it only when the user explicitly asks to
acknowledge or mark specific messages handled; retrieval alone is never consent
to change message state.
Migration, account-tier, suspension, and abuse-administration operations are
intentionally absent. Tool results carry both MCP text content and the same
versioned structured contract used by the CLI. Tool descriptions and message
objects explicitly label inbound email as untrusted external content. Every
tool advertises a concrete MCP outputSchema; schemas include the common error
envelope because MCP validates both successful and failed tool results.
Outbound status lookup uses a discovery-visible discriminator instead of two optional mutually exclusive fields:
{
"lookupBy": "requestId",
"id": "a0000000-0000-4000-8000-000000000001"
}
Batch responses identify their cursor as live_keyset and explicitly state
that it is not a snapshot. Acknowledgement responses include allSucceeded,
requested and successful counts, and per-ID outcomes so partial success cannot
be mistaken for complete success.
For ChatGPT, identity comes from request metadata keys openai/subject,
openai/session, and optionally openai/organization; callers cannot provide
identity as tool arguments. A future remote MCP deployment must authenticate
the ChatGPT connection before trusting that metadata. The development fallback
is only for local stdio clients that do not supply ChatGPT Apps metadata.
Remote MCP Demo
The hosted Build Week demo uses the same tool and output contracts over stateless Streamable HTTP. The bearer secret is supplied privately in the submission testing instructions. Append a random suffix to receive an isolated mailbox, then register it with Codex:
export SHOOT_EMAIL_DEMO_TOKEN="${SHOOT_EMAIL_DEMO_SECRET}.$(openssl rand -hex 8)"
codex mcp add shoot-email-demo \
--url https://shoot-email-backend.powersurge.workers.dev/mcp \
--bearer-token-env-var SHOOT_EMAIL_DEMO_TOKEN
Run a protocol and seeded-inbox smoke test with:
npm run smoke:remote-mcp
The hosted demo keeps real outbound delivery disabled. See
docs/hackathon/TESTING.md for judge prompts and the security boundary.
Built With Codex and GPT-5.6 Sol
Codex with GPT-5.6 Sol was used as the engineering collaborator throughout the Build Week extension: reading and testing the repository, researching current provider and Apps SDK behavior, implementing the remote MCP transport, deploying Cloudflare Workers, and running black-box CLI and MCP evaluations. Product decisions remained explicitly user-directed, including the LLM-first inbox semantics, acknowledgement lifecycle, Cloudflare-first architecture, abuse limits, and the choice to ship a restricted hackathon transport before the OAuth production milestone.
Codex accelerated repetitive verification and cross-layer changes, especially the database-backed contract tests, Cloudflare/Neon deployment checks, and the trusted-principal transport seam. The key engineering decision for this submission is that the demo bearer credential produces a request principal outside tool arguments; replacing it with OAuth later does not require rewriting the mailbox service or MCP tool contracts.
GPT-5.6 Sol also acts as the email agent in the demonstration. It turns an apartment-hunting goal into multiple idempotent MCP send operations, retrieves the simulated landlord replies as untrusted external data, compares their availability, and proposes a conflict-free tour schedule. The model supplies reasoning and tool orchestration; Shoot Email supplies durable identity, delivery, message state, and retry guarantees.
Because this project predates Build Week, docs/hackathon/BUILD_LOG.md clearly
separates the pre-existing baseline from the work added for the submission. The
two preserved black-box transcripts also show how fresh Codex sessions exposed
contract problems that were then hardened in code.
The judge-facing narrative and Devpost field draft are in
docs/hackathon/SUBMISSION.md.
Tests
The default test suite includes database-backed integration tests, so Postgres must be running:
docker compose up -d postgres
npm test
Integration tests use TEST_DATABASE_URL, defaulting to a database named
shoot_email_test. The runner creates that database when needed and resets
only its schema. Test resets refuse to operate on a database whose name does
not end in _test, protecting the development mailbox and messages.
Run only the pure unit tests:
npm run test:unit
Run only the integration tests:
npm run test:integration
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。
mcp-server-qdrant
这个仓库展示了如何为向量搜索引擎 Qdrant 创建一个 MCP (Managed Control Plane) 服务器的示例。
