Skip to content

Commit

Permalink
feat(email-plugin): Extend attachment support
Browse files Browse the repository at this point in the history
Fixes #882. You can now specify attachments as strings, Buffers or Streams in addition to by file
path.
  • Loading branch information
michaelbromley committed Jun 22, 2021
1 parent 221051f commit 70a55fd
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 13 deletions.
53 changes: 45 additions & 8 deletions packages/email-plugin/src/attachment-utils.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Injectable } from '@nestjs/common';
import { Attachment } from 'nodemailer/lib/mailer';
import { Readable } from 'stream';
import { format } from 'url';
import { Logger } from '@vendure/core';
import { Readable, Stream } from 'stream';
import { format, Url } from 'url';

import { loggerCtx } from './constants';
import { EmailAttachment, SerializedAttachment } from './types';

export async function serializeAttachments(attachments: EmailAttachment[]): Promise<SerializedAttachment[]> {
const promises = attachments.map(async a => {
const stringPath = typeof a.path === 'string' ? a.path : format(a.path);

const stringPath = (path: string | Url) => (typeof path === 'string' ? path : format(path));
const content = a.content instanceof Stream ? await streamToBuffer(a.content) : a.content;
return {
filename: null,
cid: null,
Expand All @@ -18,14 +18,24 @@ export async function serializeAttachments(attachments: EmailAttachment[]): Prom
contentDisposition: null,
headers: null,
...a,
path: stringPath,
path: a.path ? stringPath(a.path) : null,
content: JSON.stringify(content),
};
});
return Promise.all(promises);
}

export function deserializeAttachments(serializedAttachments: SerializedAttachment[]): EmailAttachment[] {
return serializedAttachments.map(a => {
const content = parseContent(a.content);
if (content instanceof Buffer && 50 * 1024 <= content.length) {
Logger.warn(
`Email has a large 'content' attachment (${Math.round(
content.length / 1024,
)}k). Consider using the 'path' instead for improved performance.`,
loggerCtx,
);
}
return {
filename: nullToUndefined(a.filename),
cid: nullToUndefined(a.cid),
Expand All @@ -34,11 +44,38 @@ export function deserializeAttachments(serializedAttachments: SerializedAttachme
contentTransferEncoding: nullToUndefined(a.contentTransferEncoding),
contentDisposition: nullToUndefined(a.contentDisposition),
headers: nullToUndefined(a.headers),
path: a.path,
path: nullToUndefined(a.path),
content,
};
});
}

function parseContent(content: string | null): string | Buffer | undefined {
try {
const parsedContent = content && JSON.parse(content);
if (typeof parsedContent === 'string') {
return parsedContent;
} else if (parsedContent.hasOwnProperty('data')) {
return Buffer.from(parsedContent.data);
}
} catch (e) {
// empty
}
}

function streamToBuffer(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
stream.on('data', chunk => {
chunks.push(Buffer.from(chunk));
});
stream.on('error', err => reject(err));
stream.on('end', () => {
resolve(Buffer.concat(chunks));
});
});
}

function nullToUndefined<T>(input: T | null): T | undefined {
return input == null ? undefined : input;
}
9 changes: 7 additions & 2 deletions packages/email-plugin/src/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,13 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit

