Przeglądaj źródła

feat(email-plugin): Allow attachments to be set on emails

Closes #481
Michael Bromley 5 lat temu
rodzic
commit
00820675c9

+ 44 - 0
packages/email-plugin/src/attachment-utils.ts

@@ -0,0 +1,44 @@
+import { Injectable } from '@nestjs/common';
+import { Attachment } from 'nodemailer/lib/mailer';
+import { Readable } from 'stream';
+import { format } from 'url';
+
+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);
+
+        return {
+            filename: null,
+            cid: null,
+            encoding: null,
+            contentType: null,
+            contentTransferEncoding: null,
+            contentDisposition: null,
+            headers: null,
+            ...a,
+            path: stringPath,
+        };
+    });
+    return Promise.all(promises);
+}
+
+export function deserializeAttachments(serializedAttachments: SerializedAttachment[]): EmailAttachment[] {
+    return serializedAttachments.map(a => {
+        return {
+            filename: nullToUndefined(a.filename),
+            cid: nullToUndefined(a.cid),
+            encoding: nullToUndefined(a.encoding),
+            contentType: nullToUndefined(a.contentType),
+            contentTransferEncoding: nullToUndefined(a.contentTransferEncoding),
+            contentDisposition: nullToUndefined(a.contentDisposition),
+            headers: nullToUndefined(a.headers),
+            path: a.path,
+        };
+    });
+}
+
+function nullToUndefined<T>(input: T | null): T | undefined {
+    return input == null ? undefined : input;
+}

+ 6 - 1
packages/email-plugin/src/email-processor.ts

@@ -1,6 +1,7 @@
 import { InternalServerError, Logger } from '@vendure/core';
 import fs from 'fs-extra';
 
+import { deserializeAttachments } from './attachment-utils';
 import { isDevModeOptions } from './common';
 import { loggerCtx } from './constants';
 import { EmailSender } from './email-sender';
@@ -59,7 +60,11 @@ export class EmailProcessor {
                 bodySource,
                 data.templateVars,
             );
-            const emailDetails = { ...generated, recipient: data.recipient };
+            const emailDetails = {
+                ...generated,
+                recipient: data.recipient,
+                attachments: deserializeAttachments(data.attachments),
+            };
             await this.emailSender.send(emailDetails, this.transport);
             return true;
         } catch (err: unknown) {

+ 1 - 0
packages/email-plugin/src/email-sender.ts

@@ -82,6 +82,7 @@ export class EmailSender {
             to: email.recipient,
             subject: email.subject,
             html: email.body,
+            attachments: email.attachments,
         });
     }
 

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

@@ -2,9 +2,18 @@ import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { Type } from '@vendure/common/lib/shared-types';
 import { Injector, Logger } from '@vendure/core';
 
+import { serializeAttachments } from './attachment-utils';
 import { loggerCtx } from './constants';
