Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed issue where attachments < 3mb were not being encoded correctly #559

Merged
merged 9 commits into from
May 1, 2024
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

### Unreleased
* Fixed issue where attachments < 3mb were not being encoded correctly

### 7.3.0 / 2024-04-15
* Add response type to `sendRsvp`
* Add support for adding custom headers to outgoing requests
Expand Down
3 changes: 2 additions & 1 deletion src/models/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
23 changes: 21 additions & 2 deletions src/resources/drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
NylasListResponse,
NylasResponse,
} from '../models/response.js';
import { encodeAttachmentStreams } from '../utils.js';

/**
* The parameters for the {@link Drafts.list} method
Expand Down Expand Up @@ -109,7 +110,7 @@ export class Drafts extends Resource {
* Return a Draft
* @return The draft
*/
public create({
public async create({
identifier,
requestBody,
overrides,
Expand All @@ -131,6 +132,15 @@ export class Drafts extends Resource {
form,
mrashed-dev marked this conversation as resolved.
Show resolved Hide resolved
overrides,
});
} else if (requestBody.attachments) {
const processedAttachments = await encodeAttachmentStreams(
requestBody.attachments
);

requestBody = {
...requestBody,
attachments: processedAttachments,
};
}

return super._create({
Expand All @@ -144,7 +154,7 @@ export class Drafts extends Resource {
* Update a Draft
* @return The updated draft
*/
public update({
public async update({
identifier,
draftId,
requestBody,
Expand All @@ -167,6 +177,15 @@ export class Drafts extends Resource {
form,
mrashed-dev marked this conversation as resolved.
Show resolved Hide resolved
overrides,
});
} else if (requestBody.attachments) {
const processedAttachments = await encodeAttachmentStreams(
requestBody.attachments
);

requestBody = {
...requestBody,
attachments: processedAttachments,
};
}

return super._update({
Expand Down
17 changes: 14 additions & 3 deletions src/resources/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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';

Expand Down Expand Up @@ -189,7 +189,7 @@ export class Messages extends Resource {
* Send an email
* @return The sent message
*/
public send({
public async send({
identifier,
requestBody,
overrides,
Expand All @@ -210,7 +210,18 @@ export class Messages extends Resource {
if (attachmentSize >= Messages.MAXIMUM_JSON_ATTACHMENT_SIZE) {
requestOptions.form = Messages._buildFormRequest(requestBody);
} else {
requestOptions.body = requestBody;
if (requestBody.attachments) {
mrashed-dev marked this conversation as resolved.
Show resolved Hide resolved
const processedAttachments = await encodeAttachmentStreams(
requestBody.attachments
);

requestOptions.body = {
...requestBody,
attachments: processedAttachments,
};
} else {
requestOptions.body = requestBody;
}
}

return this.apiClient.request(requestOptions);
Expand Down
41 changes: 41 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<string> {
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<CreateAttachmentRequest[]> {
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.
Expand Down
42 changes: 34 additions & 8 deletions tests/resources/drafts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]' }],
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,
},
],
Expand All @@ -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' },
Expand Down Expand Up @@ -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,
},
],
Expand All @@ -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' },
Expand Down
21 changes: 17 additions & 4 deletions tests/resources/messages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]' }],
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,
},
],
Expand All @@ -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' },
Expand Down
Loading