Skip to content

Commit

Permalink
feat(email-plugin): Allow async data loading in EmailEventHandlers
Browse files Browse the repository at this point in the history
Closes #184
  • Loading branch information
michaelbromley committed Nov 25, 2019
1 parent c187828 commit 155d429
Show file tree
Hide file tree
Showing 7 changed files with 382 additions and 229 deletions.
2 changes: 2 additions & 0 deletions packages/email-plugin/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/email-plugin/src/dev-mailbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down
262 changes: 262 additions & 0 deletions packages/email-plugin/src/event-handler.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading

0 comments on commit 155d429

Please sign in to comment.