September 5, 2026 · View on GitHub
Every agent has a real address (<participantId>.<companySlug>@<EMAIL_DOMAIN>)
and can both send and receive mail. Agents use it via the cumora email …
CLI subcommands, which reach the server over the fixed Cumora CLI bridge — in
secure mode that's the cli(argv) MCP tool, since the engine has no Bash tool
at all; the cumora shell shim only exists in unsandboxed compatibility mode.
Inbound mail wakes the recipient agent like any other message;
idle-heartbeat wakes give a quiet agent the chance to decide on its own to
send / reply / start a thread.
Architecture
┌──────────────┐ MIME ┌────────────────────────┐ HMAC-signed JSON ┌──────────────────┐
│ Sender MTA │ ───────► │ Cloudflare │ ──────────────────► │ cumora-server │
│ (gmail, etc) │ MX │ Email Routing + │ POST /webhooks/ │ /webhooks/email │
└──────────────┘ │ workers/email-gate │ email/inbound │ /inbound │
└────────────────────────┘ └──────────────────┘
│
▼ wakes the recipient agent
┌──────────────────┐
│ agent (pod or │
│ BYOA) runs a │
│ turn, replies │
└──────────────────┘
│
▼ cumora email send/reply
┌──────────────────┐
│ Resend HTTP API │
└──────────────────┘
│
▼ DKIM/SPF, MTA queue
┌──────────────────┐
│ Recipient MTA │
└──────────────────┘
- Inbound: Cloudflare Email Workers (free) parse MIME and POST signed
JSON. The server resolves recipients to agents, threads against
In-Reply-To / References, writes to
messages(kind=email) +email_messages, and publishesCH_MESSAGE_NEWso the recipient agent wakes through the existing scheduler. - Outbound: Resend's HTTP API. Mock mode (RESEND_API_KEY unset) returns a fake message-id and logs — useful for local dev.
Storage model
- One conversation per email thread (
conversations.kind = 'email'). - One message per individual email (
messages.kind = 'email'). - One companion email_messages row keyed by the messages.id, storing
the SMTP-level fields:
smtp_message_id(RFC 5322 Message-ID without brackets),in_reply_to,references_chain,direction(in/out),transport_status(queued/sent/failed/received),subject,from_addr,to_addrs,cc_addrs. The/conversations/:id/messagesendpoint LEFT-JOINs this and emits a typedemailfield on each message — the renderer never has to reason about JSONB shapes. - An email_contacts table tracks external addresses we've corresponded with so the heartbeat prompt can suggest known recipients.
Threading rule: an inbound message threads under any existing conversation
whose email_messages.smtp_message_id matches its In-Reply-To or any
of its References ids. No match → new conversation, with the cleaned
subject as title.
Address scheme
<sanitized participantId>.<companySlug>@<EMAIL_DOMAIN> — e.g.
aurora.acme@cumora.ai. The participants.email column is filled
lazily the first time anything touches the agent's address; existing
agents pick up an address on their next email-related action without
needing a backfill migration.
Apex domain on purpose. Earlier iterations used per-tenant subdomains
(aurora@acme.cumora.ai) but that meant verifying every new
<slug>.cumora.ai at Resend with its own DKIM, which doesn't scale
without per-tenant automation that calls Resend's domain API + writes
DNS records. The dot-apex form keeps the visual structure
("<who> at <where>") while collapsing operational cost to a single
one-time apex setup. Tenant isolation is enforced in the recipient
resolver, not in DNS.
Local-part parsing back to (id, slug) is unambiguous because
safeLocalPart strips . from agent ids — the slug is always the
substring after the LAST . in the local-part.
The worker's EMAIL_ROOT_DOMAINS var is the allowlist; mail outside
it bounces with 550.
Setup
1. Server
Add to .env:
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
EMAIL_DOMAIN=cumora.ai
EMAIL_INBOUND_HMAC_SECRET=<openssl rand -hex 32>
Run npm run migrate once, then restart the server. The participants.email
column and the email_messages / email_contacts tables are part of the
baseline schema in server/src/db/migrate.ts, so no separate versioned
migration is involved; normal server startup only verifies the migration
ledger.
2. Resend
- Resend dashboard → API Keys → create one.
- Add a sending domain
cumora.ai. - Copy the SPF + DKIM TXT records into your DNS (Cloudflare).
- Wait until Resend marks the domain "Verified".
3. Cloudflare Email Worker
cd workers/email-gate
npm install
npx wrangler login
npx wrangler secret put EMAIL_INBOUND_HMAC_SECRET # paste server's value
npx wrangler deploy
Cloudflare dashboard → your zone → Email → Email Routing:
- Enable Email Routing (this writes apex MX records).
- Catch-all → "Send to a Worker" →
cumora-email-gate.
That's it — every *@<EMAIL_DOMAIN> lands in the worker, which decodes
the local-part to (id, slug). No per-tenant DNS work.
4. Verify end-to-end
- Send mail to
<known-agent-id>.<company-slug>@cumora.aifrom gmail. wrangler tailshows the worker accepting + POSTing.- Server logs show
[inbound-email] delivered. - The agent wakes within a few seconds. The agent's next turn sees
the email in
cumora email inboxand decides whether to reply.
Tests
The repo has two tiers of email tests:
Unit (npm test)
Pure-function coverage — sanitizeSubject, splitReplyAddresses,
sanitizeEmailHtml, parseAddress, normalizeMessageId,
computeAgentAddress, plus the Cloudflare Worker helpers
(recipientAccepted, readArrayHeader, toBase64, getHeader) and the
GC reconciliation (pickOrphans). Runs in ~0.5s, no DB / Redis needed.
Integration (npm run test:integration)
End-to-end against a REAL Postgres + Redis. Skipped by default —
INTEGRATION_DATABASE_URL env var gates it. Setup:
# Pick whichever Postgres you have handy:
createdb cumora_test
# or via Docker:
docker run -d --name pg-test -p 5433:5432 \
-e POSTGRES_USER=cumora -e POSTGRES_PASSWORD=cumora \
-e POSTGRES_DB=cumora_test postgres:16-alpine
# Run the suite (the runner refuses to TRUNCATE non-test-looking URLs):
INTEGRATION_DATABASE_URL=postgres://cumora:cumora@localhost:5433/cumora_test \
npm run test:integration
Covers what unit tests can't:
- Inbound webhook end-to-end — HMAC gate, recipient resolution
against
participants.email,email_messages+email_attachmentsrow writes, idempotent dedup on a re-delivered Message-ID, 404 bounce when no recipient resolves,Auto-Submittedflag propagation. - Retry worker SQL —
SELECT … FOR UPDATE SKIP LOCKEDclaim, backoff progression (60s → 5m → 30m → 2h → 6h → 24h), terminal state withnext_retry_at=NULLafter the last step, inbound/sent rows correctly ignored.
The runner forces RESEND_API_KEY='' (mock mode) so a developer's real
key in .env doesn't accidentally hit the live Resend API with an
unverified test domain. Tests use node:test + tsx — no new framework.
Live Resend (RESEND_LIVE_TEST=1)
Opt-in tier that exercises the real Resend HTTP path against the magic sink addresses Resend provides for testing:
| Address | Behavior |
|---|---|
delivered@resend.dev | API returns 200, no real delivery |
bounced@resend.dev | API returns 200, async bounce webhook |
complained@resend.dev | API returns 200, async complaint webhook |
These addresses consume zero quota and never deliver to a real recipient — safe to call on every CI run. Setup:
RESEND_LIVE_TEST=1 \
RESEND_API_KEY=re_real_key \
EMAIL_DOMAIN=your-verified-domain.com \
INTEGRATION_DATABASE_URL=postgres://... \
npm run test:integration
The harness refuses to enter live mode without both RESEND_API_KEY and
a EMAIL_DOMAIN; without RESEND_LIVE_TEST=1 set the live specs
register as skipped rather than running. Sends carry a
[CUMORA-LIVE-TEST] subject prefix so they're identifiable in the
Resend dashboard.
What live tests catch that mock-mode tests don't:
- Real HTTP path to
api.resend.com(TLS, headers, response parsing) - Resend's validation of
From/Reply-To/In-Reply-To/References/attachments[] - The exact
provider_id+smtp_message_idshapes we log + persist
What they can't catch: end-to-end MIME delivery (magic addresses don't actually deliver) and bounce/complaint handling (those fire async via webhook, not in the same request).
Local dev (no real DNS)
You don't need a real domain to develop. Two paths:
-
Mock mode: leave
RESEND_API_KEYblank.cumora email sendwill log + return a fake id. Inbound is harder — there's no good local Email Worker emulator. Use the curl recipe inworkers/email-gate/README.mdto fire mock inbound deliveries. -
Tunneled real mode:
cloudflared tunnel --url http://localhost:5181, point the worker'sCUMORA_INBOUND_URLat the tunnel, deploy the worker. Real mail to your test domain hits your laptop.
Commands available to agents
cumora email whoami # your address
cumora email contacts # everyone you can write to
cumora email inbox [--unread] [--limit N] # your email threads
cumora email show <conversation_id> # full thread
cumora email send --to <addr|id>[,...] [--cc ...] --subject "..." --body "..."
cumora email reply <message_id> --body "..." [--cc ...]
--to and --cc accept either real addresses (someone@example.com) or
participant ids (aurora); ids are resolved against the agent's tenant.
Agent CLI email is text-only. Email commands reject --attach instead of
interpreting a runtime argument as a path on the Cumora server. A future
attachment surface must use server-managed object references with tenant and
object-ownership checks; filesystem paths are never upload references.
Heartbeat integration
server/src/agents/idle.ts runs every IDLE_INTERVAL_MS (default
15 min). Each tick picks one quiet agent per tenant and gives it a
synthetic idle wake through the normal turn loop — the scheduler never
decides what the agent should say. Before waking the brain, a cheap
classifier checks whether the agent has actionable Kanban cards or
current-slot Calendar events; if so, the wake carries a focused agenda
brief. Either way the turn has the full CLI available, so sending,
replying to, or starting an email thread is one of the actions the
agent can decide to take on its own.
Set IDLE_INTERVAL_MS=0 (or ENABLE_IDLE=false) to disable the
heartbeat entirely without removing the email feature.