ソースを参照

feat(email-plugin): Allow async data loading in EmailEventHandlers

Closes #184
Michael Bromley 6 年 前
コミット
155d429353

+ 2 - 0
packages/email-plugin/index.ts

@@ -1,5 +1,7 @@
 export * from './src/default-email-handlers';
 export * from './src/email-sender';
+export * from './src/event-handler';
+export * from './src/event-listener';
 export * from './src/handlebars-mjml-generator';
 export * from './src/noop-email-generator';
 export * from './src/plugin';

+ 1 - 1
packages/email-plugin/src/dev-mailbox.ts

@@ -5,7 +5,7 @@ import fs from 'fs-extra';
 import http from 'http';
 import path from 'path';
 
-import { EmailEventHandler } from './event-listener';
+import { EmailEventHandler } from './event-handler';
 import { EmailPluginDevModeOptions, EventWithContext } from './types';
 
 /**

+ 262 - 0
packages/email-plugin/src/event-handler.ts

@@ -0,0 +1,262 @@
+import { LanguageCode } from '../../common/lib/generated-types';
+import { Omit } from '../../common/lib/omit';
+import { Type } from '../../common/lib/shared-types';
+
+import { EmailEventListener, EmailTemplateConfig, SetTemplateVarsFn } from './event-listener';
+import { EventWithAsyncData, EventWithContext, LoadDataFn } from './types';
+
+/**
+ * @description
+ * The EmailEventHandler defines how the EmailPlugin will respond to a given event.
+ *
+ * A handler is created by creating a new {@link EmailEventListener} and calling the `.on()` method
+ * to specify which event to respond to.
+ *
+ * @example
+ * ```ts
+ * const confirmationHandler = new EmailEventListener('order-confirmation')
+ *   .on(OrderStateTransitionEvent)
+ *   .filter(event => event.toState === 'PaymentSettled')
+ *   .setRecipient(event => event.order.customer.emailAddress)
+ *   .setSubject(`Order confirmation for #{{ order.code }}`)
+ *   .setTemplateVars(event => ({ order: event.order }));
+ * ```
+ *
+ * This example creates a handler which listens for the `OrderStateTransitionEvent` and if the Order has
+ * transitioned to the `'PaymentSettled'` state, it will generate and send an email.
+ *
+ * ## Handling other languages
+ *
+ * By default, the handler will respond to all events on all channels and use the same subject ("Order confirmation for #12345" above)
+ * and body template. Where the server is intended to support multiple languages, the `.addTemplate()` method may be used
+ * to defined the subject and body template for specific language and channel combinations.
+ *
+ * @example
+ * ```ts
+ * const extendedConfirmationHandler = confirmationHandler
+ *   .addTemplate({
+ *     channelCode: 'default',
+ *     languageCode: LanguageCode.de,
+ *     templateFile: 'body.de.hbs',
+ *     subject: 'Bestellbestätigung für #{{ order.code }}',
+ *   })
+ * ```
+ *
+ * @docsCategory EmailPlugin
+ */
+export class EmailEventHandler<T extends string = string, Event extends EventWithContext = EventWithContext> {
+    private setRecipientFn: (event: Event) => string;
+    private setTemplateVarsFn: SetTemplateVarsFn<Event>;
+    private filterFns: Array<(event: Event) => boolean> = [];
+    private configurations: EmailTemplateConfig[] = [];
+    private defaultSubject: string;
+    private from: string;
+    private _mockEvent: Omit<Event, 'ctx'> | undefined;
+
+    constructor(public listener: EmailEventListener<T>, public event: Type<Event>) {}
+
+    /** @internal */
+    get type(): T {
+        return this.listener.type;
+    }
+
+    /** @internal */
+    get mockEvent(): Omit<Event, 'ctx'> | undefined {
+        return this._mockEvent;
+    }
+
+    /**
+     * @description
+     * Defines a predicate function which is used to determine whether the event will trigger an email.
+     * Multiple filter functions may be defined.
+     */
+    filter(filterFn: (event: Event) => boolean): EmailEventHandler<T, Event> {
+        this.filterFns.push(filterFn);
+        return this;
+    }
+
+    /**
+     * @description
+     * A function which defines how the recipient email address should be extracted from the incoming event.
+     */
+    setRecipient(setRecipientFn: (event: Event) => string): EmailEventHandler<T, Event> {
+        this.setRecipientFn = setRecipientFn;
+        return this;
+    }
+
+    /**
+     * @description
+     * A function which returns an object hash of variables which will be made available to the Handlebars template
+     * and subject line for interpolation.
+     */
+    setTemplateVars(templateVarsFn: SetTemplateVarsFn<Event>): EmailEventHandler<T, Event> {
+        this.setTemplateVarsFn = templateVarsFn;
+        return this;
+    }
+
+    /**
+     * @description
+     * Sets the default subject of the email. The subject string may use Handlebars variables defined by the
+     * setTemplateVars() method.
+     */
+    setSubject(defaultSubject: string): EmailEventHandler<T, Event> {
+        this.defaultSubject = defaultSubject;
+        return this;
+    }
+
+    /**
+     * @description
+     * Sets the default from field of the email. The from string may use Handlebars variables defined by the
+     * setTemplateVars() method.
+     */
+    setFrom(from: string): EmailEventHandler<T, Event> {
+        this.from = from;
+        return this;
+    }
+
+    /**
+     * @description
+     * Add configuration for another template other than the default `"body.hbs"`. Use this method to define specific
+     * templates for channels or languageCodes other than the default.
+     */
+    addTemplate(config: EmailTemplateConfig): EmailEventHandler<T, Event> {
+        this.configurations.push(config);
+        return this;
+    }
+
+    /**
+     * @description
+     * Allows data to be loaded asynchronously which can then be used as template variables.
+     * The `loadDataFn` has access to the event, the TypeORM `Connection` object, and an
+     * `inject()` function which can be used to inject any of the providers exported
+     * by the {@link PluginCommonModule}. The return value of the `loadDataFn` will be
+     * added to the `event` as the `data` property.
+     *
+     * @example
+     * ```TypeScript
+     * new EmailEventListener('order-confirmation')
+     *   .on(OrderStateTransitionEvent)
+     *   .filter(event => event.toState === 'PaymentSettled' && !!event.order.customer)
+     *   .loadData(({ event, inject}) => {
+     *     const orderService = inject(OrderService);
+     *     return orderService.getOrderPayments(event.order.id);
+     *   })
+     *   .setTemplateVars(event => ({
+     *     order: event.order,
+     *     payments: event.data,
+     *   }));
+     * ```
+     */
+    loadData<R>(
+        loadDataFn: LoadDataFn<Event, R>,
+    ): EmailEventHandlerWithAsyncData<R, T, Event, EventWithAsyncData<Event, R>> {
+        return new EmailEventHandlerWithAsyncData(loadDataFn, this.listener, this.event);
+    }
+
+    /**
+     * @description
+     * Used internally by the EmailPlugin to handle incoming events.
+     *
+     * @internal
+     */
+    async handle(
+        event: Event,
+        globals: { [key: string]: any } = {},
+    ): Promise<
+        | { from: string; recipient: string; templateVars: any; subject: string; templateFile: string }
+        | undefined
+    > {
+        for (const filterFn of this.filterFns) {
+            if (!filterFn(event)) {
+                return;
+            }
+        }
+        if (!this.setRecipientFn) {
+            throw new Error(
+                `No setRecipientFn has been defined. ` +
+                    `Remember to call ".setRecipient()" when setting up the EmailEventHandler for ${this.type}`,
+            );
+        }
+        if (this.from === undefined) {
+            throw new Error(
+                `No from field has been defined. ` +
+                    `Remember to call ".setFrom()" when setting up the EmailEventHandler for ${this.type}`,
+            );
+        }
+        const { ctx } = event;
+        const configuration = this.getBestConfiguration(ctx.channel.code, ctx.languageCode);
+        const subject = configuration ? configuration.subject : this.defaultSubject;
+        if (subject == null) {
+            throw new Error(
+                `No subject field has been defined. ` +
+                    `Remember to call ".setSubject()" when setting up the EmailEventHandler for ${this.type}`,
+            );
+        }
+        const recipient = this.setRecipientFn(event);
+        const templateVars = this.setTemplateVarsFn ? this.setTemplateVarsFn(event, globals) : {};
+        return {
+            recipient,
+            from: this.from,
+            templateVars: { ...globals, ...templateVars },
+            subject,
+            templateFile: configuration ? configuration.templateFile : 'body.hbs',
+        };
+    }
+
+    /**
+     * @description
+     * Optionally define a mock Event which is used by the dev mode mailbox app for generating mock emails
+     * from this handler, which is useful when developing the email templates.
+     */
+    setMockEvent(event: Omit<Event, 'ctx'>): EmailEventHandler<T, Event> {
+        this._mockEvent = event;
+        return this;
+    }
+
+    private getBestConfiguration(
+        channelCode: string,
+        languageCode: LanguageCode,
+    ): EmailTemplateConfig | undefined {
+        if (this.configurations.length === 0) {
+            return;
+        }
+        const exactMatch = this.configurations.find(c => {
+            return (
+                (c.channelCode === channelCode || c.channelCode === 'default') &&
+                c.languageCode === languageCode
+            );
+        });
+        if (exactMatch) {
+            return exactMatch;
+        }
+        const channelMatch = this.configurations.find(
+            c => c.channelCode === channelCode && c.languageCode === 'default',
+        );
+        if (channelMatch) {
+            return channelMatch;
+        }
+        return;
+    }
+}
+
+/**
+ * @description
+ * Identical to the {@link EmailEventHandler} but with a `data` property added to the `event` based on the result
+ * of the `.loadData()` function.
+ *
+ * @docsCategory EmailPlugin
+ */
+export class EmailEventHandlerWithAsyncData<
+    Data,
+    T extends string = string,
+    InputEvent extends EventWithContext = EventWithContext,
+    Event extends EventWithAsyncData<InputEvent, Data> = EventWithAsyncData<InputEvent, Data>
+> extends EmailEventHandler<T, Event> {
+    constructor(
+        public _loadDataFn: LoadDataFn<InputEvent, Data>,
+        listener: EmailEventListener<T>,
+        event: Type<InputEvent>,
+    ) {
+        super(listener, event as any);
+    }
+}

