feat: abstract nodemailer into email adapter interface

This commit is contained in:
Elliot DeNolf
2024-04-17 15:17:57 -04:00
parent abf0461d80
commit a1d68bd951
12 changed files with 196 additions and 218 deletions

View File

@@ -57,7 +57,7 @@ export const forgotPasswordOperation = async (incomingArgs: Arguments): Promise<
disableEmail, disableEmail,
expiration, expiration,
req: { req: {
payload: { config, emailOptions, sendEmail: email }, payload: { config, email },
payload, payload,
}, },
req, req,
@@ -132,8 +132,8 @@ export const forgotPasswordOperation = async (incomingArgs: Arguments): Promise<
} }
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
email({ email.sendEmail({
from: `"${emailOptions.fromName}" <${emailOptions.fromAddress}>`, from: `"${email.defaultFromName}" <${email.defaultFromAddress}>`,
html, html,
subject, subject,
to: data.email, to: data.email,

View File

@@ -1,7 +1,8 @@
import { URL } from 'url' import { URL } from 'url'
import type { Collection } from '../collections/config/types.js' 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 { Payload } from '../index.js'
import type { PayloadRequest } from '../types/index.js' import type { PayloadRequest } from '../types/index.js'
import type { User, VerifyConfig } from './types.js' import type { User, VerifyConfig } from './types.js'
@@ -10,9 +11,8 @@ type Args = {
collection: Collection collection: Collection
config: SanitizedConfig config: SanitizedConfig
disableEmail: boolean disableEmail: boolean
emailOptions: EmailOptions email: EmailAdapter<any, unknown>
req: PayloadRequest req: PayloadRequest
sendEmail: Payload['sendEmail']
token: string token: string
user: User user: User
} }
@@ -23,9 +23,8 @@ async function sendVerificationEmail(args: Args): Promise<void> {
collection: { config: collectionConfig }, collection: { config: collectionConfig },
config, config,
disableEmail, disableEmail,
emailOptions, email,
req, req,
sendEmail,
token, token,
user, user,
} = args } = args
@@ -67,8 +66,8 @@ async function sendVerificationEmail(args: Args): Promise<void> {
} }
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
sendEmail({ email.sendEmail({
from: `"${emailOptions.fromName}" <${emailOptions.fromAddress}>`, from: `"${email.defaultFromName}" <${email.defaultFromName}>`,
html, html,
subject, subject,
to: user.email, to: user.email,

View File

@@ -84,7 +84,7 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
fallbackLocale, fallbackLocale,
locale, locale,
payload, payload,
payload: { config, emailOptions }, payload: { config, email },
}, },
req, req,
showHiddenFields, showHiddenFields,
@@ -272,9 +272,8 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
collection: { config: collectionConfig }, collection: { config: collectionConfig },
config: payload.config, config: payload.config,
disableEmail: disableVerificationEmail, disableEmail: disableVerificationEmail,
emailOptions, email: payload.email,
req, req,
sendEmail: payload.sendEmail,
token: verificationToken, token: verificationToken,
user: result, user: result,
}) })

View File

