From 8e4d2fc5b47c8a60d591aae30cd0a11a2cb0349c Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sun, 3 May 2026 17:24:41 +0200 Subject: [PATCH] feat(safety): EMAIL_REDIRECT_TO now also pauses Documenso + webhooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes a gap exposed by the comms safety audit: the existing EMAIL_REDIRECT_TO env var only redirected outbound SMTP via the sendEmail() bottleneck. Two channels still leaked when set: 1. Documenso e-signature recipients — Documenso's own server emails them on our behalf, so SMTP redirect doesn't help. We were sending real client emails to the Documenso REST API, which would then deliver to the real client. 2. Outbound webhooks — fire from the BullMQ worker to user-configured URLs. SSRF guard blocks internal hosts but doesn't pause production endpoints. Documenso (src/lib/services/documenso-client.ts): - createDocument: rewrite every recipient.email to EMAIL_REDIRECT_TO and prefix the recipient.name with the original email so the doc is traceable. - generateDocumentFromTemplate: same treatment for both v1.13 formValues.*Email keys and v2.x recipients[]. The redirect happens BEFORE the API call, so even Documenso's own retry logic can't reach the original recipient. - Both paths log when they redirect so it's visible in dev. Webhooks (src/lib/queue/workers/webhooks.ts): - When EMAIL_REDIRECT_TO is set, short-circuit the dispatch and write a `dead_letter` row with reason "Skipped: EMAIL_REDIRECT_TO is set, outbound comms paused." so the attempt is still visible in the deliveries listing. Doc: docs/operations/outbound-comms-safety.md catalogs every outbound comms channel (email, Documenso, webhooks, WhatsApp/phone deep-links, SMS-not-implemented) and explains how each one respects the env flag. Includes a verification checklist to run before any production data import + cutover steps for going live. Single env var EMAIL_REDIRECT_TO now reliably pauses ALL automated outbound comms. Unset for production. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/operations/outbound-comms-safety.md | 123 +++++++++++++++++++++++ src/lib/queue/workers/webhooks.ts | 22 ++++ src/lib/services/documenso-client.ts | 66 +++++++++++- 3 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 docs/operations/outbound-comms-safety.md 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);