diff --git a/docs/operations/outbound-comms-safety.md b/docs/operations/outbound-comms-safety.md new file mode 100644 index 0000000..81eadb6 --- /dev/null +++ b/docs/operations/outbound-comms-safety.md @@ -0,0 +1,123 @@ +# Outbound communications safety net + +**Last reviewed:** 2026-05-03 +**Owner:** matt@portnimara.com + +This doc enumerates every channel through which the CRM can produce +outbound communication (email, document signing, webhooks) and describes +how each channel respects the `EMAIL_REDIRECT_TO` env var. The goal: a +single environment flip pauses **all** outbound traffic, so a production +data import, dedup migration dry-run, or staging environment can run +against real data without anyone getting paged or spammed. + +> **Single env switch:** when `EMAIL_REDIRECT_TO` is set to an address, +> all outbound communication is rerouted there or short-circuited. Unset +> it in production. + +--- + +## Channels + +### 1. Direct email (`sendEmail`) + +**Path:** `src/lib/email/index.ts` → `sendEmail()` → nodemailer SMTP transport. + +**Safety:** YES — covered. + +When `EMAIL_REDIRECT_TO` is set, `sendEmail()` rewrites the `to` header +to the redirect address and prefixes the subject with +`[redirected from ]`. The original recipient is logged. + +**Call sites** (all flow through `sendEmail`, so all are covered): + +- `src/lib/services/portal-auth.service.ts` — portal activation + reset +- `src/lib/services/crm-invite.service.ts` — CRM user invitations +- `src/lib/services/document-templates.ts` — template-generated PDFs sent + as attachments (the PDF body is generated locally; the email itself + goes through SMTP) +- `src/lib/services/email-compose.service.ts` — ad-hoc emails composed + in the in-app UI +- `src/lib/services/gdpr-export.service.ts` — GDPR export delivery + +### 2. Documenso e-signature recipients + +**Path:** `src/lib/services/documenso-client.ts` → `createDocument()` / +`generateDocumentFromTemplate()` → Documenso REST API. + +**Safety:** YES — covered as of 2026-05-03. + +Documenso's own server sends the signing-request email on our behalf. +We can't intercept that at the SMTP layer because it's external. The +fix is at the REST-call boundary: when `EMAIL_REDIRECT_TO` is set, +`createDocument` rewrites every recipient's email to the redirect +address and prefixes the recipient name with `(was: )` so +the doc is still traceable to its intended recipient. +`generateDocumentFromTemplate` does the same for both shapes the +template-generate endpoint accepts (v1.13 `formValues.*Email` keys and +v2.x `recipients` array). + +The redirect happens **before** the API call, so even if Documenso has +its own retry logic the original email never leaves our process. + +### 3. Webhooks (outbound to user-configured URLs) + +**Path:** `src/lib/queue/workers/webhooks.ts` → BullMQ job → `fetch(webhook.url, ...)`. + +**Safety:** YES — covered as of 2026-05-03. + +When `EMAIL_REDIRECT_TO` is set, the webhook worker short-circuits +before the HTTP call. The delivery row is marked `dead_letter` with a +human-readable reason so it's still visible in the deliveries listing. +The SSRF guard remains in place independently. + +### 4. WhatsApp / phone deep-links + +**Path:** `` and `` in +client / interest detail headers. + +**Safety:** N/A — user-initiated only. + +These are deep links the user explicitly clicks. No automated dispatch. +A deep link click opens the user's WhatsApp / phone app, which is the +intended interaction. No safety net needed. + +### 5. SMS + +Not implemented. The `interests.preferredContactMethod` enum includes +`'sms'` as a value but no sending path exists. If/when SMS is added (e.g. +via Twilio), the new send function should respect `EMAIL_REDIRECT_TO` +the same way `sendEmail` does — log the original number, drop the +message, or reroute to a configurable `SMS_REDIRECT_TO` env. + +--- + +## Verification checklist before importing real data + +- [ ] `.env` has `EMAIL_REDIRECT_TO=` set. +- [ ] Restart dev server (or worker) so the new env is picked up — env + vars are read at import time in some paths. +- [ ] Send a test email via `pnpm tsx scripts/dev-trigger-portal-invite.ts` + or similar. Confirm subject is prefixed with `[redirected from ...]`. +- [ ] Trigger an EOI send through the UI (any client). Confirm Documenso + shows the redirect address as recipient (not the real client email). +- [ ] If any webhooks are configured, trigger an event that fires one and + confirm the delivery is recorded as `dead_letter` with the + "EMAIL_REDIRECT_TO is set" reason. +- [ ] Run the NocoDB migration `--dry-run` to count clients/interests; the + `--apply` step is what creates real records but emails/webhooks are + still gated by the redirect env. + +## Production cutover + +When ready to go live: + +1. Run a final dry-run of the data migration with `EMAIL_REDIRECT_TO` set + to a sandbox address. +2. Verify the snapshot looks right (counts, client coverage). +3. Unset `EMAIL_REDIRECT_TO` in the production env. +4. Restart the app + worker. +5. Run the migration with `--apply`. From this point forward, real + recipients will receive real comms. + +If you ever need to re-pause outbound (e.g. handling a security incident, +re-importing on top of existing data), set `EMAIL_REDIRECT_TO` again. diff --git a/src/lib/queue/workers/webhooks.ts b/src/lib/queue/workers/webhooks.ts index fab37ac..9cb13fc 100644 --- a/src/lib/queue/workers/webhooks.ts +++ b/src/lib/queue/workers/webhooks.ts @@ -84,6 +84,28 @@ export const webhooksWorker = new Worker( return; } + // Safety net: when EMAIL_REDIRECT_TO is set (dev / staging / migration + // dry-run), short-circuit webhook delivery so we don't accidentally + // ping a user-configured production endpoint with synthetic events. + // Records the delivery as `dead_letter` with a clear reason so the + // attempt is still visible in the deliveries listing. + if (process.env.EMAIL_REDIRECT_TO) { + logger.info( + { webhookId, deliveryId, url: webhook.url }, + 'Webhook delivery skipped (EMAIL_REDIRECT_TO is set — outbound comms are paused)', + ); + await db + .update(webhookDeliveries) + .set({ + status: 'dead_letter', + responseStatus: null, + responseBody: 'Skipped: EMAIL_REDIRECT_TO is set, outbound comms paused.', + deliveredAt: new Date(), + }) + .where(eq(webhookDeliveries.id, deliveryId)); + return; + } + // 2. Decrypt secret let secret: string; try { diff --git a/src/lib/services/documenso-client.ts b/src/lib/services/documenso-client.ts index fccd2f7..168a0ad 100644 --- a/src/lib/services/documenso-client.ts +++ b/src/lib/services/documenso-client.ts @@ -87,17 +87,72 @@ export interface DocumensoDocument { }>; } +/** + * When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient + * email so Documenso doesn't accidentally email real clients during a + * data import / migration dry-run. Names are prefixed with the original + * email so the recipient (you) can tell who would have received the doc. + * + * In production this env var is unset and recipients flow through unchanged. + */ +function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] { + if (!env.EMAIL_REDIRECT_TO) return recipients; + return recipients.map((r) => ({ + ...r, + name: `${r.name} (was: ${r.email})`, + email: env.EMAIL_REDIRECT_TO!, + })); +} + +/** + * Same idea for the template-generate endpoint, which takes a payload + * shape with recipient email/name nested inside `formValues` (Documenso + * v1.13) or `recipients` (Documenso 2.x). We rewrite both shapes. + */ +function applyPayloadRedirect(payload: Record): Record { + if (!env.EMAIL_REDIRECT_TO) return payload; + const out: Record = { ...payload }; + // 2.x recipient shape + if (Array.isArray(out.recipients)) { + out.recipients = (out.recipients as Array>).map((r) => ({ + ...r, + name: `${String(r.name ?? '')} (was: ${String(r.email ?? '')})`, + email: env.EMAIL_REDIRECT_TO, + })); + } + // v1.13 formValues shape — keys vary per template; key by anything that + // looks like an email field. The conservative approach: only touch keys + // that already hold a string and end with `Email` / `email`. + if (out.formValues && typeof out.formValues === 'object') { + const fv = { ...(out.formValues as Record) }; + for (const key of Object.keys(fv)) { + if (/email$/i.test(key) && typeof fv[key] === 'string') { + fv[key] = env.EMAIL_REDIRECT_TO; + } + } + out.formValues = fv; + } + return out; +} + export async function createDocument( title: string, pdfBase64: string, recipients: DocumensoRecipient[], portId?: string, ): Promise { + const safeRecipients = applyRecipientRedirect(recipients); + if (env.EMAIL_REDIRECT_TO) { + logger.info( + { redirected: safeRecipients.length, original: recipients.map((r) => r.email) }, + 'Documenso recipients redirected to EMAIL_REDIRECT_TO', + ); + } return documensoFetch( '/api/v1/documents', { method: 'POST', - body: JSON.stringify({ title, document: pdfBase64, recipients }), + body: JSON.stringify({ title, document: pdfBase64, recipients: safeRecipients }), }, portId, ).then(normalizeDocument); @@ -108,11 +163,18 @@ export async function generateDocumentFromTemplate( payload: Record, portId?: string, ): Promise { + const safePayload = applyPayloadRedirect(payload); + if (env.EMAIL_REDIRECT_TO) { + logger.info( + { templateId }, + 'Documenso template-generate payload redirected to EMAIL_REDIRECT_TO', + ); + } return documensoFetch( `/api/v1/templates/${templateId}/generate-document`, { method: 'POST', - body: JSON.stringify(payload), + body: JSON.stringify(safePayload), }, portId, ).then(normalizeDocument);