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.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 |
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.comcaptures 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 → the
inbox-emailWorker (next step) - Leave the catch-all
*@usetaikan.com→ Gmail rule exactly as it is.
- Email pattern:
-
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 secretWEBHOOK_SECRET(=EMAIL_INBOUND_SECRETbelow) and a varAPI_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 }, }), }); }, }; -
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 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>" } }- 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 | The contact@usetaikan.com 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. It does not prove real DNS/MX delivery — that’s §b.