diff --git a/packages/email-plugin/src/attachment-utils.ts b/packages/email-plugin/src/attachment-utils.ts index 18cd8a7532..7a7132734f 100644 --- a/packages/email-plugin/src/attachment-utils.ts +++ b/packages/email-plugin/src/attachment-utils.ts @@ -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 { 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, @@ -18,7 +18,8 @@ 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); @@ -26,6 +27,15 @@ export async function serializeAttachments(attachments: EmailAttachment[]): Prom 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), @@ -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 { + 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(input: T | null): T | undefined { return input == null ? undefined : input; } diff --git a/packages/email-plugin/src/event-handler.ts b/packages/email-plugin/src/event-handler.ts index b697cfe2b7..9db939384b 100644 --- a/packages/email-plugin/src/event-handler.ts +++ b/packages/email-plugin/src/event-handler.ts @@ -127,8 +127,13 @@ export class EmailEventHandler { 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" ') + .setRecipient(() => 'test@test.com') + .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" ') + .setRecipient(() => 'test@test.com') + .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" ') + .setRecipient(() => 'test@test.com') + .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" ') + .setRecipient(() => 'test@test.com') + .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" ') + .setRecipient(() => 'test@test.com') + .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" ') + .setRecipient(() => 'test@test.com') + .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', () => { diff --git a/packages/email-plugin/src/types.ts b/packages/email-plugin/src/types.ts index a368e97a0b..660c13f7f1 100644 --- a/packages/email-plugin/src/types.ts +++ b/packages/email-plugin/src/types.ts @@ -360,7 +360,7 @@ export type LoadDataFn = (context: { injector: Injector; }) => Promise; -export type OptionalTuNullable = { +export type OptionalToNullable = { [K in keyof O]-?: undefined extends O[K] ? NonNullable | null : O[K]; }; @@ -374,9 +374,11 @@ export type OptionalTuNullable = { * @docsCategory EmailPlugin * @docsPage Email Plugin Types */ -export type EmailAttachment = Omit & { path: string }; +export type EmailAttachment = Omit & { path?: string }; -export type SerializedAttachment = OptionalTuNullable; +export type SerializedAttachment = OptionalToNullable< + Omit & { content: string | null } +>; export type IntermediateEmailDetails = { type: string;