Skip to Content
Living documentation — last reviewed 2026-05-28
RunbooksInbox email inbound wiring

Inbox email inbound wiring

How to wire inbound email so replies to inbox messages thread back automatically. Outbound already works via Resend; this is the return path only.

How routing works

Every outbound inbox email sets:

Reply-To: contact+<conversationId>@usetaikan.com

When the contact replies, it goes to that address. Inbound only has to deliver contact+*@usetaikan.com to our webhook; the conversation id is read straight out of the recipient (subaddressing) — no per-org addressing, no from-address guessing.

Bulk / broadcast sends (FIT-224) have no conversation on send — bulk email is a pure broadcast. They set a lead-keyed Reply-To using the same contact local part so the same single routing rule captures them (no new Cloudflare rule):

Reply-To: contact+ol-<organizationLeadId>@usetaikan.com

The ol- marker keys the reply to one organization_leads row (org + lead), so a reply can never cross orgs even for a lead shared across orgs. On reply the handler resolves org + lead, lazily finds-or-creates the lead’s email thread, appends the reply, and advances the lead new → contacted (a reply is real engagement; the send alone is not).

PieceWhere
Reply-To builders + parsers (REPLY_LOCAL = 'contact', ol- marker)apps/api/src/conversations/channels/email-threading.ts
Outbound adapter — 1:1 Reply-Toapps/api/src/conversations/channels/email.adapter.ts
Bulk processor — broadcast Reply-Toapps/api/src/organization-leads/lead-bulk-email.processor.ts
Inbound webhook (routes both address shapes)apps/api/src/conversations/email-inbound.controller.ts
Append-to-thread (1:1)ConversationsService.recordInbound()
Lazy open + append + advance (broadcast)ConversationsService.recordInboundForOrgLead()
Envapps/api/src/config/env.schema.ts

Why Cloudflare, not Resend

We send via Resend, but receive via Cloudflare Email Routing:

  • Cloudflare Email Routing supports subaddressing (since 2025-07): a single rule on contact@usetaikan.com captures every contact+<id>@usetaikan.com, and it takes precedence over the existing *@usetaikan.com → Gmail catch-all — so support@ / no-reply@ keep flowing to Gmail untouched. No subdomain, no new MX.
  • An Email Worker hands us the full body inline, so our webhook works as-is.
  • Resend Inbound’s email.received webhook is metadata-only (email_id, to, subject) — it would force a second “fetch the body” API call + new code. Not worth it.

Setup (Cloudflare dashboard — the API token is read-only, so this is manual)

  1. Email Routing → Settings → enable Subaddressing.

  2. Email Routing → Routing Rules → Create rule

    • Email pattern: contact @ usetaikan.com
    • Action: Send to a Worker → the inbox-email Worker (next step)
    • Leave the catch-all *@usetaikan.com → Gmail rule exactly as it is.
  3. Create the Email Worker (Workers & Pages → Create → Email Worker). It’s dependency-free — it forwards the raw message and the API extracts the body (channels/email-mime.ts), so it pastes straight into the dashboard quick editor. Add a secret WEBHOOK_SECRET (= EMAIL_INBOUND_SECRET below) and a var API_URL (the API base URL, or a tunnel URL for local testing):

    export default { async email(message, env) { // message.to is the envelope recipient = contact+<id>@usetaikan.com const raw = await new Response(message.raw).text(); await fetch(`${env.API_URL}/webhooks/email/inbound?secret=${env.WEBHOOK_SECRET}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ data: { to: [message.to], message_id: message.headers.get('message-id'), raw }, }), }); }, };
  4. Set API env on Railway → @taikan/api (the agent cannot write prod env), redeploy:

    INBOUND_EMAIL_DOMAIN=usetaikan.com EMAIL_INBOUND_SECRET=<random> # openssl rand -hex 32

    For local dev, put both in apps/api/.env.

Fail-safe behavior:

  • INBOUND_EMAIL_DOMAIN unset → outbound sends no Reply-To (nothing to reply to).
  • EMAIL_INBOUND_SECRET unset → the webhook rejects everything (fail closed).

Webhook contract

POST https://<api-host>/webhooks/email/inbound?secret=<EMAIL_INBOUND_SECRET> Content-Type: application/json { "data": { "to": ["contact+<id>@usetaikan.com"], "raw": "<full RFC-822 message>", "message_id": "<Message-ID>" } }
  • The body comes from data.raw (parsed server-side); a provider-supplied data.text is used as a fallback when there’s no raw.
  • to may be a string or array; {address}/{email} objects and "Name" <addr> wrappers are tolerated; cc is also scanned. Secret may instead be the x-inbound-secret header.
  • Returns { "ok": true }. Unroutable mail (no contact+<uuid>@ or contact+ol-<uuid>@ recipient) is ack’d with 200 and dropped (no retry). Wrong/absent secret → 403. Idempotent on message_id.

Prove the round-trip

a. Webhook only (no DNS needed) — fastest smoke test. Send an outbound email from the inbox, grab <conversationId>, then:

curl -sS -X POST "https://<api>/webhooks/email/inbound?secret=$EMAIL_INBOUND_SECRET" \ -H 'content-type: application/json' \ -d '{"data":{"to":["contact+<conversationId>@usetaikan.com"],"text":"Yes, what times?","message_id":"manual-1"}}' # → {"ok":true}; the reply appears in the thread, unread +1, thread reopens.

b. Full real round-trip.

  1. Send an inbox email to a real mailbox you control.
  2. Confirm it arrives with Reply-To: contact+<id>@usetaikan.com.
  3. Reply from that mailbox → within seconds it lands in the thread.

Local dev: expose the API with a tunnel (cloudflared tunnel --url http://localhost:3001, or ngrok) and point the Worker’s API_URL at the tunnel URL.

Troubleshooting

SymptomCause / fix
403URL ?secret=EMAIL_INBOUND_SECRET, or the var is unset (fails closed).
{ ok: true } but no messageRecipient had no contact+<uuid>@ address (dropped). Check the Worker forwards the envelope recipient and that the outbound mail carried a Reply-To (needs INBOUND_EMAIL_DOMAIN set when it was sent).
Reply went to Gmail instead of the appThe contact@usetaikan.com rule is missing/disabled, or Subaddressing isn’t enabled — so it fell through to the catch-all.
Reply ignoredIdempotent on message_id — vary it when testing manually.

Automated coverage

apps/api/src/conversations/email-flow.int.spec.ts exercises the loop against the test DB (send → Resend called with the contact+<id> Reply-To → inbound webhook → threaded reply, plus idempotency + secret-gate). It also covers the broadcast path: a contact+ol-<id> reply with no prior thread lazily opens one, threads the reply, and advances the lead to contacted. It does not prove real DNS/MX delivery — that’s §b.