From a1d68bd9518e823518a59efcee7f3657209f745e Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 17 Apr 2024 15:17:57 -0400 Subject: [PATCH] feat: abstract nodemailer into email adapter interface --- .../src/auth/operations/forgotPassword.ts | 6 +- .../payload/src/auth/sendVerificationEmail.ts | 13 +- .../src/collections/operations/create.ts | 5 +- packages/payload/src/config/types.ts | 50 +------ .../src/email/adapters/nodemailer/index.ts | 129 ++++++++++++++++++ packages/payload/src/email/build.ts | 77 ----------- packages/payload/src/email/defaults.ts | 11 +- packages/payload/src/email/mockHandler.ts | 30 ---- packages/payload/src/email/sendEmail.ts | 33 ++++- packages/payload/src/email/types.ts | 27 ++-- packages/payload/src/index.ts | 28 ++-- test/email/config.ts | 5 - 12 files changed, 196 insertions(+), 218 deletions(-) create mode 100644 packages/payload/src/email/adapters/nodemailer/index.ts delete mode 100644 packages/payload/src/email/build.ts delete mode 100644 packages/payload/src/email/mockHandler.ts diff --git a/packages/payload/src/auth/operations/forgotPassword.ts b/packages/payload/src/auth/operations/forgotPassword.ts index 1f6150113..56f938066 100644 --- a/packages/payload/src/auth/operations/forgotPassword.ts +++ b/packages/payload/src/auth/operations/forgotPassword.ts @@ -57,7 +57,7 @@ export const forgotPasswordOperation = async (incomingArgs: Arguments): Promise< disableEmail, expiration, req: { - payload: { config, emailOptions, sendEmail: email }, + payload: { config, email }, payload, }, req, @@ -132,8 +132,8 @@ export const forgotPasswordOperation = async (incomingArgs: Arguments): Promise< } // eslint-disable-next-line @typescript-eslint/no-floating-promises - email({ - from: `"${emailOptions.fromName}" <${emailOptions.fromAddress}>`, + email.sendEmail({ + from: `"${email.defaultFromName}" <${email.defaultFromAddress}>`, html, subject, to: data.email, diff --git a/packages/payload/src/auth/sendVerificationEmail.ts b/packages/payload/src/auth/sendVerificationEmail.ts index 0921df941..a76873c7a 100644 --- a/packages/payload/src/auth/sendVerificationEmail.ts +++ b/packages/payload/src/auth/sendVerificationEmail.ts @@ -1,7 +1,8 @@ import { URL } from 'url' import type { Collection } from '../collections/config/types.js' -import type { EmailOptions, SanitizedConfig } from '../config/types.js' +import type { SanitizedConfig } from '../config/types.js' +import type { EmailAdapter } from '../email/types.js' import type { Payload } from '../index.js' import type { PayloadRequest } from '../types/index.js' import type { User, VerifyConfig } from './types.js' @@ -10,9 +11,8 @@ type Args = { collection: Collection config: SanitizedConfig disableEmail: boolean - emailOptions: EmailOptions + email: EmailAdapter req: PayloadRequest - sendEmail: Payload['sendEmail'] token: string user: User } @@ -23,9 +23,8 @@ async function sendVerificationEmail(args: Args): Promise { collection: { config: collectionConfig }, config, disableEmail, - emailOptions, + email, req, - sendEmail, token, user, } = args @@ -67,8 +66,8 @@ async function sendVerificationEmail(args: Args): Promise { } // eslint-disable-next-line @typescript-eslint/no-floating-promises - sendEmail({ - from: `"${emailOptions.fromName}" <${emailOptions.fromAddress}>`, + email.sendEmail({ + from: `"${email.defaultFromName}" <${email.defaultFromName}>`, html, subject, to: user.email, diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index fd67ed628..636c0b0f3 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -84,7 +84,7 @@ export const createOperation = async = { [K in keyof T]: T[K] } & NonNullable -type Email = { - fromAddress: string - fromName: string - logMockCredentials?: boolean -} - // eslint-disable-next-line no-use-before-define export type Plugin = (config: Config) => Config | Promise @@ -85,36 +78,6 @@ export type GeneratePreviewURL = ( options: GeneratePreviewURLOptions, ) => Promise | null | string -export type EmailTransport = Email & { - transport: Transporter - transportOptions?: SMTPConnection.Options -} - -export type EmailTransportOptions = Email & { - transport?: Transporter - transportOptions: SMTPConnection.Options -} - -export type EmailOptions = Email | EmailTransport | EmailTransportOptions - -/** - * type guard for EmailOptions - * @param emailConfig - */ -export function hasTransport(emailConfig: EmailOptions): emailConfig is EmailTransport { - return (emailConfig as EmailTransport).transport !== undefined -} - -/** - * type guard for EmailOptions - * @param emailConfig - */ -export function hasTransportOptions( - emailConfig: EmailOptions, -): emailConfig is EmailTransportOptions { - return (emailConfig as EmailTransportOptions).transportOptions !== undefined -} - export type GraphQLInfo = { Mutation: { fields: Record @@ -162,13 +125,6 @@ export type InitOptions = { */ disableOnInit?: boolean - /** - * Configuration for Payload's email functionality - * - * @see https://payloadcms.com/docs/email/overview - */ - email?: EmailOptions - /** * A previously instantiated logger instance. Must conform to the PayloadLogger interface which uses Pino * This allows you to bring your own logger instance and let payload use it @@ -571,11 +527,11 @@ export type Config = { /** Default richtext editor to use for richText fields */ editor: RichTextAdapter /** - * Email configuration options. This value is overridden by `email` in Payload.init if passed. + * Email Adapter * * @see https://payloadcms.com/docs/email/overview */ - email?: EmailOptions + email?: EmailAdapter /** Custom REST endpoints */ endpoints?: Endpoint[] /** diff --git a/packages/payload/src/email/adapters/nodemailer/index.ts b/packages/payload/src/email/adapters/nodemailer/index.ts new file mode 100644 index 000000000..db16efbd6 --- /dev/null +++ b/packages/payload/src/email/adapters/nodemailer/index.ts @@ -0,0 +1,129 @@ +/* eslint-disable no-console */ +import type { SendMailOptions, Transporter } from 'nodemailer' +import type SMTPConnection from 'nodemailer/lib/smtp-connection' + +import nodemailer from 'nodemailer' + +import type { EmailAdapter } from '../../types.js' + +import InvalidConfiguration from '../../../errors/InvalidConfiguration.js' +import { emailDefaults } from '../../defaults.js' + +type Email = { + defaultFromAddress: string + defaultFromName: string + logMockCredentials?: boolean +} + +type EmailTransportOptions = Email & { + transport?: Transporter + transportOptions: SMTPConnection.Options +} + +export type NodemailerAdapterArgs = Email | EmailTransportOptions +export type NodemailerAdapter = EmailAdapter + +/** + * Creates an email adapter using nodemailer + * + * If no email configuration is provided, an ethereal email test account is returned + */ +export const createNodemailerAdapter = async ( + args: NodemailerAdapterArgs, +): Promise => { + const { defaultFromAddress, defaultFromName, transport } = await buildEmail(args) + + const adapter: NodemailerAdapter = { + defaultFromAddress, + defaultFromName, + sendEmail: async (message) => { + return await transport.sendMail({ + from: `${defaultFromName} <${defaultFromAddress}>`, + ...message, + }) + }, + } + + return adapter +} + +async function buildEmail( + emailConfig?: NodemailerAdapterArgs, +): Promise<{ defaultFromAddress: string; defaultFromName: string; transport: Transporter }> { + if (!emailConfig) { + return { + defaultFromAddress: emailDefaults.defaultFromAddress, + defaultFromName: emailDefaults.defaultFromName, + transport: await createMockAccount(emailConfig), + } + } + + ensureConfigHasFrom(emailConfig) + + // Create or extract transport + let transport: Transporter + if ('transport' in emailConfig && emailConfig.transport) { + ;({ transport } = emailConfig) + } else if ('transportOptions' in emailConfig && emailConfig.transportOptions) { + transport = nodemailer.createTransport(emailConfig.transportOptions) + } else { + transport = await createMockAccount(emailConfig) + } + + await verifyTransport(transport) + return { + defaultFromAddress: emailConfig.defaultFromAddress, + defaultFromName: emailConfig.defaultFromName, + transport, + } +} + +async function verifyTransport(transport: Transporter) { + try { + await transport.verify() + } catch (err: unknown) { + console.error({ err, msg: 'Error verifying Nodemailer transport.' }) + } +} + +const ensureConfigHasFrom = (emailConfig: NodemailerAdapterArgs) => { + if (!emailConfig?.defaultFromName || !emailConfig?.defaultFromAddress) { + throw new InvalidConfiguration( + 'Email fromName and fromAddress must be configured when transport is configured', + ) + } +} + +/** + * Use ethereal.email to create a mock email account + */ +async function createMockAccount(emailConfig: NodemailerAdapterArgs) { + try { + const etherealAccount = await nodemailer.createTestAccount() + + const smtpOptions = { + ...emailConfig, + auth: { + pass: etherealAccount.pass, + user: etherealAccount.user, + }, + fromAddress: emailConfig?.defaultFromAddress, + fromName: emailConfig?.defaultFromName, + host: 'smtp.ethereal.email', + port: 587, + secure: false, + } + const transport = nodemailer.createTransport(smtpOptions) + const { pass, user, web } = etherealAccount + + if (emailConfig?.logMockCredentials) { + console.info('E-mail configured with mock configuration') + console.info(`Log into mock email provider at ${web}`) + console.info(`Mock email account username: ${user}`) + console.info(`Mock email account password: ${pass}`) + } + return transport + } catch (err) { + console.error({ err, msg: 'There was a problem setting up the mock email handler' }) + } +} diff --git a/packages/payload/src/email/build.ts b/packages/payload/src/email/build.ts deleted file mode 100644 index 03add50af..000000000 --- a/packages/payload/src/email/build.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Transporter } from 'nodemailer' -import type { Logger } from 'pino' - -import nodemailer from 'nodemailer' - -import type { EmailOptions, EmailTransport } from '../config/types.js' -import type { BuildEmailResult, MockEmailHandler } from './types.js' - -import { hasTransport, hasTransportOptions } from '../config/types.js' -import { InvalidConfiguration } from '../errors/index.js' -import mockHandler from './mockHandler.js' - -async function handleTransport( - transport: Transporter, - email: EmailTransport, - logger: Logger, -): BuildEmailResult { - try { - await transport.verify() - } catch (err) { - logger.error(`There is an error with the email configuration you have provided. ${err}`) - } - - return { ...email, transport } -} - -const ensureConfigHasFrom = (emailConfig: EmailOptions) => { - if (!emailConfig?.fromName || !emailConfig?.fromAddress) { - throw new InvalidConfiguration( - 'Email fromName and fromAddress must be configured when transport is configured', - ) - } -} - -const handleMockAccount = async (emailConfig: EmailOptions, logger: Logger) => { - let mockAccount: MockEmailHandler - try { - mockAccount = await mockHandler(emailConfig) - const { - account: { pass, user, web }, - } = mockAccount - if (emailConfig?.logMockCredentials) { - logger.info('E-mail configured with mock configuration') - logger.info(`Log into mock email provider at ${web}`) - logger.info(`Mock email account username: ${user}`) - logger.info(`Mock email account password: ${pass}`) - } - } catch (err) { - logger.error({ err, msg: 'There was a problem setting up the mock email handler' }) - } - return mockAccount -} - -export default async function buildEmail( - emailConfig: EmailOptions | undefined, - logger: Logger, -): BuildEmailResult { - if (!emailConfig) { - return handleMockAccount(emailConfig, logger) - } - - if (hasTransport(emailConfig) && emailConfig.transport) { - ensureConfigHasFrom(emailConfig) - const email = { ...emailConfig } - const { transport }: { transport: Transporter } = emailConfig - return handleTransport(transport, email, logger) - } - - if (hasTransportOptions(emailConfig) && emailConfig.transportOptions) { - ensureConfigHasFrom(emailConfig) - const email = { ...emailConfig } as EmailTransport - const transport = nodemailer.createTransport(emailConfig.transportOptions) - return handleTransport(transport, email, logger) - } - - return handleMockAccount(emailConfig, logger) -} diff --git a/packages/payload/src/email/defaults.ts b/packages/payload/src/email/defaults.ts index d084de4b2..2bb905ea5 100644 --- a/packages/payload/src/email/defaults.ts +++ b/packages/payload/src/email/defaults.ts @@ -1,6 +1,9 @@ -import type { EmailOptions } from '../config/types.js' +import type { EmailAdapter } from './types.js' -export const defaults: EmailOptions = { - fromAddress: 'info@payloadcms.com', - fromName: 'Payload', +export const emailDefaults: Pick< + EmailAdapter, + 'defaultFromAddress' | 'defaultFromName' +> = { + defaultFromAddress: 'info@payloadcms.com', + defaultFromName: 'Payload', } diff --git a/packages/payload/src/email/mockHandler.ts b/packages/payload/src/email/mockHandler.ts deleted file mode 100644 index 4424b0d4d..000000000 --- a/packages/payload/src/email/mockHandler.ts +++ /dev/null @@ -1,30 +0,0 @@ -import nodemailer from 'nodemailer' - -import type { EmailOptions } from '../config/types.js' -import type { MockEmailHandler } from './types.js' - -import { defaults as emailDefaults } from './defaults.js' - -const mockEmailHandler = async (emailConfig: EmailOptions): Promise => { - const testAccount = await nodemailer.createTestAccount() - - const smtpOptions = { - ...emailConfig, - auth: { - pass: testAccount.pass, - user: testAccount.user, - }, - fromAddress: emailConfig?.fromAddress || emailDefaults.fromAddress, - fromName: emailConfig?.fromName || emailDefaults.fromName, - host: 'smtp.ethereal.email', - port: 587, - secure: false, - } - - return { - account: testAccount, - transport: nodemailer.createTransport(smtpOptions), - } -} - -export default mockEmailHandler diff --git a/packages/payload/src/email/sendEmail.ts b/packages/payload/src/email/sendEmail.ts index 008b182bf..a5022ea6e 100644 --- a/packages/payload/src/email/sendEmail.ts +++ b/packages/payload/src/email/sendEmail.ts @@ -1,13 +1,36 @@ import type { SendMailOptions } from 'nodemailer' -export default async function sendEmail(message: SendMailOptions): Promise { +import type { Payload } from '../types/index.js' + +export async function sendEmail(this: Payload, message: SendMailOptions): Promise { let result try { - const email = await this.email - result = await email.transport.sendMail(message) - } catch (err) { - this.logger.error(err, `Failed to send mail to ${message.to}, subject: ${message.subject}`) + result = await this.email.sendEmail(message) + } catch (err: unknown) { + let stringifiedTo: string | undefined + + if (typeof message.to === 'string') { + stringifiedTo = message.to + } else if (Array.isArray(message.to)) { + stringifiedTo = message.to + .map((to) => { + if (typeof to === 'string') { + return to + } else if (to.address) { + return to.address + } + return '' + }) + .join(', ') + } else if (message.to.address) { + stringifiedTo = message.to.address + } + + this.logger.error({ + err, + msg: `Failed to send mail to ${stringifiedTo}, subject: ${message.subject ?? 'No Subject'}`, + }) return err } diff --git a/packages/payload/src/email/types.ts b/packages/payload/src/email/types.ts index 72085167e..0d4460bf6 100644 --- a/packages/payload/src/email/types.ts +++ b/packages/payload/src/email/types.ts @@ -1,21 +1,10 @@ -import type { TestAccount, Transporter } from 'nodemailer' -import type Mail from 'nodemailer/lib/mailer' -import type SMTPConnection from 'nodemailer/lib/smtp-connection' +import type { SendMailOptions } from 'nodemailer' -export type Message = { - from: string - html: string - subject: string - to: string +export type EmailAdapter< + TSendEmailOptions extends SendMailOptions, + KSendEmailResponse = unknown, +> = { + defaultFromAddress: string + defaultFromName: string + sendEmail: (message: TSendEmailOptions) => Promise } - -export type MockEmailHandler = { account: TestAccount; transport: Transporter } -export type BuildEmailResult = Promise< - | { - fromAddress: string - fromName: string - transport: Mail - transportOptions?: SMTPConnection.Options - } - | MockEmailHandler -> diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index eb62456a6..e89dc5c2e 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -33,9 +33,9 @@ import type { ManyOptions as UpdateManyOptions, Options as UpdateOptions, } from './collections/operations/local/update.js' -import type { EmailOptions, InitOptions, SanitizedConfig } from './config/types.js' +import type { InitOptions, SanitizedConfig } from './config/types.js' import type { BaseDatabaseAdapter, PaginatedDocs } from './database/types.js' -import type { BuildEmailResult } from './email/types.js' +import type { EmailAdapter } from './email/types.js' import type { TypeWithID as GlobalTypeWithID, Globals } from './globals/config/types.js' import type { Options as FindGlobalOptions } from './globals/operations/local/findOne.js' import type { Options as FindGlobalVersionByIDOptions } from './globals/operations/local/findVersionByID.js' @@ -49,9 +49,9 @@ import { APIKeyAuthentication } from './auth/strategies/apiKey.js' import { JWTAuthentication } from './auth/strategies/jwt.js' import localOperations from './collections/operations/local/index.js' import { validateSchema } from './config/validate.js' -import buildEmail from './email/build.js' -import { defaults as emailDefaults } from './email/defaults.js' -import sendEmail from './email/sendEmail.js' +import { createNodemailerAdapter } from './email/adapters/nodemailer/index.js' +import { emailDefaults } from './email/defaults.js' +import { sendEmail } from './email/sendEmail.js' import { fieldAffectsData } from './exports/types.js' import localGlobalOperations from './globals/operations/local/index.js' import flattenFields from './utilities/flattenTopLevelFields.js' @@ -104,9 +104,8 @@ export class BasePayload { return duplicate(this, options) } - email: BuildEmailResult - - emailOptions: EmailOptions + // Do these types need to be injected via GeneratedTypes? + email: EmailAdapter // TODO: re-implement or remove? // errorHandler: ErrorHandler @@ -367,17 +366,10 @@ export class BasePayload { await this.db.connect() } - // Configure email service - const emailOptions = options.email ? { ...options.email } : this.config.email - if (options.email && this.config.email) { - this.logger.warn( - 'Email options provided in both init options and config. Using init options.', - ) - } + // TODO: Move nodemailer adapter into separate package after verifying all existing functionality + this.email = await createNodemailerAdapter(emailDefaults) - this.emailOptions = emailOptions ?? emailDefaults - this.email = buildEmail(this.emailOptions, this.logger) - this.sendEmail = sendEmail.bind(this) + this.sendEmail = this.email.sendEmail serverInitTelemetry(this) diff --git a/test/email/config.ts b/test/email/config.ts index e767d737c..876addd69 100644 --- a/test/email/config.ts +++ b/test/email/config.ts @@ -31,11 +31,6 @@ export default buildConfigWithDefaults({ }, }) - const email = await payload.sendEmail({ - to: 'test@example.com', - subject: 'This was sent on init', - }) - // Create image const imageFilePath = path.resolve(dirname, '../uploads/image.png') const imageFile = await getFileByPath(imageFilePath)