diff --git a/package.json b/package.json index 768f3145..57059296 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "next-themes": "^0.4.6", "nodemailer": "^8.0.7", "openai": "^6.37.0", + "p-limit": "^7.3.0", "pdf-lib": "^1.17.1", "pdfkit": "^0.18.0", "pino": "^10.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2122dfe..b97620d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: openai: specifier: ^6.37.0 version: 6.37.0(ws@8.18.3)(zod@4.4.3) + p-limit: + specifier: ^7.3.0 + version: 7.3.0 pdf-lib: specifier: ^1.17.1 version: 1.17.1 @@ -4276,6 +4279,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@7.3.0: + resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + engines: {node: '>=20'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -5447,6 +5454,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -9330,6 +9341,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@7.3.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -10659,6 +10674,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + yoctocolors@2.1.2: {} zip-stream@6.0.1: diff --git a/src/lib/services/document-signing-emails.service.ts b/src/lib/services/document-signing-emails.service.ts index f03dc598..5a2e5fc6 100644 --- a/src/lib/services/document-signing-emails.service.ts +++ b/src/lib/services/document-signing-emails.service.ts @@ -28,6 +28,8 @@ * port (single-tenant deploys can keep using Documenso's hosted UI). */ +import pLimit from 'p-limit'; + import { sendEmail } from '@/lib/email'; import { getBrandingShell } from '@/lib/email/branding-resolver'; import { @@ -231,33 +233,41 @@ export async function sendSigningReminder(args: SigningReminderArgs): Promise { const branding = await getBrandingShell(args.portId); + // Cap concurrency at 3: a Sales Contract with 10 recipients (client + + // 5 sellers + 4 witnesses) shouldn't fan out 10 simultaneous SMTP + // sends. Most SMTP providers (Mailgun, SES, Postmark) cap concurrent + // connections in the single digits and silently drop the overflow. + const sendLimit = pLimit(3); + await Promise.all( - args.recipients.map(async (recipient) => { - const { subject, html, text } = signingCompletedEmail( - { - recipientName: recipient.name, - documentLabel: args.documentLabel, - clientName: args.clientName, - portName: args.portName, - completedAt: args.completedAt, - }, - { branding }, - ); - try { - await sendEmail(recipient.email, subject, html, undefined, text, args.portId, [ - { fileId: args.signedPdfFileId, filename: args.signedPdfFilename }, - ]); - logger.info( - { portId: args.portId, recipient: recipient.email, documentLabel: args.documentLabel }, - 'Signing-completed email sent', + args.recipients.map((recipient) => + sendLimit(async () => { + const { subject, html, text } = signingCompletedEmail( + { + recipientName: recipient.name, + documentLabel: args.documentLabel, + clientName: args.clientName, + portName: args.portName, + completedAt: args.completedAt, + }, + { branding }, ); - } catch (err) { - logger.error( - { err, portId: args.portId, recipient: recipient.email }, - 'Signing-completed email send failed', - ); - // Don't throw — sending to one recipient shouldn't block the others. - } - }), + try { + await sendEmail(recipient.email, subject, html, undefined, text, args.portId, [ + { fileId: args.signedPdfFileId, filename: args.signedPdfFilename }, + ]); + logger.info( + { portId: args.portId, recipient: recipient.email, documentLabel: args.documentLabel }, + 'Signing-completed email sent', + ); + } catch (err) { + logger.error( + { err, portId: args.portId, recipient: recipient.email }, + 'Signing-completed email send failed', + ); + // Don't throw — sending to one recipient shouldn't block the others. + } + }), + ), ); } diff --git a/src/lib/services/email-compose.service.ts b/src/lib/services/email-compose.service.ts index 4cfc04b8..3ba4a47b 100644 --- a/src/lib/services/email-compose.service.ts +++ b/src/lib/services/email-compose.service.ts @@ -1,5 +1,6 @@ import nodemailer from 'nodemailer'; import { and, eq, inArray, sql } from 'drizzle-orm'; +import pLimit from 'p-limit'; import { db } from '@/lib/db'; import { emailAccounts, emailMessages, emailThreads } from '@/lib/db/schema/email'; @@ -112,26 +113,33 @@ export async function sendEmail( } } - // Resolve attachments for the user-path SMTP send. + // Resolve attachments for the user-path SMTP send. Cap concurrency so + // a user attaching 20 large files doesn't fan out 20 simultaneous + // S3/MinIO reads + 20 buffers in memory at once — bounded at 4 means + // peak memory tops out at ~4 × max-file-size irrespective of the + // attachment count. + const attachmentLimit = pLimit(4); const resolvedAttachments = data.attachments ? await Promise.all( - data.attachments.map(async (ref) => { - const file = await db.query.files.findFirst({ - where: eq(files.id, ref.fileId), - }); - if (!file) throw new NotFoundError('File'); - const { getStorageBackend } = await import('@/lib/storage'); - const stream = await (await getStorageBackend()).get(file.storagePath); - const chunks: Buffer[] = []; - for await (const chunk of stream) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return { - filename: ref.filename ?? file.originalName, - content: Buffer.concat(chunks), - ...(file.mimeType ? { contentType: file.mimeType } : {}), - }; - }), + data.attachments.map((ref) => + attachmentLimit(async () => { + const file = await db.query.files.findFirst({ + where: eq(files.id, ref.fileId), + }); + if (!file) throw new NotFoundError('File'); + const { getStorageBackend } = await import('@/lib/storage'); + const stream = await (await getStorageBackend()).get(file.storagePath); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return { + filename: ref.filename ?? file.originalName, + content: Buffer.concat(chunks), + ...(file.mimeType ? { contentType: file.mimeType } : {}), + }; + }), + ), ) : undefined;