-import { EmailEventListener, EmailTemplateConfig, SetTemplateVarsFn } from './event-listener';
-import { EventWithAsyncData, EventWithContext, IntermediateEmailDetails, LoadDataFn } from './types';
+import { EmailEventListener } from './event-listener';
+import {
+    EmailTemplateConfig,
+    EventWithAsyncData,
+    EventWithContext,
+    IntermediateEmailDetails,
+    LoadDataFn,
+    SetAttachmentsFn,
+    SetTemplateVarsFn,
+} from './types';
 
 /**
  * @description
@@ -48,6 +57,7 @@ import { EventWithAsyncData, EventWithContext, IntermediateEmailDetails, LoadDat
 export class EmailEventHandler<T extends string = string, Event extends EventWithContext = EventWithContext> {
     private setRecipientFn: (event: Event) => string;
     private setTemplateVarsFn: SetTemplateVarsFn<Event>;
+    private setAttachmentsFn?: SetAttachmentsFn<Event>;
     private filterFns: Array<(event: Event) => boolean> = [];
     private configurations: EmailTemplateConfig[] = [];
     private defaultSubject: string;
@@ -115,6 +125,32 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
         return this;
     }
 
+    /**
+     * @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.
+     *
+     * @example
+     * ```TypeScript
+     * const testAttachmentHandler = new EmailEventListener('activate-voucher')
+     *   .on(ActivateVoucherEvent)
+     *   // ... omitted some steps for brevity
+     *   .setAttachments(async (event) => {
+     *     const { imageUrl, voucherCode } = await getVoucherDataForUser(event.user.id);
+     *     return [
+     *       {
+     *         filename: `voucher-${voucherCode}.jpg`,
+     *         path: imageUrl,
+     *       },
+     *     ];
+     *   });
+     * ```
+     */
+    setAttachments(setAttachmentsFn: SetAttachmentsFn<Event>) {
+        this.setAttachmentsFn = setAttachmentsFn;
+        return this;
+    }
+
     /**
      * @description
      * Add configuration for another template other than the default `"body.hbs"`. Use this method to define specific
@@ -154,6 +190,7 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
         const asyncHandler = new EmailEventHandlerWithAsyncData(loadDataFn, this.listener, this.event);
         asyncHandler.setRecipientFn = this.setRecipientFn;
         asyncHandler.setTemplateVarsFn = this.setTemplateVarsFn;
+        asyncHandler.setAttachmentsFn = this.setAttachmentsFn;
         asyncHandler.filterFns = this.filterFns;
         asyncHandler.configurations = this.configurations;
         asyncHandler.defaultSubject = this.defaultSubject;
@@ -216,6 +253,7 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
         }
         const recipient = this.setRecipientFn(event);
         const templateVars = this.setTemplateVarsFn ? this.setTemplateVarsFn(event, globals) : {};
+        const attachments = await serializeAttachments((await this.setAttachmentsFn?.(event)) ?? []);
         return {
             type: this.type,
             recipient,
@@ -223,6 +261,7 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
             templateVars: { ...globals, ...templateVars },
             subject,
             templateFile: configuration ? configuration.templateFile : 'body.hbs',
+            attachments,
         };
     }
 

+ 0 - 47
packages/email-plugin/src/event-listener.ts

@@ -1,55 +1,8 @@
-import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { Type } from '@vendure/common/lib/shared-types';
 
 import { EmailEventHandler } from './event-handler';
 import { EventWithContext } from './types';
 
-/**
- * @description
- * Configures the {@link EmailEventHandler} to handle a particular channel & languageCode
- * combination.
- *
- * @docsCategory EmailPlugin
- */
-export interface EmailTemplateConfig {
-    /**
-     * @description
-     * Specifies the channel to which this configuration will apply. If set to `'default'`, it will be applied to all
-     * channels.
-     */
-    channelCode: string | 'default';
-    /**
-     * @description
-     * Specifies the languageCode to which this configuration will apply. If set to `'default'`, it will be applied to all
-     * languages.
-     */
-    languageCode: LanguageCode | 'default';
-    /**
-     * @description
-     * Defines the file name of the Handlebars template file to be used to when generating this email.
-     */
-    templateFile: string;
-    /**
-     * @description
-     * A string defining the email subject line. Handlebars variables defined in the `templateVars` object may
-     * be used inside the subject.
-     */
-    subject: string;
-}
-
-/**
- * @description
- * A function used to define template variables available to email templates.
- * See {@link EmailEventHandler}.setTemplateVars().
- *
- * @docsCategory EmailPlugin
- * @docsPage Email Plugin Types
- */
-export type SetTemplateVarsFn<Event> = (
-    event: Event,
-    globals: { [key: string]: any },
-) => { [key: string]: any };
-
 /**
  * @description
  * An EmailEventListener is used to listen for events and set up a {@link EmailEventHandler} which

+ 61 - 1
packages/email-plugin/src/plugin.spec.ts

@@ -318,7 +318,7 @@ describe('EmailPlugin', () => {
             expect(onSend.mock.calls[0][0].body).toContain('Date: Wed Jan 01 2020 10:00:00');
         });
 
-        it('formateMoney', async () => {
+        it('formatMoney', async () => {
             const handler = new EmailEventListener('test-helpers')
                 .on(MockEvent)
                 .setFrom('"test from" <noreply@test.com>')
@@ -448,6 +448,66 @@ describe('EmailPlugin', () => {
         });
     });
 
+    describe('attachments', () => {
+        const ctx = RequestContext.deserialize({
+            _channel: { code: DEFAULT_CHANNEL_CODE },
+            _languageCode: LanguageCode.en,
+        } as any);
+        const TEST_IMAGE_PATH = path.join(__dirname, '../test-fixtures/test.jpg');
+
+        it('attachments are empty by default', async () => {
+            const handler = new EmailEventListener('test')
+                .on(MockEvent)
+                .setFrom('"test from" <noreply@test.com>')
+                .setRecipient(() => 'test@test.com')
+                .setSubject('Hello {{ subjectVar }}');
+
+            await initPluginWithHandlers([handler]);
+
+            eventBus.publish(new MockEvent(ctx, true));
+            await pause();
+            expect(onSend.mock.calls[0][0].attachments).toEqual([]);
+        });
+
+        it('sync attachment', async () => {
+            const handler = new EmailEventListener('test')
+                .on(MockEvent)
+                .setFrom('"test from" <noreply@test.com>')
+                .setRecipient(() => 'test@test.com')
+                .setSubject('Hello {{ subjectVar }}')
+                .setAttachments(() => [
+                    {
+                        path: TEST_IMAGE_PATH,
+                    },
+                ]);
+
+            await initPluginWithHandlers([handler]);
+            eventBus.publish(new MockEvent(ctx, true));
+            await pause();
+
+            expect(onSend.mock.calls[0][0].attachments).toEqual([{ path: TEST_IMAGE_PATH }]);
+        });
+
+        it('async attachment', async () => {
+            const handler = new EmailEventListener('test')
+                .on(MockEvent)
+                .setFrom('"test from" <noreply@test.com>')
+                .setRecipient(() => 'test@test.com')
+                .setSubject('Hello {{ subjectVar }}')
+                .setAttachments(async () => [
+                    {
+                        path: TEST_IMAGE_PATH,
+                    },
+                ]);
+
+            await initPluginWithHandlers([handler]);
+            eventBus.publish(new MockEvent(ctx, true));
+            await pause();
+
+            expect(onSend.mock.calls[0][0].attachments).toEqual([{ path: TEST_IMAGE_PATH }]);
+        });
+    });
+
     describe('orderConfirmationHandler', () => {
         beforeEach(async () => {
             await initPluginWithHandlers([orderConfirmationHandler], {

+ 82 - 2
packages/email-plugin/src/types.ts

@@ -1,5 +1,8 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { Omit } from '@vendure/common/lib/omit';
+import { JsonCompatible } from '@vendure/common/lib/shared-types';
 import { Injector, RequestContext, VendureEvent, WorkerMessage } from '@vendure/core';
+import { Attachment } from 'nodemailer/lib/mailer';
 
 import { EmailEventHandler } from './event-handler';
 
@@ -235,11 +238,12 @@ export interface NoopTransportOptions {
  * @docsCategory EmailPlugin
  * @docsPage Email Plugin Types
  */