@@ -1,8 +1,6 @@
import type { I18nOptions, TFunction } from '@payloadcms/translations' import type { I18nOptions, TFunction } from '@payloadcms/translations'
import type { Options as ExpressFileUploadOptions } from 'express-fileupload' import type { Options as ExpressFileUploadOptions } from 'express-fileupload'
import type GraphQL from 'graphql' import type GraphQL from 'graphql'
import type { Transporter } from 'nodemailer'
import type SMTPConnection from 'nodemailer/lib/smtp-connection'
import type { DestinationStream, LoggerOptions } from 'pino' import type { DestinationStream, LoggerOptions } from 'pino'
import type React from 'react' import type React from 'react'
import type { default as sharp } from 'sharp' import type { default as sharp } from 'sharp'
@@ -18,6 +16,7 @@ import type {
SanitizedCollectionConfig, SanitizedCollectionConfig,
} from '../collections/config/types.js' } from '../collections/config/types.js'
import type { DatabaseAdapterResult } from '../database/types.js' import type { DatabaseAdapterResult } from '../database/types.js'
import type { EmailAdapter } from '../email/types.js'
import type { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js' import type { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js'
import type { Payload } from '../index.js' import type { Payload } from '../index.js'
import type { PayloadRequest, Where } from '../types/index.js' import type { PayloadRequest, Where } from '../types/index.js'
@@ -34,12 +33,6 @@ type Prettify<T> = {
[K in keyof T]: T[K] [K in keyof T]: T[K]
} & NonNullable<unknown> } & NonNullable<unknown>
type Email = {
fromAddress: string
fromName: string
logMockCredentials?: boolean
}
// eslint-disable-next-line no-use-before-define // eslint-disable-next-line no-use-before-define
export type Plugin = (config: Config) => Config | Promise<Config> export type Plugin = (config: Config) => Config | Promise<Config>
@@ -85,36 +78,6 @@ export type GeneratePreviewURL = (
options: GeneratePreviewURLOptions, options: GeneratePreviewURLOptions,
) => Promise<null | string> | null | string ) => Promise<null | string> | 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 = { export type GraphQLInfo = {
Mutation: { Mutation: {
fields: Record<string, any> fields: Record<string, any>
@@ -162,13 +125,6 @@ export type InitOptions = {
*/ */
disableOnInit?: boolean 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 * 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 * 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 */ /** Default richtext editor to use for richText fields */
editor: RichTextAdapter<any, any, any> editor: RichTextAdapter<any, any, any>
/** /**
* Email configuration options. This value is overridden by `email` in Payload.init if passed. * Email Adapter
* *
* @see https://payloadcms.com/docs/email/overview * @see https://payloadcms.com/docs/email/overview
*/ */
email?: EmailOptions email?: EmailAdapter<any, unknown>
/** Custom REST endpoints */ /** Custom REST endpoints */
endpoints?: Endpoint[] endpoints?: Endpoint[]
/** /**

View File

@@ -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<SendMailOptions, unknown>
/**
* 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<NodemailerAdapter> => {
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' })
}
}

View File

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

View File

@@ -1,6 +1,9 @@
import type { EmailOptions } from '../config/types.js' import type { EmailAdapter } from './types.js'
export const defaults: EmailOptions = { export const emailDefaults: Pick<
fromAddress: 'info@payloadcms.com', EmailAdapter<any, unknown>,
fromName: 'Payload', 'defaultFromAddress' | 'defaultFromName'
> = {
defaultFromAddress: 'info@payloadcms.com',
defaultFromName: 'Payload',
} }

View File

@@ -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<MockEmailHandler> => {
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

View File

@@ -1,13 +1,36 @@
import type { SendMailOptions } from 'nodemailer' import type { SendMailOptions } from 'nodemailer'
export default async function sendEmail(message: SendMailOptions): Promise<unknown> { import type { Payload } from '../types/index.js'
export async function sendEmail(this: Payload, message: SendMailOptions): Promise<unknown> {
let result let result
try { try {
const email = await this.email result = await this.email.sendEmail(message)
result = await email.transport.sendMail(message) } catch (err: unknown) {
} catch (err) { let stringifiedTo: string | undefined
this.logger.error(err, `Failed to send mail to ${message.to}, subject: ${message.subject}`)
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 return err
} }

View File

@@ -1,21 +1,10 @@
import type { TestAccount, Transporter } from 'nodemailer' import type { SendMailOptions } from 'nodemailer'
import type Mail from 'nodemailer/lib/mailer'
import type SMTPConnection from 'nodemailer/lib/smtp-connection'
export type Message = { export type EmailAdapter<
from: string TSendEmailOptions extends SendMailOptions,
html: string KSendEmailResponse = unknown,
subject: string > = {
to: string defaultFromAddress: string
defaultFromName: string
sendEmail: (message: TSendEmailOptions) => Promise<KSendEmailResponse>
} }
export type MockEmailHandler = { account: TestAccount; transport: Transporter }
export type BuildEmailResult = Promise<
| {
fromAddress: string
fromName: string
transport: Mail
transportOptions?: SMTPConnection.Options
}
| MockEmailHandler
>

View File

@@ -33,9 +33,9 @@ import type {
ManyOptions as UpdateManyOptions, ManyOptions as UpdateManyOptions,
Options as UpdateOptions, Options as UpdateOptions,
} from './collections/operations/local/update.js' } 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 { 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 { TypeWithID as GlobalTypeWithID, Globals } from './globals/config/types.js'
import type { Options as FindGlobalOptions } from './globals/operations/local/findOne.js' import type { Options as FindGlobalOptions } from './globals/operations/local/findOne.js'
import type { Options as FindGlobalVersionByIDOptions } from './globals/operations/local/findVersionByID.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 { JWTAuthentication } from './auth/strategies/jwt.js'
import localOperations from './collections/operations/local/index.js' import localOperations from './collections/operations/local/index.js'
import { validateSchema } from './config/validate.js' import { validateSchema } from './config/validate.js'
import buildEmail from './email/build.js' import { createNodemailerAdapter } from './email/adapters/nodemailer/index.js'
import { defaults as emailDefaults } from './email/defaults.js' import { emailDefaults } from './email/defaults.js'
import sendEmail from './email/sendEmail.js' import { sendEmail } from './email/sendEmail.js'
import { fieldAffectsData } from './exports/types.js' import { fieldAffectsData } from './exports/types.js'
import localGlobalOperations from './globals/operations/local/index.js' import localGlobalOperations from './globals/operations/local/index.js'
import flattenFields from './utilities/flattenTopLevelFields.js' import flattenFields from './utilities/flattenTopLevelFields.js'
@@ -104,9 +104,8 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
return duplicate<T>(this, options) return duplicate<T>(this, options)
} }
email: BuildEmailResult // Do these types need to be injected via GeneratedTypes?
email: EmailAdapter<any, unknown>
emailOptions: EmailOptions
// TODO: re-implement or remove? // TODO: re-implement or remove?
// errorHandler: ErrorHandler // errorHandler: ErrorHandler
@@ -367,17 +366,10 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
await this.db.connect() await this.db.connect()
} }
// Configure email service // TODO: Move nodemailer adapter into separate package after verifying all existing functionality
const emailOptions = options.email ? { ...options.email } : this.config.email this.email = await createNodemailerAdapter(emailDefaults)
if (options.email && this.config.email) {
this.logger.warn(
'Email options provided in both init options and config. Using init options.',
)
}
this.emailOptions = emailOptions ?? emailDefaults this.sendEmail = this.email.sendEmail
this.email = buildEmail(this.emailOptions, this.logger)
this.sendEmail = sendEmail.bind(this)
serverInitTelemetry(this) serverInitTelemetry(this)

View File

@@ -31,11 +31,6 @@ export default buildConfigWithDefaults({
}, },
}) })
const email = await payload.sendEmail({
to: 'test@example.com',
subject: 'This was sent on init',
})
// Create image // Create image
const imageFilePath = path.resolve(dirname, '../uploads/image.png') const imageFilePath = path.resolve(dirname, '../uploads/image.png')
const imageFile = await getFileByPath(imageFilePath) const imageFile = await getFileByPath(imageFilePath)