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', () => {