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:'); } });