diff --git a/CHANGELOG.md b/CHANGELOG.md index 970a06ce..305d2103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Unreleased * Added support for `provider` field in code exchange response +* Fixed issue where attachments < 3mb were not being encoded correctly + ### 7.3.0 / 2024-04-15 * Add response type to `sendRsvp` diff --git a/src/models/attachments.ts b/src/models/attachments.ts index 0434b7d9..d464c1b1 100644 --- a/src/models/attachments.ts +++ b/src/models/attachments.ts @@ -39,8 +39,9 @@ interface BaseAttachment { export interface CreateAttachmentRequest extends BaseAttachment { /** * Content of the attachment. + * It can either be a readable stream or a base64 encoded string. */ - content: NodeJS.ReadableStream; + content: NodeJS.ReadableStream | string; } /** diff --git a/src/resources/drafts.ts b/src/resources/drafts.ts index 20dfd22c..bcf1f915 100644 --- a/src/resources/drafts.ts +++ b/src/resources/drafts.ts @@ -13,6 +13,7 @@ import { NylasListResponse, NylasResponse, } from '../models/response.js'; +import { encodeAttachmentStreams } from '../utils.js'; /** * The parameters for the {@link Drafts.list} method @@ -109,7 +110,7 @@ export class Drafts extends Resource { * Return a Draft * @return The draft */ - public create({ + public async create({ identifier, requestBody, overrides, @@ -118,8 +119,8 @@ export class Drafts extends Resource { // Use form data only if the attachment size is greater than 3mb const attachmentSize = - requestBody.attachments?.reduce(function(_, attachment) { - return attachment.size || 0; + requestBody.attachments?.reduce((total, attachment) => { + return total + (attachment.size || 0); }, 0) || 0; if (attachmentSize >= Messages.MAXIMUM_JSON_ATTACHMENT_SIZE) { @@ -131,6 +132,15 @@ export class Drafts extends Resource { form, overrides, }); + } else if (requestBody.attachments) { + const processedAttachments = await encodeAttachmentStreams( + requestBody.attachments + ); + + requestBody = { + ...requestBody, + attachments: processedAttachments, + }; } return super._create({ @@ -144,7 +154,7 @@ export class Drafts extends Resource { * Update a Draft * @return The updated draft */ - public update({ + public async update({ identifier, draftId, requestBody, @@ -154,8 +164,8 @@ export class Drafts extends Resource { // Use form data only if the attachment size is greater than 3mb const attachmentSize = - requestBody.attachments?.reduce(function(_, attachment) { - return attachment.size || 0; + requestBody.attachments?.reduce((total, attachment) => { + return total + (attachment.size || 0); }, 0) || 0; if (attachmentSize >= Messages.MAXIMUM_JSON_ATTACHMENT_SIZE) { @@ -167,6 +177,15 @@ export class Drafts extends Resource { form, overrides, }); + } else if (requestBody.attachments) { + const processedAttachments = await encodeAttachmentStreams( + requestBody.attachments + ); + + requestBody = { + ...requestBody, + attachments: processedAttachments, + }; } return super._update({ diff --git a/src/resources/messages.ts b/src/resources/messages.ts index 4be8ed80..d590ffa2 100644 --- a/src/resources/messages.ts +++ b/src/resources/messages.ts @@ -22,7 +22,7 @@ import { UpdateDraftRequest, } from '../models/drafts.js'; import * as FormData from 'form-data'; -import { objKeysToSnakeCase } from '../utils.js'; +import { encodeAttachmentStreams, objKeysToSnakeCase } from '../utils.js'; import { SmartCompose } from './smartCompose.js'; import APIClient, { RequestOptionsParams } from '../apiClient.js'; @@ -201,7 +201,7 @@ export class Messages extends Resource { * Send an email * @return The sent message */ - public send({ + public async send({ identifier, requestBody, overrides, @@ -215,14 +215,25 @@ export class Messages extends Resource { // Use form data only if the attachment size is greater than 3mb const attachmentSize = - requestBody.attachments?.reduce(function(_, attachment) { - return attachment.size || 0; + requestBody.attachments?.reduce((total, attachment) => { + return total + (attachment.size || 0); }, 0) || 0; if (attachmentSize >= Messages.MAXIMUM_JSON_ATTACHMENT_SIZE) { requestOptions.form = Messages._buildFormRequest(requestBody); } else { - requestOptions.body = requestBody; + if (requestBody.attachments) { + const processedAttachments = await encodeAttachmentStreams( + requestBody.attachments + ); + + requestOptions.body = { + ...requestBody, + attachments: processedAttachments, + }; + } else { + requestOptions.body = requestBody; + } } return this.apiClient.request(requestOptions); diff --git a/src/utils.ts b/src/utils.ts index ff706b41..1bef91ef 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,6 +2,7 @@ import { camelCase, snakeCase } from 'change-case'; import * as fs from 'fs'; import * as path from 'path'; import * as mime from 'mime-types'; +import { Readable } from 'stream'; import { CreateAttachmentRequest } from './models/attachments.js'; export function createFileRequestBuilder( @@ -20,6 +21,46 @@ export function createFileRequestBuilder( }; } +/** + * Converts a ReadableStream to a base64 encoded string. + * @param stream The ReadableStream containing the binary data. + * @returns The stream base64 encoded to a string. + */ +function streamToBase64(stream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + stream.on('end', () => { + const base64 = Buffer.concat(chunks).toString('base64'); + resolve(base64); + }); + stream.on('error', err => { + reject(err); + }); + }); +} + +/** + * Encodes the content of each attachment stream to base64. + * @param attachments The attachments to encode. + * @returns The attachments with their content encoded to base64. + */ +export async function encodeAttachmentStreams( + attachments: CreateAttachmentRequest[] +): Promise { + return await Promise.all( + attachments.map(async attachment => { + const base64EncodedContent = + attachment.content instanceof Readable + ? await streamToBase64(attachment.content) + : attachment.content; + return { ...attachment, content: base64EncodedContent }; // Replace the stream with its base64 string + }) + ); +} + /** * The type of function that converts a string to a different casing. * @ignore Not for public use. diff --git a/tests/resources/drafts.spec.ts b/tests/resources/drafts.spec.ts index 8ce6a6e5..e863dd83 100644 --- a/tests/resources/drafts.spec.ts +++ b/tests/resources/drafts.spec.ts @@ -197,15 +197,28 @@ describe('Drafts', () => { }); it('should attach files less than 3mb', async () => { - const fileStream = createReadableStream('This is the text from file 1'); - const jsonBody = { + const baseJson = { to: [{ name: 'Test', email: 'test@example.com' }], subject: 'This is my test email', + }; + const jsonBody = { + ...baseJson, attachments: [ { filename: 'file1.txt', contentType: 'text/plain', - content: fileStream, + content: createReadableStream('This is the text from file 1'), + size: 100, + }, + ], + }; + const expectedJson = { + ...baseJson, + attachments: [ + { + filename: 'file1.txt', + contentType: 'text/plain', + content: 'VGhpcyBpcyB0aGUgdGV4dCBmcm9tIGZpbGUgMQ==', size: 100, }, ], @@ -222,7 +235,7 @@ describe('Drafts', () => { const capturedRequest = apiClient.request.mock.calls[0][0]; expect(capturedRequest.method).toEqual('POST'); expect(capturedRequest.path).toEqual('/v3/grants/id123/drafts'); - expect(capturedRequest.body).toEqual(jsonBody); + expect(capturedRequest.body).toEqual(expectedJson); expect(capturedRequest.overrides).toEqual({ apiUri: 'https://test.api.nylas.com', headers: { override: 'bar' }, @@ -269,14 +282,27 @@ describe('Drafts', () => { describe('update', () => { it('should call apiClient.request with the correct params', async () => { - const fileStream = createReadableStream('This is the text from file 1'); - const jsonBody = { + const baseJson = { subject: 'updated subject', + }; + const jsonBody = { + ...baseJson, attachments: [ { filename: 'file1.txt', contentType: 'text/plain', - content: fileStream, + content: createReadableStream('This is the text from file 1'), + size: 100, + }, + ], + }; + const expectedJson = { + ...baseJson, + attachments: [ + { + filename: 'file1.txt', + contentType: 'text/plain', + content: 'VGhpcyBpcyB0aGUgdGV4dCBmcm9tIGZpbGUgMQ==', size: 100, }, ], @@ -294,7 +320,7 @@ describe('Drafts', () => { const capturedRequest = apiClient.request.mock.calls[0][0]; expect(capturedRequest.method).toEqual('PUT'); expect(capturedRequest.path).toEqual('/v3/grants/id123/drafts/draft123'); - expect(capturedRequest.body).toEqual(jsonBody); + expect(capturedRequest.body).toEqual(expectedJson); expect(capturedRequest.overrides).toEqual({ apiUri: 'https://test.api.nylas.com', headers: { override: 'bar' }, diff --git a/tests/resources/messages.spec.ts b/tests/resources/messages.spec.ts index b5de9d7c..20393683 100644 --- a/tests/resources/messages.spec.ts +++ b/tests/resources/messages.spec.ts @@ -156,15 +156,28 @@ describe('Messages', () => { }); it('should attach files less than 3mb', async () => { - const fileStream = createReadableStream('This is the text from file 1'); - const jsonBody = { + const baseJson = { to: [{ name: 'Test', email: 'test@example.com' }], subject: 'This is my test email', + }; + const jsonBody = { + ...baseJson, attachments: [ { filename: 'file1.txt', contentType: 'text/plain', - content: fileStream, + content: createReadableStream('This is the text from file 1'), + size: 100, + }, + ], + }; + const expectedJson = { + ...baseJson, + attachments: [ + { + filename: 'file1.txt', + contentType: 'text/plain', + content: 'VGhpcyBpcyB0aGUgdGV4dCBmcm9tIGZpbGUgMQ==', size: 100, }, ], @@ -181,7 +194,7 @@ describe('Messages', () => { const capturedRequest = apiClient.request.mock.calls[0][0]; expect(capturedRequest.method).toEqual('POST'); expect(capturedRequest.path).toEqual('/v3/grants/id123/messages/send'); - expect(capturedRequest.body).toEqual(jsonBody); + expect(capturedRequest.body).toEqual(expectedJson); expect(capturedRequest.overrides).toEqual({ apiUri: 'https://test.api.nylas.com', headers: { override: 'bar' },