-export interface EmailDetails {
+export interface EmailDetails<Type extends 'serialized' | 'unserialized' = 'unserialized'> {
     from: string;
     recipient: string;
     subject: string;
     body: string;
+    attachments: Array<Type extends 'serialized' ? SerializedAttachment : Attachment>;
 }
 
 /**
@@ -279,7 +283,7 @@ export interface EmailGenerator<T extends string = any, E extends VendureEvent =
         subject: string,
         body: string,
         templateVars: { [key: string]: any },
-    ): Omit<EmailDetails, 'recipient'>;
+    ): Omit<EmailDetails, 'recipient' | 'attachments'>;
 }
 
 /**
@@ -293,6 +297,24 @@ export type LoadDataFn<Event extends EventWithContext, R> = (context: {
     injector: Injector;
 }) => Promise<R>;
 
+export type OptionalTuNullable<O> = {
+    [K in keyof O]-?: undefined extends O[K] ? NonNullable<O[K]> | null : O[K];
+};
+
+/**
+ * @description
+ * An object defining a file attachment for an email. Based on the object described
+ * [here in the Nodemailer docs](https://nodemailer.com/message/attachments/), but
+ * only uses the `path` property to define a filesystem path or a URL pointing to
+ * the attachment file.
+ *
+ * @docsCategory EmailPlugin
+ * @docsPage Email Plugin Types
+ */
+export type EmailAttachment = Omit<Attachment, 'content' | 'raw'> & { path: string };
+
+export type SerializedAttachment = OptionalTuNullable<EmailAttachment>;
+
 export type IntermediateEmailDetails = {
     type: string;
     from: string;
@@ -300,8 +322,66 @@ export type IntermediateEmailDetails = {
     templateVars: any;
     subject: string;
     templateFile: string;
+    attachments: SerializedAttachment[];
 };
 
 export class EmailWorkerMessage extends WorkerMessage<IntermediateEmailDetails, boolean> {
     static readonly pattern = 'send-email';
 }
+
+/**
+ * @description
+ * Configures the {@link EmailEventHandler} to handle a particular channel & languageCode
+ * combination.
+ *
+ * @docsCategory EmailPlugin
+ */
+export interface EmailTemplateConfig {
+    /**
+     * @description
+     * Specifies the channel to which this configuration will apply. If set to `'default'`, it will be applied to all
+     * channels.
+     */
+    channelCode: string | 'default';
+    /**
+     * @description
+     * Specifies the languageCode to which this configuration will apply. If set to `'default'`, it will be applied to all
+     * languages.
+     */
+    languageCode: LanguageCode | 'default';
+    /**
+     * @description
+     * Defines the file name of the Handlebars template file to be used to when generating this email.
+     */
+    templateFile: string;
+    /**
+     * @description
+     * A string defining the email subject line. Handlebars variables defined in the `templateVars` object may
+     * be used inside the subject.
+     */
+    subject: string;
+}
+
+/**
+ * @description
+ * A function used to define template variables available to email templates.
+ * See {@link EmailEventHandler}.setTemplateVars().
+ *
+ * @docsCategory EmailPlugin
+ * @docsPage Email Plugin Types
+ */
+export type SetTemplateVarsFn<Event> = (
+    event: Event,
+    globals: { [key: string]: any },
+) => { [key: string]: any };
+
+/**
+ * @description
+ * A function used to define attachments to be sent with the email.
+ * See https://nodemailer.com/message/attachments/ for more information about
+ * how attachments work in Nodemailer.
+ *
+ * @docsCategory EmailPlugin
+ * @docsPage Email Plugin Types
+ */
+export type SetAttachmentsFn<Event> = (event: Event) => EmailAttachment[] | Promise<EmailAttachment[]>;

BIN
packages/email-plugin/test-fixtures/test.jpg