Skip to main content

Plan — CRM email tracking (outbound + inbound replies) + per-send invoice individualization

Status: Designed & verified, ready to build. Date: 2026-06-21. Feasibility: Resend Inbound is GA (since 2025-11-03) — receiving replies is feasible on the existing single Resend account/key. Verified against Resend docs (see §6 sources).

Builds on the shipped configurable-email-settings work (docs/email-settings/HANDOFF.md): company From/Reply-to/Enabled, area-based (per-process) reply-to, per-send CC/BCC/reply-to.


1. The three asks

  1. Track every sent email in the CRM — facility-wide and attributed to the specific individual (facility_contacts row) emailed.
  2. Track every reply — receive replies, thread them to the original send, show on Facility 360.
  3. Individualize each invoice email — both a per-invoice subject/body override and a single batch-level message.

2. Key technical facts (verified)

  • Resend Inbound: point one MX record at Resend on a dedicated subdomain (e.g. reply.nightingalepm.comnever the root, or it hijacks the primary Google/MS mail), it parses incoming mail and POSTs an email.received webhook.
  • Webhook is metadata-only (no body/headers). To read the reply's In-Reply-To/References (which point at our original message), make a second call: GET /emails/receiving/{id}.
  • Threading: stamp our own RFC Message-ID on every outbound send and persist it; match an inbound reply's In-Reply-To/References to it. Fallback: match from_emailfacility_contact.
  • Two different ids: Resend's response id (UUID, used by delivery-status webhooks) ≠ the RFC Message-ID (used for reply threading). Persist both.
  • Webhook auth = Svix, NOT the repo's Hubstaff ?token= pattern. Verify svix-id/svix-timestamp/svix-signature (HMAC-SHA256 over the raw body, whsec_ secret), dedupe on svix-id, read await req.text() before parsing, fail closed if the secret is unset.
  • Received emails count against the same Resend quota as sent (free 3,000/mo, 100/day).

3. External setup (ONLY the owner can do — gates Phases 4–6)

  1. DNS/MX — In Resend → Domains, enable Receiving on a subdomain reply.nightingalepm.com; copy the MX host/value/priority into DNS; verify. (Prototype: Resend's *.resend.app address.)
  2. Webhook — resend.com/webhooks → endpoint https://<prod>/api/webhooks/resend-inbound, subscribe to email.received (optionally email.delivered/bounced/complained for Phase 6). Copy the whsec_… secret (shown once).
  3. Secrets/env — add RESEND_WEBHOOK_SECRET=whsec_… to the deploy env (+ .env.example).
  4. Align From/Reply-to to the inbound subdomain so replies actually reach Resend (not the existing Google/MS mailbox). The invoice area reply-to can stay billing@… only if that domain's MX is Resend; otherwise route replies via the reply.* subdomain.

4. Phases (1–3 + individualization need NO external setup → ship first)

#PhaseMigrationExternal setup
1Capture provider_message_id + stamped RFC Message-ID on every send0071 (2 cols + idx)none
2Per-individual attribution (facility_contact_id by email match)0072 (col + idx)none
3Surface outbound emails on Facility 360 timeline (facility-wide + per-individual)nonenone
IInvoice individualization (per-invoice + batch subject/body)0071b (2 cols on invoices)none
4Inbound webhook (Svix-verified) + receiving-API fetch → inbound_emails0073 (new table + RLS + audit)MX + secret
5Thread replies to parent outbound (RFC Message-ID; fallback from_email)none(gated)
6Show replies on Facility 360 (threaded) + optional delivery-status enrichmentnone(gated)

Phase details (files, SQL, verification) are in the design output; each phase is independently shippable + tsc/Biome/Vitest-verified. Migrations are additive/nullable; the new inbound_emails table reuses the 0035 admin-RLS + app.audit_row() audit pattern.

4a. Linking the owner's OWN mailbox (decided 2026-06-21)

Decision: BCC-to-CRM now, full Gmail sync later; AI note-taking — build in.

  • Phase 7 — BCC/forward-to-CRM (build with the inbound pipeline): a magic address (e.g. crm@reply.nightingalepm.com) the owner BCCs/forwards from their own client. The same Phase-4 Svix webhook ingests it; match the other recipients/sender against facility_contacts to attribute it to a facility/contact. No OAuth. Reuses inbound_emails (+ a source discriminator reply | bcc). Gated on the same MX/secret as Phases 4–6.
  • Phase 8 — AI note-taking: after an email/thread is logged (outbound, reply, or BCC), summarize it into a crm_activities/facility_notes entry via the existing extractStructured (ADR-0026 OpenAI→Anthropic). Owner chose auto-build it in; follow the review-before-send precedent where it fits. Tone/PII guardrails per CLAUDE.md (never log bodies).
  • Phase 9 (LATER) — full Gmail mailbox sync: Google OAuth (the repo already scaffolds GMAIL_CLIENT_ID/SECRET/REFRESH_TOKEN, GMAIL_PAYMENTS_INBOX); watch/poll a Workspace mailbox and auto-log mail to/from known contacts — no BCC needed. Open question: which mailbox — the @abckidsny.com inbox the owner uses, vs. a @nightingalepm.com Workspace box (SSO domain). Decide at Phase 9.

5. Risks / guardrails

  • MX on root domain would steal production mail → subdomain only.
  • Metadata-only webhook → threading needs the second API fetch; store the raw inbound row first so nothing is lost, thread idempotently.
  • Inbound bodies are PII → RLS + audit table, never logged (CLAUDE.md logger contract).
  • Svix replay → dedupe on svix-id + unique index on provider_inbound_id.
  • Attribution is to the first matching contact (to_emails is an array); per-recipient granularity would need a child table (future ADR).
  • Individualization precedence: per-invoice override → batch override → facility template → default, for subject and body; the rendered text (not the template) is what's audited.

6. Sources

resend.com/docs/dashboard/receiving/* , resend.com/blog/inbound-emails , resend.com/docs/api-reference/emails/retrieve-received-email , resend.com/docs/knowledge-base/* .