fix(eoi): silence Documenso's own lifecycle emails on createDocument
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m53s

The local-fill EOI pathway creates fresh Documenso envelopes via
createDocument, which (unlike the template pathway that inherits
template 8's all-false emailSettings) used Documenso's defaults — every
email event defaults to true on both the v1 and v2.13 APIs. So Documenso
fired its OWN unbranded "Waiting for others to complete signing." and
"Signing Complete!" emails (signed PDF attached, reply-to sales@),
bypassing EMAIL_REDIRECT_TO and duplicating the CRM's branded sends.

Force emailSettings to all-false (DOCUMENSO_SILENT_EMAIL_SETTINGS) on
every createDocument call (v1 JSON + v2 multipart). The CRM stays the
sole sender of signing comms. Verified against the live v2.13 OpenAPI +
template 8's stored meta.

Also stop the EMAIL_REDIRECT_TO gate from appending "(was: <email>)" to
the recipient NAME: a "Name" field auto-fills from it into the signed
PDF, so the annotation overlapped the signature. Redirect the email
only; the original is still logged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 13:20:55 +02:00
parent e17476f3e3
commit fc994cd88b
2 changed files with 128 additions and 29 deletions

View File

@@ -243,16 +243,19 @@ export interface DocumensoDocument {
/** /**
* When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient * When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient
* email so Documenso doesn't accidentally email real clients during a * email so Documenso doesn't accidentally email real clients during a
* data import / migration dry-run. Names are prefixed with the original * data import / migration dry-run.
* 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. * The NAME is left untouched: a "Name" signature field auto-fills from the
* recipient name and renders into the signed PDF, so any annotation here
* (we used to append "(was: <email>)") leaks into the document and overlaps
* the signature. The original email is captured in the createDocument log
* line instead. In production this env var is unset and recipients flow
* through unchanged.
*/ */
function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] { function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] {
if (!env.EMAIL_REDIRECT_TO) return recipients; if (!env.EMAIL_REDIRECT_TO) return recipients;
return recipients.map((r) => ({ return recipients.map((r) => ({
...r, ...r,
name: `${r.name} (was: ${r.email})`,
email: env.EMAIL_REDIRECT_TO!, email: env.EMAIL_REDIRECT_TO!,
})); }));
} }
@@ -265,11 +268,11 @@ function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoReci
function applyPayloadRedirect(payload: Record<string, unknown>): Record<string, unknown> { function applyPayloadRedirect(payload: Record<string, unknown>): Record<string, unknown> {
if (!env.EMAIL_REDIRECT_TO) return payload; if (!env.EMAIL_REDIRECT_TO) return payload;
const out: Record<string, unknown> = { ...payload }; const out: Record<string, unknown> = { ...payload };
// 2.x recipient shape // 2.x recipient shape — redirect the email only, keep the name clean (it
// renders into the signed PDF's Name field). See applyRecipientRedirect.
if (Array.isArray(out.recipients)) { if (Array.isArray(out.recipients)) {
out.recipients = (out.recipients as Array<Record<string, unknown>>).map((r) => ({ out.recipients = (out.recipients as Array<Record<string, unknown>>).map((r) => ({
...r, ...r,
name: `${String(r.name ?? '')} (was: ${String(r.email ?? '')})`,
email: env.EMAIL_REDIRECT_TO, email: env.EMAIL_REDIRECT_TO,
})); }));
} }
@@ -288,11 +291,41 @@ function applyPayloadRedirect(payload: Record<string, unknown>): Record<string,
return out; return out;
} }
/**
* Documenso fires its OWN lifecycle emails for every envelope: each event
* below defaults to `true` (verified against the v2.13 OpenAPI + the EOI
* Documenso template's stored meta). The CRM is the SOLE sender of signing
* comms — branded invitations via `sendSigningInvitation`, plus the
* completion / "who signed" alert emails — so we disable ALL of Documenso's
* events at creation time.
*
* Without this, the local-fill pathway (which creates fresh envelopes via
* `createDocument`, unlike the template pathway that inherits the template's
* all-false `emailSettings`) leaks unbranded "Waiting for others" /
* "Signing Complete!" emails — sent with the signed PDF attached from the
* Documenso instance's own account (reply-to sales@) — duplicating ours.
*
* The v2 schema marks every key `required` when the object is present, so
* all nine are listed explicitly.
*/
export const DOCUMENSO_SILENT_EMAIL_SETTINGS = {
recipientSigningRequest: false,
recipientRemoved: false,
recipientSigned: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: false,
ownerRecipientExpired: false,
ownerDocumentCreated: false,
} as const;
/** /**
* Optional metadata applied to the document on creation. v1 accepts * Optional metadata applied to the document on creation. v1 accepts
* `redirectUrl` and `subject`/`message` on its `/documents` endpoint. * `redirectUrl` and `subject`/`message` on its `/documents` endpoint.
* v2's `/envelope/create` accepts the same plus `signingOrder` for * v2's `/envelope/create` accepts the same plus `signingOrder` for
* PARALLEL-vs-SEQUENTIAL signing enforcement. * PARALLEL-vs-SEQUENTIAL signing enforcement. `emailSettings` is always
* forced to `DOCUMENSO_SILENT_EMAIL_SETTINGS` inside `createDocument`.
*/ */
export interface CreateDocumentMeta { export interface CreateDocumentMeta {
subject?: string; subject?: string;
@@ -342,16 +375,14 @@ export async function createDocument(
role: r.role, role: r.role,
signingOrder: r.signingOrder || i + 1, signingOrder: r.signingOrder || i + 1,
})), })),
...(meta
? {
meta: { meta: {
...(meta.subject ? { subject: meta.subject } : {}), // CRM is the sole email sender — Documenso stays silent.
...(meta.message ? { message: meta.message } : {}), emailSettings: DOCUMENSO_SILENT_EMAIL_SETTINGS,
...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}), ...(meta?.subject ? { subject: meta.subject } : {}),
...(meta.signingOrder ? { signingOrder: meta.signingOrder } : {}), ...(meta?.message ? { message: meta.message } : {}),
...(meta?.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
...(meta?.signingOrder ? { signingOrder: meta.signingOrder } : {}),
}, },
}
: {}),
}; };
form.append('payload', JSON.stringify(payload)); form.append('payload', JSON.stringify(payload));
form.append( form.append(
@@ -412,15 +443,13 @@ export async function createDocument(
title, title,
document: pdfBase64, document: pdfBase64,
recipients: safeRecipients, recipients: safeRecipients,
...(meta?.subject || meta?.message || meta?.redirectUrl
? {
meta: { meta: {
...(meta.subject ? { subject: meta.subject } : {}), // CRM is the sole email sender — Documenso stays silent.
...(meta.message ? { message: meta.message } : {}), emailSettings: DOCUMENSO_SILENT_EMAIL_SETTINGS,
...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}), ...(meta?.subject ? { subject: meta.subject } : {}),
...(meta?.message ? { message: meta.message } : {}),
...(meta?.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
}, },
}
: {}),
}), }),
}, },
portId, portId,

