From fc994cd88bef0156c53a1bac5374ea1640cffb12 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 25 Jun 2026 13:20:55 +0200 Subject: [PATCH] fix(eoi): silence Documenso's own lifecycle emails on createDocument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: )" 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) --- src/lib/services/documenso-client.ts | 81 +++++++++++++++++++--------- tests/unit/comms-safety.test.ts | 76 ++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 29 deletions(-) diff --git a/src/lib/services/documenso-client.ts b/src/lib/services/documenso-client.ts index dede9f94..18eea6c2 100644 --- a/src/lib/services/documenso-client.ts +++ b/src/lib/services/documenso-client.ts @@ -243,16 +243,19 @@ 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. + * data import / migration dry-run. * - * 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: )") 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[] { if (!env.EMAIL_REDIRECT_TO) return recipients; return recipients.map((r) => ({ ...r, - name: `${r.name} (was: ${r.email})`, email: env.EMAIL_REDIRECT_TO!, })); } @@ -265,11 +268,11 @@ function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoReci function applyPayloadRedirect(payload: Record): Record { if (!env.EMAIL_REDIRECT_TO) return payload; const out: Record = { ...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)) { out.recipients = (out.recipients as Array>).map((r) => ({ ...r, - name: `${String(r.name ?? '')} (was: ${String(r.email ?? '')})`, email: env.EMAIL_REDIRECT_TO, })); } @@ -288,11 +291,41 @@ function applyPayloadRedirect(payload: Record): Record { const originalRedirect = process.env.EMAIL_REDIRECT_TO; const originalDocumensoUrl = process.env.DOCUMENSO_API_URL; const originalDocumensoKey = process.env.DOCUMENSO_API_KEY; + const originalDocumensoVersion = process.env.DOCUMENSO_API_VERSION; let fetchMock: ReturnType; @@ -28,6 +29,10 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => { process.env.EMAIL_REDIRECT_TO = REDIRECT_TARGET; process.env.DOCUMENSO_API_URL = 'https://documenso.example.test'; 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 () => ({ ok: true, @@ -49,6 +54,8 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => { else process.env.DOCUMENSO_API_URL = originalDocumensoUrl; if (originalDocumensoKey === undefined) delete process.env.DOCUMENSO_API_KEY; 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(); }); @@ -63,10 +70,72 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => { expect(fetchMock).toHaveBeenCalledOnce(); const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as any; expect(callBody.recipients).toHaveLength(2); + const namesByOrder = Object.fromEntries( + callBody.recipients.map((r: any) => [r.signingOrder, r.name]), + ); for (const r of callBody.recipients) { 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; for (const r of callBody.recipients) { 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:'); } });