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

@@ -21,6 +21,7 @@ describe('Documenso recipient redirect - EMAIL_REDIRECT_TO', () => {
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<typeof vi.fn>;
@@ -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:');
}
});