+ 2 - 199
packages/email-plugin/src/event-listener.ts

@@ -1,8 +1,8 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
-import { Omit } from '@vendure/common/lib/omit';
 import { Type } from '@vendure/common/lib/shared-types';
 
-import { EmailDetails, EventWithContext } from './types';
+import { EmailEventHandler } from './event-handler';
+import { EventWithContext } from './types';
 
 /**
  * @description
@@ -71,200 +71,3 @@ export class EmailEventListener<T extends string> {
         return new EmailEventHandler<T, Event>(this, event);
     }
 }
-
-/**
- * @description
- * The EmailEventHandler defines how the EmailPlugin will respond to a given event.
- *
- * A handler is created by creating a new {@link EmailEventListener} and calling the `.on()` method
- * to specify which event to respond to.
- *
- * @example
- * ```ts
- * const confirmationHandler = new EmailEventListener('order-confirmation')
- *   .on(OrderStateTransitionEvent)
- *   .filter(event => event.toState === 'PaymentSettled')
- *   .setRecipient(event => event.order.customer.emailAddress)
- *   .setSubject(`Order confirmation for #{{ order.code }}`)
- *   .setTemplateVars(event => ({ order: event.order }));
- * ```
- *
- * This example creates a handler which listens for the `OrderStateTransitionEvent` and if the Order has
- * transitioned to the `'PaymentSettled'` state, it will generate and send an email.
- *
- * ## Handling other languages
- *
- * By default, the handler will respond to all events on all channels and use the same subject ("Order confirmation for #12345" above)
- * and body template. Where the server is intended to support multiple languages, the `.addTemplate()` method may be used
- * to defined the subject and body template for specific language and channel combinations.
- *
- * @example
- * ```ts
- * const extendedConfirmationHandler = confirmationHandler
- *   .addTemplate({
- *     channelCode: 'default',
- *     languageCode: LanguageCode.de,
- *     templateFile: 'body.de.hbs',
- *     subject: 'Bestellbestätigung für #{{ order.code }}',
- *   })
- * ```
- *
- * @docsCategory EmailPlugin
- */
-export class EmailEventHandler<T extends string = string, Event extends EventWithContext = EventWithContext> {
-    private setRecipientFn: (event: Event) => string;
-    private setTemplateVarsFn: SetTemplateVarsFn<Event>;
-    private filterFns: Array<(event: Event) => boolean> = [];
-    private configurations: EmailTemplateConfig[] = [];
-    private defaultSubject: string;
-    private from: string;
-    private _mockEvent: Omit<Event, 'ctx'> | undefined;
-
-    constructor(public listener: EmailEventListener<T>, public event: Type<Event>) {}
-
-    /** @internal */
-    get type(): T {
-        return this.listener.type;
-    }
-
-    /** @internal */
-    get mockEvent(): Omit<Event, 'ctx'> | undefined {
-        return this._mockEvent;
-    }
-
-    /**
-     * @description
-     * Defines a predicate function which is used to determine whether the event will trigger an email.
-     * Multiple filter functions may be defined.
-     */
-    filter(filterFn: (event: Event) => boolean): EmailEventHandler<T, Event> {
-        this.filterFns.push(filterFn);
-        return this;
-    }
-
-    /**
-     * @description
-     * A function which defines how the recipient email address should be extracted from the incoming event.
-     */
-    setRecipient(setRecipientFn: (event: Event) => string): EmailEventHandler<T, Event> {
-        this.setRecipientFn = setRecipientFn;
-        return this;
-    }
-
-    /**
-     * @description
-     * A function which returns an object hash of variables which will be made available to the Handlebars template
-     * and subject line for interpolation.
-     */
-    setTemplateVars(templateVarsFn: SetTemplateVarsFn<Event>): EmailEventHandler<T, Event> {
-        this.setTemplateVarsFn = templateVarsFn;
-        return this;
-    }
-
-    /**
-     * @description
-     * Sets the default subject of the email. The subject string may use Handlebars variables defined by the
-     * setTemplateVars() method.
-     */
-    setSubject(defaultSubject: string): EmailEventHandler<T, Event> {
-        this.defaultSubject = defaultSubject;
-        return this;
-    }
-
-    /**
-     * @description
-     * Sets the default from field of the email. The from string may use Handlebars variables defined by the
-     * setTemplateVars() method.
-     */
-    setFrom(from: string): EmailEventHandler<T, Event> {
-        this.from = from;
-        return this;
-    }
-
-    /**
-     * @description
-     * Add configuration for another template other than the default `"body.hbs"`. Use this method to define specific
-     * templates for channels or languageCodes other than the default.
-     */
-    addTemplate(config: EmailTemplateConfig): EmailEventHandler<T, Event> {
-        this.configurations.push(config);
-        return this;
-    }
-
-    /**
-     * @description
-     * Used internally by the EmailPlugin to handle incoming events.
-     *
-     * @internal
-     */
-    handle(
-        event: Event,
-        globals: { [key: string]: any } = {},
-    ):
-        | { from: string; recipient: string; templateVars: any; subject: string; templateFile: string }
-        | undefined {
-        for (const filterFn of this.filterFns) {
-            if (!filterFn(event)) {
-                return;
-            }
-        }
-        if (!this.setRecipientFn) {
-            throw new Error(
-                `No setRecipientFn has been defined. ` +
-                    `Remember to call ".setRecipient()" when setting up the EmailEventHandler for ${this.type}`,
-            );
-        }
-        if (this.from === undefined) {
-            throw new Error(
-                `No from field has been defined. ` +
-                    `Remember to call ".setFrom()" when setting up the EmailEventHandler for ${this.type}`,
-            );
-        }
-        const { ctx } = event;
-        const configuration = this.getBestConfiguration(ctx.channel.code, ctx.languageCode);
-        const recipient = this.setRecipientFn(event);
-        const templateVars = this.setTemplateVarsFn ? this.setTemplateVarsFn(event, globals) : {};
-        return {
-            recipient,
-            from: this.from,
-            templateVars: { ...globals, ...templateVars },
-            subject: configuration ? configuration.subject : this.defaultSubject,
-            templateFile: configuration ? configuration.templateFile : 'body.hbs',
-        };
-    }
-
-    /**
-     * @description
-     * Optionally define a mock Event which is used by the dev mode mailbox app for generating mock emails
-     * from this handler, which is useful when developing the email templates.
-     */
-    setMockEvent(event: Omit<Event, 'ctx'>): EmailEventHandler<T, Event> {
-        this._mockEvent = event;
-        return this;
-    }
-
-    private getBestConfiguration(
-        channelCode: string,
-        languageCode: LanguageCode,
-    ): EmailTemplateConfig | undefined {
-        if (this.configurations.length === 0) {
-            return;
-        }
-        const exactMatch = this.configurations.find(c => {
-            return (
-                (c.channelCode === channelCode || c.channelCode === 'default') &&
-                c.languageCode === languageCode
-            );
-        });
-        if (exactMatch) {
-            return exactMatch;
-        }
-        const channelMatch = this.configurations.find(
-            c => c.channelCode === channelCode && c.languageCode === 'default',
-        );
-        if (channelMatch) {
-            return channelMatch;
-        }
-        return;
-    }
-}