/**
* @description
* Defines one or more files to be attached to the email. An attachment _must_ specify
* a `path` property which can be either a file system path _or_ a URL to the file.
* Defines one or more files to be attached to the email. An attachment can be specified
* as either a `path` (to a file or URL) or as `content` which can be a string, Buffer or Stream.
*
* **Note:** When using the `content` to pass a Buffer or Stream, the raw data will get serialized
* into the job queue. For this reason the total size of all attachments passed as `content` should kept to
* **less than ~50k**. If the attachments are greater than that limit, a warning will be logged and
* errors may result if using the DefaultJobQueuePlugin with certain DBs such as MySQL/MariaDB.
*
* @example
* ```TypeScript
Expand Down
129 changes: 129 additions & 0 deletions packages/email-plugin/src/plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
VendureEvent,
} from '@vendure/core';
import { TestingLogger } from '@vendure/testing';
import { createReadStream, readFileSync } from 'fs';
import { readFile } from 'fs-extra';
import path from 'path';
import { Readable } from 'stream';

import { orderConfirmationHandler } from './default-email-handlers';
import { EmailEventHandler } from './event-handler';
Expand Down Expand Up @@ -504,6 +507,132 @@ describe('EmailPlugin', () => {

expect(onSend.mock.calls[0][0].attachments).toEqual([{ path: TEST_IMAGE_PATH }]);
});

it('attachment content as a string buffer', async () => {
const handler = new EmailEventListener('test')
.on(MockEvent)
.setFrom('"test from" <[email protected]>')
.setRecipient(() => '[email protected]')
.setSubject('Hello {{ subjectVar }}')
.setAttachments(() => [
{
content: Buffer.from('hello'),
},
]);

await initPluginWithHandlers([handler]);
eventBus.publish(new MockEvent(ctx, true));
await pause();

const attachment = onSend.mock.calls[0][0].attachments[0].content;
expect(Buffer.compare(attachment, Buffer.from('hello'))).toBe(0); // 0 = buffers are equal
});

it('attachment content as an image buffer', async () => {
const imageFileBuffer = readFileSync(TEST_IMAGE_PATH);
const handler = new EmailEventListener('test')
.on(MockEvent)
.setFrom('"test from" <[email protected]>')
.setRecipient(() => '[email protected]')
.setSubject('Hello {{ subjectVar }}')
.setAttachments(() => [
{
content: imageFileBuffer,
},
]);

await initPluginWithHandlers([handler]);
eventBus.publish(new MockEvent(ctx, true));
await pause();

const attachment = onSend.mock.calls[0][0].attachments[0].content;
expect(Buffer.compare(attachment, imageFileBuffer)).toBe(0); // 0 = buffers are equal
});

it('attachment content as a string', async () => {
const handler = new EmailEventListener('test')
.on(MockEvent)
.setFrom('"test from" <[email protected]>')
.setRecipient(() => '[email protected]')
.setSubject('Hello {{ subjectVar }}')
.setAttachments(() => [
{
content: 'hello',
},
]);

await initPluginWithHandlers([handler]);
eventBus.publish(new MockEvent(ctx, true));
await pause();

const attachment = onSend.mock.calls[0][0].attachments[0].content;
expect(attachment).toBe('hello');
});

it('attachment content as a string stream', async () => {
const handler = new EmailEventListener('test')
.on(MockEvent)
.setFrom('"test from" <[email protected]>')
.setRecipient(() => '[email protected]')
.setSubject('Hello {{ subjectVar }}')
.setAttachments(() => [
{
content: Readable.from(['hello']),
},
]);

await initPluginWithHandlers([handler]);
eventBus.publish(new MockEvent(ctx, true));
await pause();

const attachment = onSend.mock.calls[0][0].attachments[0].content;
expect(Buffer.compare(attachment, Buffer.from('hello'))).toBe(0); // 0 = buffers are equal
});

it('attachment content as an image stream', async () => {
const imageFileBuffer = readFileSync(TEST_IMAGE_PATH);
const imageFileStream = createReadStream(TEST_IMAGE_PATH);
const handler = new EmailEventListener('test')
.on(MockEvent)
.setFrom('"test from" <[email protected]>')
.setRecipient(() => '[email protected]')
.setSubject('Hello {{ subjectVar }}')
.setAttachments(() => [
{
content: imageFileStream,
},
]);

await initPluginWithHandlers([handler]);
eventBus.publish(new MockEvent(ctx, true));
await pause();

const attachment = onSend.mock.calls[0][0].attachments[0].content;
expect(Buffer.compare(attachment, imageFileBuffer)).toBe(0); // 0 = buffers are equal
});

it('raises a warning for large content attachments', async () => {
testingLogger.warnSpy.mockClear();
const largeBuffer = Buffer.from(Array.from({ length: 65535, 0: 0 }));
const handler = new EmailEventListener('test')
.on(MockEvent)
.setFrom('"test from" <[email protected]>')
.setRecipient(() => '[email protected]')
.setSubject('Hello {{ subjectVar }}')
.setAttachments(() => [
{
content: largeBuffer,
},
]);

await initPluginWithHandlers([handler]);
eventBus.publish(new MockEvent(ctx, true));
await pause();

expect(testingLogger.warnSpy.mock.calls[0][0]).toContain(
`Email has a large 'content' attachment (64k). Consider using the 'path' instead for improved performance.`,
);
});
});

describe('orderConfirmationHandler', () => {
Expand Down
8 changes: 5 additions & 3 deletions packages/email-plugin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ export type LoadDataFn<Event extends EventWithContext, R> = (context: {
injector: Injector;
}) => Promise<R>;

export type OptionalTuNullable<O> = {
export type OptionalToNullable<O> = {
[K in keyof O]-?: undefined extends O[K] ? NonNullable<O[K]> | null : O[K];
};

Expand All @@ -374,9 +374,11 @@ export type OptionalTuNullable<O> = {
* @docsCategory EmailPlugin
* @docsPage Email Plugin Types
*/
export type EmailAttachment = Omit<Attachment, 'content' | 'raw'> & { path: string };
export type EmailAttachment = Omit<Attachment, 'raw'> & { path?: string };

export type SerializedAttachment = OptionalTuNullable<EmailAttachment>;
export type SerializedAttachment = OptionalToNullable<
Omit<EmailAttachment, 'content'> & { content: string | null }
>;

export type IntermediateEmailDetails = {
type: string;
Expand Down

0 comments on commit 70a55fd

Please sign in to comment.