feat(safety): EMAIL_REDIRECT_TO now also pauses Documenso + webhooks

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) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-03 17:24:41 +02:00
parent 78f2f46d41
commit 8e4d2fc5b4
3 changed files with 209 additions and 2 deletions

View File

@@ -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 {