+ 51 - 8
packages/email-plugin/src/plugin.spec.ts

@@ -1,16 +1,20 @@
 /* tslint:disable:no-non-null-assertion */
 import { Test, TestingModule } from '@nestjs/testing';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
+import {
+    EventBus,
+    LanguageCode,
+    Order,
+    OrderStateTransitionEvent,
+    PluginCommonModule,
+    VendureEvent,
+} from '@vendure/core';
 import path from 'path';
 
-import { LanguageCode } from '../../common/lib/generated-types';
-import { DEFAULT_CHANNEL_CODE } from '../../common/lib/shared-constants';
-import { Order } from '../../core/dist/entity/order/order.entity';
-import { EventBus } from '../../core/dist/event-bus/event-bus';
-import { OrderStateTransitionEvent } from '../../core/dist/event-bus/events/order-state-transition-event';
-import { VendureEvent } from '../../core/dist/event-bus/vendure-event';
-
 import { orderConfirmationHandler } from './default-email-handlers';
-import { EmailEventHandler, EmailEventListener } from './event-listener';
+import { EmailEventHandler } from './event-handler';
+import { EmailEventListener } from './event-listener';
 import { EmailPlugin } from './plugin';
 import { EmailPluginOptions } from './types';
 
@@ -26,6 +30,10 @@ describe('EmailPlugin', () => {
         onSend = jest.fn();
         const module = await Test.createTestingModule({
             imports: [
+                TypeOrmModule.forRoot({
+                    type: 'sqljs',
+                }),
+                PluginCommonModule,
                 EmailPlugin.init({
                     templatePath: path.join(__dirname, '../test-templates'),
                     transport: {
@@ -36,6 +44,7 @@ describe('EmailPlugin', () => {
                     ...options,
                 }),
             ],
+            providers: [MockService],
         }).compile();
 
         plugin = module.get(EmailPlugin);
@@ -234,6 +243,34 @@ describe('EmailPlugin', () => {
         });
     });
 
+    describe('loadData', () => {
+        it('loads async data', async () => {
+            const handler = new EmailEventListener('test')
+                .on(MockEvent)
+                .loadData(async ({ inject }) => {
+                    const service = inject(MockService);
+                    return service.someAsyncMethod();
+                })
+                .setFrom('"test from" <noreply@test.com>')
+                .setSubject('Hello, {{ testData }}!')
+                .setRecipient(() => 'test@test.com')
+                .setTemplateVars(event => ({ testData: event.data }));
+
+            const module = await initPluginWithHandlers([handler]);
+
+            eventBus.publish(
+                new MockEvent(
+                    { channel: { code: DEFAULT_CHANNEL_CODE }, languageCode: LanguageCode.en },
+                    true,
+                ),
+            );
+            await pause();
+
+            expect(onSend.mock.calls[0][0].subject).toBe('Hello, loaded data!');
+            await module.close();
+        });
+    });
+
     describe('orderConfirmationHandler', () => {
         let module: TestingModule;
         beforeEach(async () => {
@@ -299,3 +336,9 @@ class MockEvent extends VendureEvent {
         super();
     }
 }
+
+class MockService {
+    someAsyncMethod() {
+        return Promise.resolve('loaded data');
+    }
+}

+ 39 - 17
packages/email-plugin/src/plugin.ts

@@ -1,26 +1,30 @@
+import { ModuleRef } from '@nestjs/core';
+import { InjectConnection } from '@nestjs/typeorm';
 import {
     createProxyHandler,
     EventBus,
-    EventBusModule,
     InternalServerError,
     Logger,
     OnVendureBootstrap,
     OnVendureClose,
+    PluginCommonModule,
     RuntimeVendureConfig,
     Type,
     VendurePlugin,
 } from '@vendure/core';
 import fs from 'fs-extra';
+import { Connection } from 'typeorm';
 
 import { DevMailbox } from './dev-mailbox';
 import { EmailSender } from './email-sender';
-import { EmailEventHandler } from './event-listener';
+import { EmailEventHandler, EmailEventHandlerWithAsyncData } from './event-handler';
 import { HandlebarsMjmlGenerator } from './handlebars-mjml-generator';
 import { TemplateLoader } from './template-loader';
 import {
     EmailPluginDevModeOptions,
     EmailPluginOptions,
     EmailTransportOptions,
+    EventWithAsyncData,
     EventWithContext,
 } from './types';
 
@@ -133,7 +137,7 @@ import {
  * @docsCategory EmailPlugin
  */
 @VendurePlugin({
-    imports: [EventBusModule],
+    imports: [PluginCommonModule],
     configuration: config => EmailPlugin.configure(config),
 })
 export class EmailPlugin implements OnVendureBootstrap, OnVendureClose {
@@ -145,7 +149,11 @@ export class EmailPlugin implements OnVendureBootstrap, OnVendureClose {
     private devMailbox: DevMailbox | undefined;
 
     /** @internal */
-    constructor(private eventBus: EventBus) {}
+    constructor(
+        private eventBus: EventBus,
+        @InjectConnection() private connection: Connection,
+        private moduleRef: ModuleRef,
+    ) {}
 
     /**
      * Set the plugin options.
@@ -222,22 +230,36 @@ export class EmailPlugin implements OnVendureBootstrap, OnVendureClose {
         }
     }
 
-    private async handleEvent(handler: EmailEventHandler, event: EventWithContext) {
+    private async handleEvent(
+        handler: EmailEventHandler | EmailEventHandlerWithAsyncData<any>,
+        event: EventWithContext,
+    ) {
         Logger.debug(`Handling event "${handler.type}"`, 'EmailPlugin');
         const { type } = handler;
-        const result = handler.handle(event, EmailPlugin.options.globalTemplateVars);
-        if (!result) {
-            return;
+        try {
+            if (handler instanceof EmailEventHandlerWithAsyncData) {
+                (event as EventWithAsyncData<EventWithContext, any>).data = await handler._loadDataFn({
+                    event,
+                    connection: this.connection,
+                    inject: t => this.moduleRef.get(t, { strict: false }),
+                });
+            }
+            const result = await handler.handle(event as any, EmailPlugin.options.globalTemplateVars);
+            if (!result) {
+                return;
+            }
+            const bodySource = await this.templateLoader.loadTemplate(type, result.templateFile);
+            const generated = await this.generator.generate(
+                result.from,
+                result.subject,
+                bodySource,
+                result.templateVars,
+            );
+            const emailDetails = { ...generated, recipient: result.recipient };
+            await this.emailSender.send(emailDetails, this.transport);
+        } catch (e) {
+            Logger.error(e.message, 'EmailPlugin', e.stack);
         }
-        const bodySource = await this.templateLoader.loadTemplate(type, result.templateFile);
-        const generated = await this.generator.generate(
-            result.from,
-            result.subject,
-            bodySource,
-            result.templateVars,
-        );
-        const emailDetails = { ...generated, recipient: result.recipient };
-        await this.emailSender.send(emailDetails, this.transport);
     }
 }
 

+ 25 - 4
packages/email-plugin/src/types.ts

@@ -1,9 +1,8 @@
-import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { Omit } from '@vendure/common/lib/omit';
-import { Type } from '@vendure/common/lib/shared-types';
-import { RequestContext, VendureEvent } from '@vendure/core';
+import { RequestContext, Type, VendureEvent } from '@vendure/core';
+import { Connection } from 'typeorm';
 
-import { EmailEventHandler } from './event-listener';
+import { EmailEventHandler } from './event-handler';
 
 /**
  * @description
@@ -16,6 +15,16 @@ import { EmailEventHandler } from './event-listener';
  */
 export type EventWithContext = VendureEvent & { ctx: RequestContext };
 
+/**
+ * @description
+ * A VendureEvent with a {@link RequestContext} and a `data` property which contains the
+ * value resolved from the {@link EmailEventHandler}`.loadData()` callback.
+ *
+ * @docsCategory EmailPlugin
+ * @docsPage Email Plugin Types
+ */
+export type EventWithAsyncData<Event extends EventWithContext, R> = Event & { data: R };
+
 /**
  * @description
  * Configuration for the EmailPlugin.
@@ -251,3 +260,15 @@ export interface EmailGenerator<T extends string = any, E extends VendureEvent =
         templateVars: { [key: string]: any },
     ): Omit<EmailDetails, 'recipient'>;
 }
+
+/**
+ * @description
+ * A function used to load async data for use by an {@link EmailEventHandler}.
+ *
+ * @docsCategory EmailPlugin
+ */
+export type LoadDataFn<Event extends EventWithContext, R> = (context: {
+    event: Event;
+    connection: Connection;
+    inject: <T>(type: Type<T>) => T;
+}) => Promise<R>;