View File

@@ -21,6 +21,7 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
const originalRedirect = process.env.EMAIL_REDIRECT_TO; const originalRedirect = process.env.EMAIL_REDIRECT_TO;
const originalDocumensoUrl = process.env.DOCUMENSO_API_URL; const originalDocumensoUrl = process.env.DOCUMENSO_API_URL;
const originalDocumensoKey = process.env.DOCUMENSO_API_KEY; const originalDocumensoKey = process.env.DOCUMENSO_API_KEY;
const originalDocumensoVersion = process.env.DOCUMENSO_API_VERSION;
let fetchMock: ReturnType<typeof vi.fn>; let fetchMock: ReturnType<typeof vi.fn>;
@@ -28,6 +29,10 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
process.env.EMAIL_REDIRECT_TO = REDIRECT_TARGET; process.env.EMAIL_REDIRECT_TO = REDIRECT_TARGET;
process.env.DOCUMENSO_API_URL = 'https://documenso.example.test'; process.env.DOCUMENSO_API_URL = 'https://documenso.example.test';
process.env.DOCUMENSO_API_KEY = 'test-key'; process.env.DOCUMENSO_API_KEY = 'test-key';
// Pin v1 — prod's API version + these assertions read the JSON request
// body. Without this the local .env's DOCUMENSO_API_VERSION leaks in and
// the v2 multipart/FormData path makes JSON.parse(body) throw.
process.env.DOCUMENSO_API_VERSION = 'v1';
fetchMock = vi.fn(async () => ({ fetchMock = vi.fn(async () => ({
ok: true, ok: true,
@@ -49,6 +54,8 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
else process.env.DOCUMENSO_API_URL = originalDocumensoUrl; else process.env.DOCUMENSO_API_URL = originalDocumensoUrl;
if (originalDocumensoKey === undefined) delete process.env.DOCUMENSO_API_KEY; if (originalDocumensoKey === undefined) delete process.env.DOCUMENSO_API_KEY;
else process.env.DOCUMENSO_API_KEY = originalDocumensoKey; else process.env.DOCUMENSO_API_KEY = originalDocumensoKey;
if (originalDocumensoVersion === undefined) delete process.env.DOCUMENSO_API_VERSION;
else process.env.DOCUMENSO_API_VERSION = originalDocumensoVersion;
vi.resetModules(); vi.resetModules();
}); });
@@ -63,10 +70,72 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce();
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any; const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any;
expect(callBody.recipients).toHaveLength(2); expect(callBody.recipients).toHaveLength(2);
const namesByOrder = Object.fromEntries(
callBody.recipients.map((r: any) => [r.signingOrder, r.name]),
);
for (const r of callBody.recipients) { for (const r of callBody.recipients) {
expect(r.email).toBe(REDIRECT_TARGET); expect(r.email).toBe(REDIRECT_TARGET);
// Original email preserved in the name for traceability }
expect(r.name).toMatch(/\(was: .+@realclient\.com\)/); // Name must stay CLEAN — it renders into the signed PDF's Name field, so
// the "(was: …)" redirect annotation must NOT leak into it (it overlapped
// the signature). Email-only redirect; original email lives in the logs.
expect(namesByOrder[1]).toBe('Alice Smith');
expect(namesByOrder[2]).toBe('Bob Smith');
for (const r of callBody.recipients) {
expect(r.name).not.toContain('(was:');
}
});
it('createDocument - suppresses Documenso own emails (emailSettings all false)', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.createDocument('Test Doc', 'pdf-base64', [
{ name: 'Alice Smith', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 },
]);
expect(fetchMock).toHaveBeenCalledOnce();
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any;
// The CRM is the SOLE sender of signing comms. Documenso must never fire
// its own "Waiting for others" / "Signing Complete!" lifecycle emails, so
// every per-document email event is disabled at creation time.
expect(callBody.meta).toBeDefined();
expect(callBody.meta.emailSettings).toBeDefined();
const es = callBody.meta.emailSettings;
for (const key of [
'recipientSigningRequest',
'recipientSigned',
'recipientRemoved',
'documentPending',
'documentCompleted',
'documentDeleted',
'ownerDocumentCreated',
'ownerDocumentCompleted',
'ownerRecipientExpired',
]) {
expect(es[key]).toBe(false);
}
});
it('createDocument (v2) - emailSettings all false in the multipart payload', async () => {
process.env.DOCUMENSO_API_VERSION = 'v2';
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.createDocument('Test Doc', 'pdf-base64', [
{ name: 'Alice Smith', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 },
]);
// v2 envelope/create is multipart/form-data; the JSON lives in `payload`.
const form = fetchMock.mock.calls[0]![1].body as FormData;
const payload = JSON.parse(form.get('payload') as string) as any;
expect(payload.meta.emailSettings).toBeDefined();
for (const key of [
'recipientSigningRequest',
'recipientSigned',
'documentPending',
'documentCompleted',
'ownerDocumentCompleted',
]) {
expect(payload.meta.emailSettings[key]).toBe(false);
} }
}); });
@@ -102,7 +171,8 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any; const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any;
for (const r of callBody.recipients) { for (const r of callBody.recipients) {
expect(r.email).toBe(REDIRECT_TARGET); expect(r.email).toBe(REDIRECT_TARGET);
expect(r.name).toMatch(/\(was: .+@realclient\.com\)/); // Name stays clean — no "(was: …)" annotation (renders into the PDF).
expect(r.name).not.toContain('(was:');
} }
}); });