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:
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
if (!env.EMAIL_REDIRECT_TO) return payload;
|
||||
const out: Record<string, unknown> = { ...payload };
|
||||
// 2.x recipient shape
|
||||
if (Array.isArray(out.recipients)) {
|
||||
out.recipients = (out.recipients as Array<Record<string, unknown>>).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<string, unknown>) };
|
||||
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<DocumensoDocument> {
|
||||
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<string, unknown>,
|
||||
portId?: string,
|
||||
): Promise<DocumensoDocument> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user