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
- Track every sent email in the CRM — facility-wide and attributed to the specific
individual (
facility_contactsrow) emailed. - Track every reply — receive replies, thread them to the original send, show on Facility 360.
- 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.com— never the root, or it hijacks the primary Google/MS mail), it parses incoming mail and POSTs anemail.receivedwebhook. - 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-IDon every outbound send and persist it; match an inbound reply'sIn-Reply-To/Referencesto it. Fallback: matchfrom_email→facility_contact. - Two different ids: Resend's response
id(UUID, used by delivery-status webhooks) ≠ the RFCMessage-ID(used for reply threading). Persist both. - Webhook auth = Svix, NOT the repo's Hubstaff
?token=pattern. Verifysvix-id/svix-timestamp/svix-signature(HMAC-SHA256 over the raw body,whsec_secret), dedupe onsvix-id, readawait 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)
- 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.appaddress.) - Webhook — resend.com/webhooks → endpoint
https://<prod>/api/webhooks/resend-inbound, subscribe toemail.received(optionallyemail.delivered/bounced/complainedfor Phase 6). Copy thewhsec_…secret (shown once). - Secrets/env — add
RESEND_WEBHOOK_SECRET=whsec_…to the deploy env (+.env.example). - 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 thereply.*subdomain.
4. Phases (1–3 + individualization need NO external setup → ship first)
| # | Phase | Migration | External setup |
|---|---|---|---|
| 1 | Capture provider_message_id + stamped RFC Message-ID on every send | 0071 (2 cols + idx) | none |
| 2 | Per-individual attribution (facility_contact_id by email match) | 0072 (col + idx) | none |
| 3 | Surface outbound emails on Facility 360 timeline (facility-wide + per-individual) | none | none |
| I | Invoice individualization (per-invoice + batch subject/body) | 0071b (2 cols on invoices) | none |
| 4 | Inbound webhook (Svix-verified) + receiving-API fetch → inbound_emails | 0073 (new table + RLS + audit) | MX + secret |
| 5 | Thread replies to parent outbound (RFC Message-ID; fallback from_email) | none | (gated) |
| 6 | Show replies on Facility 360 (threaded) + optional delivery-status enrichment | none | (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 againstfacility_contactsto attribute it to a facility/contact. No OAuth. Reusesinbound_emails(+ asourcediscriminatorreply | 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_notesentry via the existingextractStructured(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.cominbox the owner uses, vs. a@nightingalepm.comWorkspace 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 onprovider_inbound_id. - Attribution is to the first matching contact (
to_emailsis 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/* .