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.

Production domain is taikan.fit (INBOUND_EMAIL_DOMAIN), not usetaikan.com. The examples below say usetaikan.com; substitute the value of INBOUND_EMAIL_DOMAIN. The Worker serves the contact@ rule on fitkit.fit, taikan.fit and usetaikan.com.

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 (both inbound keys are in PROD_EXPECTED_ENV_KEYS)
Body-size limit for the webhook routeapps/api/src/conversations/inbound-email-body-parser.ts
Email Worker sourcedeploy/inbox-email-worker/

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 [email protected] 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 → leads-worker (next step)
    • Leave the catch-all *@usetaikan.com → Gmail rule exactly as it is.
  3. Deploy the Email Worker from deploy/inbox-email-worker/ (never edit it in the dashboard — the repo is the source of truth). It’s dependency-free: it posts the raw message and the API extracts the text (channels/email-mime.ts).

    cd deploy/inbox-email-worker wrangler secret put WEBHOOK_SECRET # = EMAIL_INBOUND_SECRET below wrangler deploy # API_URL + FALLBACK_ADDRESS come from wrangler.toml

    What it guarantees: a bare contact@ (no +id) is forwarded to FALLBACK_ADDRESS; a network error, 429 or 5xx is retried once after 2 s; any other non-2xx (or a failed retry) forwards the message to FALLBACK_ADDRESS with an X-Taikan-Inbound-Failure: <status|unreachable> header. If the forward itself fails the Worker throws, so the sender gets a bounce instead of a silent drop.

  4. Set API env on Railway → @taikan/api (the agent cannot write prod env), redeploy:

    INBOUND_EMAIL_DOMAIN=taikan.fit 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>" } }
  • Size: the full raw message, attachments included, up to 30 MB (Email Routing caps messages at 25 MiB; JSON escaping adds a little). Only a request carrying the right secret gets that limit — anything else falls back to the API-wide 100 kb and 413s above it. Every other route stays at 100 kb. Attachments are not stored: only the text part reaches the thread, and an attachment-only reply is recorded with an empty body.

  • 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 app, with an X-Taikan-Inbound-Failure headerThe Worker reached the API and got that status (or unreachable). 413: body over the limit above. 403: WEBHOOK_SECRET ≠ EMAIL_INBOUND_SECRET. 5xx: check Sentry for POST /webhooks/email/inbound. Recover by re-posting the text with curl (§a) using the original Message-ID.
Reply went to Gmail instead of the app, no failure headerThe contact@ 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. Over real HTTP it posts a 5 MB reply-with-screenshot (200, text threaded) and checks that an unauthenticated inbound post and another route still 413 above 100 kb. It does not prove real DNS/MX delivery — that’s §b.