From 0cae0cbf2e6b154d8cfde97ac8599e31e6b2cbda Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Fri, 30 May 2025 13:15:24 -0400 Subject: [PATCH] fix: enforce 3MB limit on total payload size (body + attachments) Previously, the SDK only considered attachment size when determining whether to use multipart/form-data encoding, ignoring the email body size. This caused requests to fail when the combined size of message body and attachments exceeded 3MB, even if attachments alone were under the limit. Changes: - Add calculateTotalPayloadSize() function to calculate total request payload size - Update Messages.send() to use total payload size instead of just attachment size - Update Drafts.create() and Drafts.update() to use total payload size - Add comprehensive tests to verify the fix works correctly - Maintain full backwards compatibility Fixes: Requests failing when total payload exceeds 3MB despite small attachments --- CHANGELOG.md | 3 ++ src/resources/drafts.ts | 23 ++++----- src/resources/messages.ts | 10 ++-- src/utils.ts | 30 +++++++++++ tests/resources/drafts.spec.ts | 89 ++++++++++++++++++++++++++++++++ tests/resources/messages.spec.ts | 81 +++++++++++++++++++++++++++++ 6 files changed, 217 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98456771..df9c6477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support for `rawMime` property in Message responses when using `fields=raw_mime` - `MessageTrackingOptions` interface for tracking message opens, thread replies, link clicks, and custom labels +### Fixed +- Fixed 3MB payload size limit to consider total request size (message body + attachments) instead of just attachment size when determining whether to use multipart/form-data encoding + ## [7.10.0] - 2025-05-27 ### Added diff --git a/src/resources/drafts.ts b/src/resources/drafts.ts index 30865ebb..15196295 100644 --- a/src/resources/drafts.ts +++ b/src/resources/drafts.ts @@ -13,7 +13,10 @@ import { NylasListResponse, NylasResponse, } from '../models/response.js'; -import { encodeAttachmentStreams } from '../utils.js'; +import { + encodeAttachmentStreams, + calculateTotalPayloadSize, +} from '../utils.js'; import { makePathParams } from '../utils.js'; /** * The parameters for the {@link Drafts.list} method @@ -122,13 +125,10 @@ export class Drafts extends Resource { identifier, }); - // Use form data only if the attachment size is greater than 3mb - const attachmentSize = - requestBody.attachments?.reduce((total, attachment) => { - return total + (attachment.size || 0); - }, 0) || 0; + // Use form data if the total payload size (body + attachments) is greater than 3mb + const totalPayloadSize = calculateTotalPayloadSize(requestBody); - if (attachmentSize >= Messages.MAXIMUM_JSON_ATTACHMENT_SIZE) { + if (totalPayloadSize >= Messages.MAXIMUM_JSON_ATTACHMENT_SIZE) { const form = Messages._buildFormRequest(requestBody); return this.apiClient.request({ @@ -170,13 +170,10 @@ export class Drafts extends Resource { draftId, }); - // Use form data only if the attachment size is greater than 3mb - const attachmentSize = - requestBody.attachments?.reduce((total, attachment) => { - return total + (attachment.size || 0); - }, 0) || 0; + // Use form data if the total payload size (body + attachments) is greater than 3mb + const totalPayloadSize = calculateTotalPayloadSize(requestBody); - if (attachmentSize >= Messages.MAXIMUM_JSON_ATTACHMENT_SIZE) { + if (totalPayloadSize >= Messages.MAXIMUM_JSON_ATTACHMENT_SIZE) { const form = Messages._buildFormRequest(requestBody); return this.apiClient.request({ diff --git a/src/resources/messages.ts b/src/resources/messages.ts index 5d0cfd22..33693972 100644 --- a/src/resources/messages.ts +++ b/src/resources/messages.ts @@ -26,6 +26,7 @@ import { encodeAttachmentStreams, objKeysToSnakeCase, makePathParams, + calculateTotalPayloadSize, } from '../utils.js'; import { AsyncListResponse, Resource } from './resource.js'; import { SmartCompose } from './smartCompose.js'; @@ -240,13 +241,10 @@ export class Messages extends Resource { overrides, }; - // Use form data only if the attachment size is greater than 3mb - const attachmentSize = - requestBody.attachments?.reduce((total, attachment) => { - return total + (attachment.size || 0); - }, 0) || 0; + // Use form data if the total payload size (body + attachments) is greater than 3mb + const totalPayloadSize = calculateTotalPayloadSize(requestBody); - if (attachmentSize >= Messages.MAXIMUM_JSON_ATTACHMENT_SIZE) { + if (totalPayloadSize >= Messages.MAXIMUM_JSON_ATTACHMENT_SIZE) { requestOptions.form = Messages._buildFormRequest(requestBody); } else { if (requestBody.attachments) { diff --git a/src/utils.ts b/src/utils.ts index a413d03a..5ae41e01 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -210,3 +210,33 @@ export function makePathParams( ): string { return safePath(path, params); } + +/** + * Calculates the total payload size for a message request, including body and attachments. + * This is used to determine if multipart/form-data should be used instead of JSON. + * @param requestBody The message request body + * @returns The total estimated payload size in bytes + */ +export function calculateTotalPayloadSize(requestBody: any): number { + let totalSize = 0; + + // Calculate size of the message body (JSON payload without attachments) + const messagePayloadWithoutAttachments = { + ...requestBody, + attachments: undefined, + }; + const messagePayloadString = JSON.stringify( + objKeysToSnakeCase(messagePayloadWithoutAttachments) + ); + totalSize += Buffer.byteLength(messagePayloadString, 'utf8'); + + // Add attachment sizes + const attachmentSize = + requestBody.attachments?.reduce((total: number, attachment: any) => { + return total + (attachment.size || 0); + }, 0) || 0; + + totalSize += attachmentSize; + + return totalSize; +} diff --git a/tests/resources/drafts.spec.ts b/tests/resources/drafts.spec.ts index 733a61c4..229611ff 100644 --- a/tests/resources/drafts.spec.ts +++ b/tests/resources/drafts.spec.ts @@ -304,6 +304,50 @@ describe('Drafts', () => { headers: { override: 'bar' }, }); }); + + it('should use multipart when total payload (body + attachments) exceeds 3MB for create', async () => { + // Create a large message body that, combined with small attachments, exceeds 3MB + const largeBody = 'A'.repeat(3.5 * 1024 * 1024); // 3.5MB body + const messageJson = { + to: [{ name: 'Test', email: 'test@example.com' }], + subject: 'This is my test email with large content', + body: largeBody, + }; + + const fileStream = createReadableStream('Small attachment content'); + const smallAttachment: CreateAttachmentRequest = { + filename: 'small_file.txt', + contentType: 'text/plain', + content: fileStream, + size: 1000, // 1KB attachment - small but total payload > 3MB + }; + + await drafts.create({ + identifier: 'id123', + requestBody: { + ...messageJson, + attachments: [smallAttachment], + }, + overrides: { + apiUri: 'https://test.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + const capturedRequest = apiClient.request.mock.calls[0][0]; + + // Should use form data because total payload exceeds 3MB + expect(capturedRequest.form).toBeDefined(); + expect(capturedRequest.body).toBeUndefined(); + + const formData = ( + capturedRequest.form as any as MockedFormData + )._getAppendedData(); + expect(formData.message).toEqual(JSON.stringify(messageJson)); + expect(formData.file0).toEqual(fileStream); + expect(capturedRequest.method).toEqual('POST'); + expect(capturedRequest.path).toEqual('/v3/grants/id123/drafts'); + }); }); describe('update', () => { @@ -392,6 +436,51 @@ describe('Drafts', () => { headers: { override: 'bar' }, }); }); + + it('should use multipart when total payload (body + attachments) exceeds 3MB for update', async () => { + // Create a large message body that, combined with small attachments, exceeds 3MB + const largeBody = 'A'.repeat(3.5 * 1024 * 1024); // 3.5MB body + const messageJson = { + to: [{ name: 'Test', email: 'test@example.com' }], + subject: 'This is my test email with large content', + body: largeBody, + }; + + const fileStream = createReadableStream('Small attachment content'); + const smallAttachment: CreateAttachmentRequest = { + filename: 'small_file.txt', + contentType: 'text/plain', + content: fileStream, + size: 1000, // 1KB attachment - small but total payload > 3MB + }; + + await drafts.update({ + identifier: 'id123', + draftId: 'draft123', + requestBody: { + ...messageJson, + attachments: [smallAttachment], + }, + overrides: { + apiUri: 'https://test.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + const capturedRequest = apiClient.request.mock.calls[0][0]; + + // Should use form data because total payload exceeds 3MB + expect(capturedRequest.form).toBeDefined(); + expect(capturedRequest.body).toBeUndefined(); + + const formData = ( + capturedRequest.form as any as MockedFormData + )._getAppendedData(); + expect(formData.message).toEqual(JSON.stringify(messageJson)); + expect(formData.file0).toEqual(fileStream); + expect(capturedRequest.method).toEqual('PUT'); + expect(capturedRequest.path).toEqual('/v3/grants/id123/drafts/draft123'); + }); }); describe('destroy', () => { diff --git a/tests/resources/messages.spec.ts b/tests/resources/messages.spec.ts index 96280fe8..31714834 100644 --- a/tests/resources/messages.spec.ts +++ b/tests/resources/messages.spec.ts @@ -399,6 +399,87 @@ describe('Messages', () => { headers: { override: 'bar' }, }); }); + + it('should use multipart when total payload (body + attachments) exceeds 3MB', async () => { + // Create a large message body that, combined with small attachments, exceeds 3MB + const largeBody = 'A'.repeat(3.5 * 1024 * 1024); // 3.5MB body + const messageJson = { + to: [{ name: 'Test', email: 'test@example.com' }], + subject: 'This is my test email with large content', + body: largeBody, + }; + + const fileStream = createReadableStream('Small attachment content'); + const smallAttachment: CreateAttachmentRequest = { + filename: 'small_file.txt', + contentType: 'text/plain', + content: fileStream, + size: 1000, // 1KB attachment - small but total payload > 3MB + }; + + await messages.send({ + identifier: 'id123', + requestBody: { + ...messageJson, + attachments: [smallAttachment], + }, + overrides: { + apiUri: 'https://test.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + const capturedRequest = apiClient.request.mock.calls[0][0]; + + // Should use form data because total payload exceeds 3MB + expect(capturedRequest.form).toBeDefined(); + expect(capturedRequest.body).toBeUndefined(); + + const formData = ( + capturedRequest.form as any as MockedFormData + )._getAppendedData(); + expect(formData.message).toEqual(JSON.stringify(messageJson)); + expect(formData.file0).toEqual(fileStream); + expect(capturedRequest.method).toEqual('POST'); + expect(capturedRequest.path).toEqual('/v3/grants/id123/messages/send'); + }); + + it('should use JSON when total payload (body + attachments) is under 3MB', async () => { + // Create a message with body + attachments under 3MB + const smallBody = 'Small message content'; + const messageJson = { + to: [{ name: 'Test', email: 'test@example.com' }], + subject: 'This is my test email', + body: smallBody, + }; + + const smallAttachment: CreateAttachmentRequest = { + filename: 'small_file.txt', + contentType: 'text/plain', + content: createReadableStream('Small attachment content'), + size: 1000, // 1KB attachment + }; + + await messages.send({ + identifier: 'id123', + requestBody: { + ...messageJson, + attachments: [smallAttachment], + }, + overrides: { + apiUri: 'https://test.api.nylas.com', + headers: { override: 'bar' }, + }, + }); + + const capturedRequest = apiClient.request.mock.calls[0][0]; + + // Should use JSON body because total payload is under 3MB + expect(capturedRequest.body).toBeDefined(); + expect(capturedRequest.form).toBeUndefined(); + expect(capturedRequest.method).toEqual('POST'); + expect(capturedRequest.path).toEqual('/v3/grants/id123/messages/send'); + }); }); describe('scheduledMessages', () => {