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), notusetaikan.com. The examples below sayusetaikan.com; substitute the value ofINBOUND_EMAIL_DOMAIN. The Worker serves thecontact@rule onfitkit.fit,taikan.fitandusetaikan.com.
How routing works
Every outbound inbox email sets:
Reply-To: contact+<conversationId>@usetaikan.comWhen 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.comThe 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).
| Piece | Where |
|---|---|
Reply-To builders + parsers (REPLY_LOCAL = 'contact', ol- marker) | apps/api/src/conversations/channels/email-threading.ts |
| Outbound adapter — 1:1 Reply-To | apps/api/src/conversations/channels/email.adapter.ts |
| Bulk processor — broadcast Reply-To | apps/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() |
| Env | apps/api/src/config/env.schema.ts (both inbound keys are in PROD_EXPECTED_ENV_KEYS) |
| Body-size limit for the webhook route | apps/api/src/conversations/inbound-email-body-parser.ts |
| Email Worker source | deploy/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 everycontact+<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.receivedwebhook 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)
-
Email Routing → Settings → enable Subaddressing.
-
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.
- Email pattern:
-
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.tomlWhat it guarantees: a bare
contact@(no+id) is forwarded toFALLBACK_ADDRESS; a network error, 429 or 5xx is retried once after 2 s; any other non-2xx (or a failed retry) forwards the message toFALLBACK_ADDRESSwith anX-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. -
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 32For local dev, put both in
apps/api/.env.
Fail-safe behavior:
INBOUND_EMAIL_DOMAINunset → outbound sends no Reply-To (nothing to reply to).EMAIL_INBOUND_SECRETunset → 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-supplieddata.textis used as a fallback when there’s noraw. -
tomay be a string or array;{address}/{email}objects and"Name" <addr>wrappers are tolerated;ccis also scanned. Secret may instead be thex-inbound-secretheader. -
Returns
{ "ok": true }. Unroutable mail (nocontact+<uuid>@orcontact+ol-<uuid>@recipient) is ack’d with 200 and dropped (no retry). Wrong/absent secret → 403. Idempotent onmessage_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.
- Send an inbox email to a real mailbox you control.
- Confirm it arrives with
Reply-To: contact+<id>@usetaikan.com. - 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
| Symptom | Cause / fix |
|---|---|
403 | URL ?secret= ≠ EMAIL_INBOUND_SECRET, or the var is unset (fails closed). |
{ ok: true } but no message | Recipient 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 header | The 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 header | The contact@ rule is missing/disabled, or Subaddressing isn’t enabled — so it fell through to the catch-all. |
| Reply ignored | Idempotent 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.