Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 10 additions & 13 deletions src/resources/drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
10 changes: 4 additions & 6 deletions src/resources/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
encodeAttachmentStreams,
objKeysToSnakeCase,
makePathParams,
calculateTotalPayloadSize,
} from '../utils.js';
import { AsyncListResponse, Resource } from './resource.js';
import { SmartCompose } from './smartCompose.js';
Expand Down Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,33 @@ export function makePathParams<Path extends string>(
): 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;
}
89 changes: 89 additions & 0 deletions tests/resources/drafts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
81 changes: 81 additions & 0 deletions tests/resources/messages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down