Browse Source

feat(email-plugin): Extend attachment support

Fixes #882. You can now specify attachments as strings, Buffers or Streams in addition to by file
path.
Michael Bromley 4 years ago
parent
commit
70a55fdc6f

+ 45 - 8
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<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,
@@ -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<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;
 }

+ 7 - 2
packages/email-plugin/src/event-handler.ts

@@ -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

+ 129 - 0
packages/email-plugin/src/plugin.spec.ts

@@ -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';
@@ -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" <noreply@test.com>')
+                .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" <noreply@test.com>')
+                .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" <noreply@test.com>')
+                .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" <noreply@test.com>')
+                .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" <noreply@test.com>')
+                .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" <noreply@test.com>')
+                .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', () => {

+ 5 - 3
packages/email-plugin/src/types.ts

@@ -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];
 };
 
@@ -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;