From a1d68bd9518e823518a59efcee7f3657209f745e Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 17 Apr 2024 15:17:57 -0400 Subject: [PATCH 01/14] 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 1f6150113e..56f9380668 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 0921df941f..a76873c7a9 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 fd67ed628c..636c0b0f3e 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 0000000000..db16efbd6d --- /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 03add50afd..0000000000 --- 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 d084de4b28..2bb905ea5d 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 4424b0d4db..0000000000 --- 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 008b182bf0..a5022ea6e3 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 72085167e8..0d4460bf60 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 eb62456a60..e89dc5c2ef 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 e767d737c9..876addd694 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) From fb7925f272c360137a91f54ee7f952add4a84ff7 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Wed, 17 Apr 2024 17:10:24 -0400 Subject: [PATCH 02/14] feat: create email-nodemailer package --- package.json | 1 + packages/email-nodemailer/.eslintignore | 10 ++ packages/email-nodemailer/.eslintrc.cjs | 7 ++ packages/email-nodemailer/.prettierignore | 10 ++ packages/email-nodemailer/.swcrc | 15 +++ packages/email-nodemailer/LICENSE.md | 22 ++++ packages/email-nodemailer/README.md | 1 + packages/email-nodemailer/package.json | 58 ++++++++++ packages/email-nodemailer/src/index.ts | 126 +++++++++++++++++++++ packages/email-nodemailer/tsconfig.json | 18 +++ packages/payload/src/config/types.ts | 2 +- packages/payload/src/exports/types.ts | 2 + packages/payload/src/index.ts | 11 +- pnpm-lock.yaml | 16 +++ test/email-nodemailer/.eslintrc.cjs | 8 ++ test/email-nodemailer/.gitignore | 2 + test/email-nodemailer/config.ts | 40 +++++++ test/email-nodemailer/payload-types.ts | 50 ++++++++ test/email-nodemailer/tsconfig.eslint.json | 13 +++ test/email/config.ts | 5 + test/package.json | 1 + 21 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 packages/email-nodemailer/.eslintignore create mode 100644 packages/email-nodemailer/.eslintrc.cjs create mode 100644 packages/email-nodemailer/.prettierignore create mode 100644 packages/email-nodemailer/.swcrc create mode 100644 packages/email-nodemailer/LICENSE.md create mode 100644 packages/email-nodemailer/README.md create mode 100644 packages/email-nodemailer/package.json create mode 100644 packages/email-nodemailer/src/index.ts create mode 100644 packages/email-nodemailer/tsconfig.json create mode 100644 test/email-nodemailer/.eslintrc.cjs create mode 100644 test/email-nodemailer/.gitignore create mode 100644 test/email-nodemailer/config.ts create mode 100644 test/email-nodemailer/payload-types.ts create mode 100644 test/email-nodemailer/tsconfig.eslint.json diff --git a/package.json b/package.json index 510c4e580c..3ed7757271 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build:create-payload-app": "turbo build --filter create-payload-app", "build:db-mongodb": "turbo build --filter db-mongodb", "build:db-postgres": "turbo build --filter db-postgres", + "build:email-nodemailer": "turbo build --filter email-nodemailer", "build:eslint-config-payload": "turbo build --filter eslint-config-payload", "build:graphql": "turbo build --filter graphql", "build:live-preview": "turbo build --filter live-preview", diff --git a/packages/email-nodemailer/.eslintignore b/packages/email-nodemailer/.eslintignore new file mode 100644 index 0000000000..247f3f12de --- /dev/null +++ b/packages/email-nodemailer/.eslintignore @@ -0,0 +1,10 @@ +.tmp +**/.git +**/.hg +**/.pnp.* +**/.svn +**/.yarn/** +**/build +**/dist/** +**/node_modules +**/temp diff --git a/packages/email-nodemailer/.eslintrc.cjs b/packages/email-nodemailer/.eslintrc.cjs new file mode 100644 index 0000000000..d6b3a476b8 --- /dev/null +++ b/packages/email-nodemailer/.eslintrc.cjs @@ -0,0 +1,7 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + parserOptions: { + project: ['./tsconfig.json'], + tsconfigRootDir: __dirname, + }, +} diff --git a/packages/email-nodemailer/.prettierignore b/packages/email-nodemailer/.prettierignore new file mode 100644 index 0000000000..247f3f12de --- /dev/null +++ b/packages/email-nodemailer/.prettierignore @@ -0,0 +1,10 @@ +.tmp +**/.git +**/.hg +**/.pnp.* +**/.svn +**/.yarn/** +**/build +**/dist/** +**/node_modules +**/temp diff --git a/packages/email-nodemailer/.swcrc b/packages/email-nodemailer/.swcrc new file mode 100644 index 0000000000..14463f4b08 --- /dev/null +++ b/packages/email-nodemailer/.swcrc @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "sourceMaps": true, + "jsc": { + "target": "esnext", + "parser": { + "syntax": "typescript", + "tsx": true, + "dts": true + } + }, + "module": { + "type": "es6" + } +} diff --git a/packages/email-nodemailer/LICENSE.md b/packages/email-nodemailer/LICENSE.md new file mode 100644 index 0000000000..05e80b2b48 --- /dev/null +++ b/packages/email-nodemailer/LICENSE.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2018-2022 Payload CMS, LLC +Portions Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/email-nodemailer/README.md b/packages/email-nodemailer/README.md new file mode 100644 index 0000000000..1740cad35a --- /dev/null +++ b/packages/email-nodemailer/README.md @@ -0,0 +1 @@ +# Nodemailer Email Adapter diff --git a/packages/email-nodemailer/package.json b/packages/email-nodemailer/package.json new file mode 100644 index 0000000000..f32b392455 --- /dev/null +++ b/packages/email-nodemailer/package.json @@ -0,0 +1,58 @@ +{ + "name": "@payloadcms/email-nodemailer", + "version": "0.0.0", + "description": "Payload Nodemailer Email Adapter", + "repository": { + "type": "git", + "url": "https://github.com/payloadcms/payload.git", + "directory": "packages/email-nodemailer" + }, + "license": "MIT", + "homepage": "https://payloadcms.com", + "author": "Payload CMS, Inc.", + "main": "./src/index.ts", + "types": "./src/index.ts", + "type": "module", + "scripts": { + "build": "pnpm build:swc && pnpm build:types", + "build:swc": "swc ./src -d ./dist --config-file .swcrc", + "build:types": "tsc --emitDeclarationOnly --outDir dist", + "build:clean": "find . \\( -type d \\( -name build -o -name dist -o -name .cache \\) -o -type f -name tsconfig.tsbuildinfo \\) -exec rm -rf {} + && pnpm build", + "clean": "rimraf {dist,*.tsbuildinfo}", + "prepublishOnly": "pnpm clean && pnpm turbo build" + }, + "dependencies": { + "nodemailer": "6.9.10" + }, + "peerDependencies": { + "payload": "workspace:*" + }, + "exports": { + ".": { + "import": "./src/index.ts", + "require": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "publishConfig": { + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "registry": "https://registry.npmjs.org/", + "types": "./dist/index.d.ts" + }, + "engines": { + "node": ">=18.20.2" + }, + "files": [ + "dist" + ], + "devDependencies": { + "@types/nodemailer": "6.4.14" + } +} diff --git a/packages/email-nodemailer/src/index.ts b/packages/email-nodemailer/src/index.ts new file mode 100644 index 0000000000..307eaf5a1c --- /dev/null +++ b/packages/email-nodemailer/src/index.ts @@ -0,0 +1,126 @@ +/* eslint-disable no-console */ +import type { SendMailOptions, Transporter } from 'nodemailer' +import type SMTPConnection from 'nodemailer/lib/smtp-connection' +import type { EmailAdapter } from 'payload/types' + +import nodemailer from 'nodemailer' +import { InvalidConfiguration } from 'payload/errors' + +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: 'info@payloadcms.com', + defaultFromName: 'Payload', + 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/email-nodemailer/tsconfig.json b/packages/email-nodemailer/tsconfig.json new file mode 100644 index 0000000000..f4391b4fae --- /dev/null +++ b/packages/email-nodemailer/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, // Make sure typescript knows that this module depends on their references + "noEmit": false /* Do not emit outputs. */, + "emitDeclarationOnly": true, + "outDir": "./dist" /* Specify an output folder for all emitted files. */, + "rootDir": "./src" /* Specify the root folder within your source files. */, + }, + "exclude": [ + "dist", + "src/**/*.spec.js", + "src/**/*.spec.jsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ], + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"], +} diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 1d521f59c8..6ed38693d2 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -531,7 +531,7 @@ export type Config = { * * @see https://payloadcms.com/docs/email/overview */ - email?: EmailAdapter + email?: EmailAdapter | Promise> /** Custom REST endpoints */ endpoints?: Endpoint[] /** diff --git a/packages/payload/src/exports/types.ts b/packages/payload/src/exports/types.ts index 7fcdf1b579..126a76166d 100644 --- a/packages/payload/src/exports/types.ts +++ b/packages/payload/src/exports/types.ts @@ -3,7 +3,9 @@ export type * from '../admin/types.js' export type * from '../uploads/types.js' export type { DocumentPermissions, FieldPermissions } from '../auth/index.js' + export type { MeOperationResult } from '../auth/operations/me.js' +export type { EmailAdapter } from '../email/types.js' export type { CollapsedPreferences, diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index e89dc5c2ef..e77dc65cba 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -49,9 +49,6 @@ 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 { 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' @@ -366,8 +363,12 @@ export class BasePayload { await this.db.connect() } - // TODO: Move nodemailer adapter into separate package after verifying all existing functionality - this.email = await createNodemailerAdapter(emailDefaults) + // Load email adapter + if (this.config.email instanceof Promise) { + this.email = await this.config.email + } else { + this.email = this.config.email + } this.sendEmail = this.email.sendEmail diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b95b955f55..d96b996aab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -440,6 +440,19 @@ importers: specifier: workspace:* version: link:../payload + packages/email-nodemailer: + dependencies: + nodemailer: + specifier: 6.9.10 + version: 6.9.10 + payload: + specifier: workspace:* + version: link:../payload + devDependencies: + '@types/nodemailer': + specifier: 6.4.14 + version: 6.4.14 + packages/eslint-config-payload: dependencies: '@types/eslint': @@ -1502,6 +1515,9 @@ importers: '@payloadcms/db-postgres': specifier: workspace:* version: link:../packages/db-postgres + '@payloadcms/email-nodemailer': + specifier: workspace:* + version: link:../packages/email-nodemailer '@payloadcms/eslint-config': specifier: workspace:* version: link:../packages/eslint-config-payload diff --git a/test/email-nodemailer/.eslintrc.cjs b/test/email-nodemailer/.eslintrc.cjs new file mode 100644 index 0000000000..39a96642f1 --- /dev/null +++ b/test/email-nodemailer/.eslintrc.cjs @@ -0,0 +1,8 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + ignorePatterns: ['payload-types.ts'], + parserOptions: { + project: ['./tsconfig.eslint.json'], + tsconfigRootDir: __dirname, + }, +} diff --git a/test/email-nodemailer/.gitignore b/test/email-nodemailer/.gitignore new file mode 100644 index 0000000000..cce01755f4 --- /dev/null +++ b/test/email-nodemailer/.gitignore @@ -0,0 +1,2 @@ +/media +/media-gif diff --git a/test/email-nodemailer/config.ts b/test/email-nodemailer/config.ts new file mode 100644 index 0000000000..1219f0acba --- /dev/null +++ b/test/email-nodemailer/config.ts @@ -0,0 +1,40 @@ +import { createNodemailerAdapter } from '@payloadcms/email-nodemailer' +import path from 'path' +import { getFileByPath } from 'payload/uploads' +import { fileURLToPath } from 'url' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { devUser } from '../credentials.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export default buildConfigWithDefaults({ + // ...extend config here + collections: [], + email: createNodemailerAdapter(), + onInit: async (payload) => { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + 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) + + await payload.create({ + collection: 'media', + data: {}, + file: imageFile, + }) + }, +}) diff --git a/test/email-nodemailer/payload-types.ts b/test/email-nodemailer/payload-types.ts new file mode 100644 index 0000000000..7cf43254ea --- /dev/null +++ b/test/email-nodemailer/payload-types.ts @@ -0,0 +1,50 @@ +/* tslint:disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +export interface Config { + collections: { + posts: Post + media: Media + users: User + } + globals: { + menu: Menu + } +} +export interface Post { + id: string + text?: string + associatedMedia?: string | Media + updatedAt: string + createdAt: string +} +export interface Media { + id: string + updatedAt: string + createdAt: string + url?: string + filename?: string + mimeType?: string + filesize?: number + width?: number + height?: number +} +export interface User { + id: string + updatedAt: string + createdAt: string + email?: string + resetPasswordToken?: string + resetPasswordExpiration?: string + loginAttempts?: number + lockUntil?: string + password?: string +} +export interface Menu { + id: string + globalText?: string +} diff --git a/test/email-nodemailer/tsconfig.eslint.json b/test/email-nodemailer/tsconfig.eslint.json new file mode 100644 index 0000000000..b34cc7afbb --- /dev/null +++ b/test/email-nodemailer/tsconfig.eslint.json @@ -0,0 +1,13 @@ +{ + // extend your base config to share compilerOptions, etc + //"extends": "./tsconfig.json", + "compilerOptions": { + // ensure that nobody can accidentally use this config for a build + "noEmit": true + }, + "include": [ + // whatever paths you intend to lint + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/test/email/config.ts b/test/email/config.ts index 876addd694..e767d737c9 100644 --- a/test/email/config.ts +++ b/test/email/config.ts @@ -31,6 +31,11 @@ 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) diff --git a/test/package.json b/test/package.json index 3ab9a2db41..4b768f6c96 100644 --- a/test/package.json +++ b/test/package.json @@ -15,6 +15,7 @@ "@payloadcms/db-mongodb": "workspace:*", "@payloadcms/db-postgres": "workspace:*", "@payloadcms/eslint-config": "workspace:*", + "@payloadcms/email-nodemailer": "workspace:*", "@payloadcms/graphql": "workspace:*", "@payloadcms/live-preview": "workspace:*", "@payloadcms/live-preview-react": "workspace:*", From b297c5499ddf19b86a4f5db6fdd664102365e49f Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 18 Apr 2024 00:01:57 -0400 Subject: [PATCH 03/14] chore(email): strict true --- packages/email-nodemailer/src/index.ts | 19 ++++++++++++++----- packages/email-nodemailer/tsconfig.json | 5 +++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/email-nodemailer/src/index.ts b/packages/email-nodemailer/src/index.ts index 307eaf5a1c..2d46ea544d 100644 --- a/packages/email-nodemailer/src/index.ts +++ b/packages/email-nodemailer/src/index.ts @@ -48,10 +48,13 @@ async function buildEmail( emailConfig?: NodemailerAdapterArgs, ): Promise<{ defaultFromAddress: string; defaultFromName: string; transport: Transporter }> { if (!emailConfig) { + const transport = await createMockAccount(emailConfig) + if (!transport) throw new InvalidConfiguration('Unable to create Nodemailer test account.') + return { defaultFromAddress: 'info@payloadcms.com', defaultFromName: 'Payload', - transport: await createMockAccount(emailConfig), + transport, } } @@ -94,12 +97,12 @@ const ensureConfigHasFrom = (emailConfig: NodemailerAdapterArgs) => { /** * Use ethereal.email to create a mock email account */ -async function createMockAccount(emailConfig: NodemailerAdapterArgs) { +async function createMockAccount(emailConfig?: NodemailerAdapterArgs) { try { const etherealAccount = await nodemailer.createTestAccount() const smtpOptions = { - ...emailConfig, + ...(emailConfig || {}), auth: { pass: etherealAccount.pass, user: etherealAccount.user, @@ -120,7 +123,13 @@ async function createMockAccount(emailConfig: NodemailerAdapterArgs) { 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' }) + } catch (err: unknown) { + if (err instanceof Error) { + console.error({ err, msg: 'There was a problem setting up the mock email handler' }) + throw new InvalidConfiguration( + `Unable to create Nodemailer test account. Error: ${err.message}`, + ) + } + throw new InvalidConfiguration('Unable to create Nodemailer test account.') } } diff --git a/packages/email-nodemailer/tsconfig.json b/packages/email-nodemailer/tsconfig.json index f4391b4fae..9e82b9e073 100644 --- a/packages/email-nodemailer/tsconfig.json +++ b/packages/email-nodemailer/tsconfig.json @@ -6,13 +6,18 @@ "emitDeclarationOnly": true, "outDir": "./dist" /* Specify an output folder for all emitted files. */, "rootDir": "./src" /* Specify the root folder within your source files. */, + "strict": true, }, "exclude": [ "dist", + "node_modules", "src/**/*.spec.js", "src/**/*.spec.jsx", "src/**/*.spec.ts", "src/**/*.spec.tsx" ], "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"], + "references": [ + { "path": "../payload" }, + ] } From 62233788e0c3fe6582c919d146b06aba24b0a9a1 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 18 Apr 2024 01:38:01 -0400 Subject: [PATCH 04/14] feat(plugin-cloud): use nodemailer adapter --- packages/plugin-cloud/package.json | 1 + packages/plugin-cloud/src/email.spec.ts | 65 ++++++++------ packages/plugin-cloud/src/email.ts | 45 +++++----- packages/plugin-cloud/src/plugin.spec.ts | 108 +++++++++++++---------- packages/plugin-cloud/src/plugin.ts | 7 +- packages/plugin-cloud/src/types.ts | 11 ++- pnpm-lock.yaml | 3 + 7 files changed, 146 insertions(+), 94 deletions(-) diff --git a/packages/plugin-cloud/package.json b/packages/plugin-cloud/package.json index 0e6a722a94..23ae1d5615 100644 --- a/packages/plugin-cloud/package.json +++ b/packages/plugin-cloud/package.json @@ -28,6 +28,7 @@ "@aws-sdk/client-s3": "^3.525.0", "@aws-sdk/credential-providers": "^3.525.0", "@aws-sdk/lib-storage": "^3.525.0", + "@payloadcms/email-nodemailer": "workspace:^", "amazon-cognito-identity-js": "^6.1.2", "nodemailer": "6.9.10", "resend": "^0.17.2" diff --git a/packages/plugin-cloud/src/email.spec.ts b/packages/plugin-cloud/src/email.spec.ts index 7cca75441b..fa028b67ff 100644 --- a/packages/plugin-cloud/src/email.spec.ts +++ b/packages/plugin-cloud/src/email.spec.ts @@ -1,4 +1,5 @@ import type { Config } from 'payload/config' +import nodemailer from 'nodemailer' import { defaults } from 'payload/config' @@ -6,18 +7,34 @@ import { payloadCloudEmail } from './email.js' describe('email', () => { let defaultConfig: Config + const skipVerify = true + const defaultDomain = 'test.com' + const apiKey = 'test' + let createTransportSpy: jest.SpyInstance beforeEach(() => { - // @ts-expect-error No need for db or editor - defaultConfig = { ...defaults } + defaultConfig = defaults as Config + + createTransportSpy = jest.spyOn(nodemailer, 'createTransport').mockImplementation(() => { + return { + verify: jest.fn(), + } as unknown as ReturnType + }) + + const createTestAccountSpy = jest.spyOn(nodemailer, 'createTestAccount').mockResolvedValue({ + pass: 'password', + user: 'user', + web: 'ethereal.email', + } as unknown as nodemailer.TestAccount) }) describe('not in Payload Cloud', () => { - it('should return undefined', () => { - const email = payloadCloudEmail({ - apiKey: 'test', + it('should return undefined', async () => { + const email = await payloadCloudEmail({ + apiKey, config: defaultConfig, - defaultDomain: 'test', + defaultDomain, + skipVerify, }) expect(email).toBeUndefined() @@ -29,35 +46,33 @@ describe('email', () => { process.env.PAYLOAD_CLOUD = 'true' }) - it('should respect PAYLOAD_CLOUD env var', () => { - const email = payloadCloudEmail({ - apiKey: 'test', + it('should respect PAYLOAD_CLOUD env var', async () => { + const email = await payloadCloudEmail({ + apiKey, config: defaultConfig, - defaultDomain: 'test', + defaultDomain, + skipVerify, }) - expect(email?.fromName).toBeDefined() - expect(email?.fromAddress).toBeDefined() - expect(email?.transport?.transporter.name).toEqual('SMTP') + expect(email).toBeDefined() }) - it('should allow setting fromName and fromAddress', () => { - const fromName = 'custom from name' - const fromAddress = 'custom@fromaddress.com' + it('should allow setting fromName and fromAddress', async () => { + const defaultFromName = 'custom from name' + const defaultFromAddress = 'custom@fromaddress.com' const configWithFrom: Config = { ...defaultConfig, - email: { - fromAddress, - fromName, - }, } - const email = payloadCloudEmail({ - apiKey: 'test', + const email = await payloadCloudEmail({ + apiKey, config: configWithFrom, - defaultDomain: 'test', + defaultDomain, + skipVerify, + defaultFromName, + defaultFromAddress, }) - expect(email?.fromName).toEqual(fromName) - expect(email?.fromAddress).toEqual(fromAddress) + expect(email.defaultFromName).toEqual(defaultFromName) + expect(email.defaultFromAddress).toEqual(defaultFromAddress) }) }) }) diff --git a/packages/plugin-cloud/src/email.ts b/packages/plugin-cloud/src/email.ts index 05c52344a7..88be904302 100644 --- a/packages/plugin-cloud/src/email.ts +++ b/packages/plugin-cloud/src/email.ts @@ -1,10 +1,13 @@ -import type { EmailTransport } from 'payload/config' +import type { NodemailerAdapter } from '@payloadcms/email-nodemailer' +import { createNodemailerAdapter } from '@payloadcms/email-nodemailer' import nodemailer from 'nodemailer' import type { PayloadCloudEmailOptions } from './types.js' -export const payloadCloudEmail = (args: PayloadCloudEmailOptions): EmailTransport | undefined => { +export const payloadCloudEmail = async ( + args: PayloadCloudEmailOptions, +): Promise | undefined => { if (process.env.PAYLOAD_CLOUD !== 'true' || !args) { return undefined } @@ -13,7 +16,16 @@ export const payloadCloudEmail = (args: PayloadCloudEmailOptions): EmailTranspor if (!args.defaultDomain) throw new Error('defaultDomain must be provided to use Payload Cloud Email') - const { apiKey, config, defaultDomain } = args + // Check if already has email configuration + + if (args.config.email) { + console.log( + 'Payload Cloud Email is enabled but email configuration is already provided in Payload config. If this is intentional, set `email: false` in the Payload Cloud plugin options.', + ) + return args.config.email + } + + const { apiKey, defaultDomain, skipVerify } = args const customDomainEnvs = Object.keys(process.env).filter( (e) => e.startsWith('PAYLOAD_CLOUD_EMAIL_DOMAIN_') && !e.endsWith('API_KEY'), @@ -27,23 +39,14 @@ export const payloadCloudEmail = (args: PayloadCloudEmailOptions): EmailTranspor ) } - const fromName = config.email?.fromName || 'Payload CMS' - const fromAddress = - config.email?.fromAddress || `cms@${customDomains.length ? customDomains[0] : defaultDomain}` + const defaultFromName = args.defaultFromName || 'Payload CMS' + const defaultFromAddress = + args.defaultFromAddress || `cms@${customDomains.length ? customDomains[0] : defaultDomain}` - const existingTransport = config.email && 'transport' in config.email && config.email?.transport - - if (existingTransport) { - return { - fromAddress, - fromName, - transport: existingTransport, - } - } - - return { - fromAddress, - fromName, + const emailAdapter = await createNodemailerAdapter({ + defaultFromAddress, + defaultFromName, + skipVerify, transport: nodemailer.createTransport({ auth: { pass: apiKey, @@ -53,5 +56,7 @@ export const payloadCloudEmail = (args: PayloadCloudEmailOptions): EmailTranspor port: 465, secure: true, }), - } + }) + + return emailAdapter } diff --git a/packages/plugin-cloud/src/plugin.spec.ts b/packages/plugin-cloud/src/plugin.spec.ts index 54c78dea52..94d22b5eeb 100644 --- a/packages/plugin-cloud/src/plugin.spec.ts +++ b/packages/plugin-cloud/src/plugin.spec.ts @@ -4,16 +4,35 @@ import nodemailer from 'nodemailer' import { defaults } from 'payload/config' import { payloadCloud } from './plugin.js' +import { createNodemailerAdapter } from '@payloadcms/email-nodemailer' describe('plugin', () => { + let createTransportSpy: jest.SpyInstance + + const skipVerify = true + + beforeEach(() => { + createTransportSpy = jest.spyOn(nodemailer, 'createTransport').mockImplementation(() => { + return { + verify: jest.fn(), + } as unknown as ReturnType + }) + + const createTestAccountSpy = jest.spyOn(nodemailer, 'createTestAccount').mockResolvedValue({ + pass: 'password', + user: 'user', + web: 'ethereal.email', + } as unknown as nodemailer.TestAccount) + }) + describe('not in Payload Cloud', () => { // eslint-disable-next-line jest/expect-expect - it('should return unmodified config', () => { + it('should return unmodified config', async () => { const plugin = payloadCloud() - const config = plugin(createConfig()) + const config = await plugin(createConfig()) assertNoCloudStorage(config) - assertNoCloudEmail(config) + expect(config.email).toBeUndefined() }) }) @@ -26,17 +45,17 @@ describe('plugin', () => { describe('storage', () => { // eslint-disable-next-line jest/expect-expect - it('should default to using payload cloud storage', () => { + it('should default to using payload cloud storage', async () => { const plugin = payloadCloud() - const config = plugin(createConfig()) + const config = await plugin(createConfig()) assertCloudStorage(config) }) // eslint-disable-next-line jest/expect-expect - it('should allow opt-out', () => { + it('should allow opt-out', async () => { const plugin = payloadCloud({ storage: false }) - const config = plugin(createConfig()) + const config = await plugin(createConfig()) assertNoCloudStorage(config) }) @@ -44,33 +63,35 @@ describe('plugin', () => { describe('email', () => { // eslint-disable-next-line jest/expect-expect - it('should default to using payload cloud email', () => { + it('should default to using payload cloud email', async () => { const plugin = payloadCloud() - const config = plugin(createConfig()) + const config = await plugin(createConfig()) assertCloudEmail(config) }) // eslint-disable-next-line jest/expect-expect - it('should allow opt-out', () => { + it('should allow opt-out', async () => { const plugin = payloadCloud({ email: false }) - const config = plugin(createConfig()) + const config = await plugin(createConfig()) - assertNoCloudEmail(config) + expect(config.email).toBeUndefined() }) // eslint-disable-next-line jest/expect-expect - it('should allow PAYLOAD_CLOUD_EMAIL_* env vars to be unset', () => { + it('should allow PAYLOAD_CLOUD_EMAIL_* env vars to be unset', async () => { delete process.env.PAYLOAD_CLOUD_EMAIL_API_KEY delete process.env.PAYLOAD_CLOUD_DEFAULT_DOMAIN const plugin = payloadCloud() - const config = plugin(createConfig()) + const config = await plugin(createConfig()) - assertNoCloudEmail(config) + expect(config.email).toBeUndefined() }) - it('should not modify existing email transport', () => { + it('should not modify existing email transport', async () => { + const logSpy = jest.spyOn(console, 'log') + const existingTransport = nodemailer.createTransport({ name: 'existing-transport', // eslint-disable-next-line @typescript-eslint/require-await @@ -81,36 +102,41 @@ describe('plugin', () => { }) const configWithTransport = createConfig({ - email: { - fromAddress: 'test@test.com', - fromName: 'Test', + email: await createNodemailerAdapter({ + defaultFromAddress: 'test@test.com', + defaultFromName: 'Test', transport: existingTransport, - }, + skipVerify, + }), }) const plugin = payloadCloud() - const config = plugin(configWithTransport) + const config = await plugin(configWithTransport) - expect( - config.email && 'transport' in config.email && config.email.transport?.transporter.name, - ).toEqual('existing-transport') + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Payload Cloud Email is enabled but'), + ) - assertNoCloudEmail(config) + // expect(config.email).toBeUndefined() }) - it('should allow setting fromName and fromAddress', () => { + it('should allow setting fromName and fromAddress', async () => { + const defaultFromName = 'Test' + const defaultFromAddress = 'test@test.com' const configWithPartialEmail = createConfig({ - email: { - fromAddress: 'test@test.com', - fromName: 'Test', - }, + email: await createNodemailerAdapter({ + defaultFromAddress, + defaultFromName, + skipVerify, + }), }) const plugin = payloadCloud() - const config = plugin(configWithPartialEmail) + const config = await plugin(configWithPartialEmail) + const emailConfig = config.email as Awaited> - expect(config.email?.fromName).toEqual(configWithPartialEmail.email?.fromName) - expect(config.email?.fromAddress).toEqual(configWithPartialEmail.email?.fromAddress) + expect(emailConfig.defaultFromName).toEqual(defaultFromName) + expect(emailConfig.defaultFromAddress).toEqual(defaultFromAddress) assertCloudEmail(config) }) @@ -127,19 +153,9 @@ function assertNoCloudStorage(config: Config) { } function assertCloudEmail(config: Config) { - if (config.email && 'transport' in config.email) { - expect(config.email?.transport?.transporter.name).toEqual('SMTP') - } -} - -/** Asserts that plugin did not run */ -function assertNoCloudEmail(config: Config) { - // No transport set - if (!config.email) return - - if ('transport' in config.email) { - expect(config.email?.transport?.transporter.name).not.toEqual('SMTP') - } + expect( + config.email && 'sendEmail' in config.email && typeof config.email.sendEmail === 'function', + ).toBe(true) } function createConfig(overrides?: Partial): Config { diff --git a/packages/plugin-cloud/src/plugin.ts b/packages/plugin-cloud/src/plugin.ts index 3a4597f021..7aead3b875 100644 --- a/packages/plugin-cloud/src/plugin.ts +++ b/packages/plugin-cloud/src/plugin.ts @@ -13,7 +13,7 @@ import { getStaticHandler } from './staticHandler.js' export const payloadCloud = (pluginOptions?: PluginOptions) => - (incomingConfig: Config): Config => { + async (incomingConfig: Config): Promise => { let config = { ...incomingConfig } if (process.env.PAYLOAD_CLOUD !== 'true') { @@ -83,10 +83,13 @@ export const payloadCloud = const apiKey = process.env.PAYLOAD_CLOUD_EMAIL_API_KEY const defaultDomain = process.env.PAYLOAD_CLOUD_DEFAULT_DOMAIN if (pluginOptions?.email !== false && apiKey && defaultDomain) { - config.email = payloadCloudEmail({ + config.email = await payloadCloudEmail({ apiKey, config, defaultDomain, + defaultFromAddress: pluginOptions?.email?.defaultFromAddress, + defaultFromName: pluginOptions?.email?.defaultFromName, + skipVerify: pluginOptions?.email?.skipVerify, }) } diff --git a/packages/plugin-cloud/src/types.ts b/packages/plugin-cloud/src/types.ts index 3a68475819..2f91094799 100644 --- a/packages/plugin-cloud/src/types.ts +++ b/packages/plugin-cloud/src/types.ts @@ -42,13 +42,22 @@ export interface PayloadCloudEmailOptions { apiKey: string config: Config defaultDomain: string + defaultFromAddress?: string + defaultFromName?: string + skipVerify?: boolean } export interface PluginOptions { /** Payload Cloud Email * @default true */ - email?: false + email?: + | { + defaultFromAddress: string + defaultFromName: string + skipVerify?: boolean + } + | false /** * Payload Cloud API endpoint diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d96b996aab..623dea744a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -951,6 +951,9 @@ importers: '@aws-sdk/lib-storage': specifier: ^3.525.0 version: 3.550.0(@aws-sdk/client-s3@3.550.0) + '@payloadcms/email-nodemailer': + specifier: workspace:^ + version: link:../email-nodemailer amazon-cognito-identity-js: specifier: ^6.1.2 version: 6.3.12 From 55c59e71da81b246109a115d6d7b88a305676bc7 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 18 Apr 2024 01:44:35 -0400 Subject: [PATCH 05/14] chore: remove nodemailer from payload completely --- packages/payload/package.json | 1 - .../src/email/adapters/nodemailer/index.ts | 129 ------------------ pnpm-lock.yaml | 3 - 3 files changed, 133 deletions(-) delete mode 100644 packages/payload/src/email/adapters/nodemailer/index.ts diff --git a/packages/payload/package.json b/packages/payload/package.json index 1a738dd54c..41e6db75f7 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -59,7 +59,6 @@ "minimist": "1.2.8", "mkdirp": "1.0.4", "monaco-editor": "0.38.0", - "nodemailer": "6.9.10", "pino": "8.15.0", "pino-pretty": "10.2.0", "pluralize": "8.0.0", diff --git a/packages/payload/src/email/adapters/nodemailer/index.ts b/packages/payload/src/email/adapters/nodemailer/index.ts deleted file mode 100644 index db16efbd6d..0000000000 --- a/packages/payload/src/email/adapters/nodemailer/index.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* 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/pnpm-lock.yaml b/pnpm-lock.yaml index 623dea744a..d53abbe89e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -782,9 +782,6 @@ importers: monaco-editor: specifier: 0.38.0 version: 0.38.0 - nodemailer: - specifier: 6.9.10 - version: 6.9.10 pino: specifier: 8.15.0 version: 8.15.0 From 24072d222ca9bac7f703258b6cac8fd17e91e32d Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 18 Apr 2024 02:07:54 -0400 Subject: [PATCH 06/14] chore: clean up types, remove logMockEmailCredentials --- packages/email-nodemailer/src/index.ts | 35 ++++++++------------------ 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/packages/email-nodemailer/src/index.ts b/packages/email-nodemailer/src/index.ts index 2d46ea544d..9a8b88f4a0 100644 --- a/packages/email-nodemailer/src/index.ts +++ b/packages/email-nodemailer/src/index.ts @@ -6,18 +6,14 @@ import type { EmailAdapter } from 'payload/types' import nodemailer from 'nodemailer' import { InvalidConfiguration } from 'payload/errors' -type Email = { +export type NodemailerAdapterArgs = { defaultFromAddress: string defaultFromName: string - logMockCredentials?: boolean -} - -type EmailTransportOptions = Email & { + skipVerify?: boolean transport?: Transporter - transportOptions: SMTPConnection.Options + transportOptions?: SMTPConnection.Options } -export type NodemailerAdapterArgs = Email | EmailTransportOptions export type NodemailerAdapter = EmailAdapter /** @@ -58,8 +54,6 @@ async function buildEmail( } } - ensureConfigHasFrom(emailConfig) - // Create or extract transport let transport: Transporter if ('transport' in emailConfig && emailConfig.transport) { @@ -70,7 +64,10 @@ async function buildEmail( transport = await createMockAccount(emailConfig) } - await verifyTransport(transport) + if (emailConfig.skipVerify !== false) { + await verifyTransport(transport) + } + return { defaultFromAddress: emailConfig.defaultFromAddress, defaultFromName: emailConfig.defaultFromName, @@ -86,14 +83,6 @@ async function verifyTransport(transport: Transporter) { } } -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 */ @@ -116,12 +105,10 @@ async function createMockAccount(emailConfig?: NodemailerAdapterArgs) { 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}`) - } + console.info('E-mail configured with ethereal.email test account. ') + 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: unknown) { if (err instanceof Error) { From 8d52f1b2792a74a8d3902cd60b113ff7c58a5182 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 18 Apr 2024 02:26:44 -0400 Subject: [PATCH 07/14] chore: add payload to dev deps --- packages/email-nodemailer/package.json | 1 + packages/email-nodemailer/tsconfig.json | 4 ---- pnpm-lock.yaml | 6 +++--- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/email-nodemailer/package.json b/packages/email-nodemailer/package.json index f32b392455..d4afa95cea 100644 --- a/packages/email-nodemailer/package.json +++ b/packages/email-nodemailer/package.json @@ -53,6 +53,7 @@ "dist" ], "devDependencies": { + "payload": "workspace:*", "@types/nodemailer": "6.4.14" } } diff --git a/packages/email-nodemailer/tsconfig.json b/packages/email-nodemailer/tsconfig.json index 9e82b9e073..79ddab46b9 100644 --- a/packages/email-nodemailer/tsconfig.json +++ b/packages/email-nodemailer/tsconfig.json @@ -11,10 +11,6 @@ "exclude": [ "dist", "node_modules", - "src/**/*.spec.js", - "src/**/*.spec.jsx", - "src/**/*.spec.ts", - "src/**/*.spec.tsx" ], "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"], "references": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d53abbe89e..a4ba4ace6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -445,13 +445,13 @@ importers: nodemailer: specifier: 6.9.10 version: 6.9.10 - payload: - specifier: workspace:* - version: link:../payload devDependencies: '@types/nodemailer': specifier: 6.4.14 version: 6.4.14 + payload: + specifier: workspace:* + version: link:../payload packages/eslint-config-payload: dependencies: From f0198b62f39a6ce74fc8c8f57236d2b8e8416278 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 18 Apr 2024 11:59:03 -0400 Subject: [PATCH 08/14] feat: implement stdout email adapter, use if no adapter configured --- .../src/email/getStringifiedToAddress.ts | 23 +++++++++++++++++++ packages/payload/src/email/sendEmail.ts | 21 +++-------------- packages/payload/src/email/stdoutAdapter.ts | 23 +++++++++++++++++++ packages/payload/src/index.ts | 9 +++++++- 4 files changed, 57 insertions(+), 19 deletions(-) create mode 100644 packages/payload/src/email/getStringifiedToAddress.ts create mode 100644 packages/payload/src/email/stdoutAdapter.ts diff --git a/packages/payload/src/email/getStringifiedToAddress.ts b/packages/payload/src/email/getStringifiedToAddress.ts new file mode 100644 index 0000000000..878c5267d1 --- /dev/null +++ b/packages/payload/src/email/getStringifiedToAddress.ts @@ -0,0 +1,23 @@ +import type { SendMailOptions } from 'nodemailer' + +export const getStringifiedToAddress = (message: SendMailOptions): string | undefined => { + 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 + } + return stringifiedTo +} diff --git a/packages/payload/src/email/sendEmail.ts b/packages/payload/src/email/sendEmail.ts index a5022ea6e3..15a48b7f1e 100644 --- a/packages/payload/src/email/sendEmail.ts +++ b/packages/payload/src/email/sendEmail.ts @@ -2,30 +2,15 @@ import type { SendMailOptions } from 'nodemailer' import type { Payload } from '../types/index.js' +import { getStringifiedToAddress } from './getStringifiedToAddress.js' + export async function sendEmail(this: Payload, message: SendMailOptions): Promise { let result try { 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 - } + const stringifiedTo = getStringifiedToAddress(message) this.logger.error({ err, diff --git a/packages/payload/src/email/stdoutAdapter.ts b/packages/payload/src/email/stdoutAdapter.ts new file mode 100644 index 0000000000..4e218bf25c --- /dev/null +++ b/packages/payload/src/email/stdoutAdapter.ts @@ -0,0 +1,23 @@ +import type { SendMailOptions } from 'nodemailer' + +import type { Payload } from '../index.js' +import type { EmailAdapter } from './types.js' + +import { emailDefaults } from './defaults.js' +import { getStringifiedToAddress } from './getStringifiedToAddress.js' + +export type StdoutAdapter = EmailAdapter + +export const createStdoutAdapter = (payload: Payload) => { + const stdoutAdapter: StdoutAdapter = { + defaultFromAddress: emailDefaults.defaultFromAddress, + defaultFromName: emailDefaults.defaultFromName, + sendEmail: async (message) => { + const stringifiedTo = getStringifiedToAddress(message) + const res = `EMAIL NON-DELIVERY. To: '${stringifiedTo}', Subject: '${message.subject}'` + payload.logger.info({ msg: res }) + return Promise.resolve() + }, + } + return stdoutAdapter +} diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index e77dc65cba..f9720edab7 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -49,6 +49,7 @@ 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 { createStdoutAdapter } from './email/stdoutAdapter.js' import { fieldAffectsData } from './exports/types.js' import localGlobalOperations from './globals/operations/local/index.js' import flattenFields from './utilities/flattenTopLevelFields.js' @@ -366,8 +367,14 @@ export class BasePayload { // Load email adapter if (this.config.email instanceof Promise) { this.email = await this.config.email - } else { + } else if (this.config.email) { this.email = this.config.email + } else { + this.logger.warn( + `No email adapter provided. Email will be written to stdout. More info at https://payloadcms.com/docs/email/overview.`, + ) + + this.email = createStdoutAdapter(this) } this.sendEmail = this.email.sendEmail From 6e4135e790b98ee647cc7fb654aa219c1b587c2b Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 18 Apr 2024 13:36:48 -0400 Subject: [PATCH 09/14] test: add nodemailer adapter to email test config --- test/email/config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/email/config.ts b/test/email/config.ts index e767d737c9..434bbe843d 100644 --- a/test/email/config.ts +++ b/test/email/config.ts @@ -1,3 +1,4 @@ +import { createNodemailerAdapter } from '@payloadcms/email-nodemailer' import path from 'path' import { getFileByPath } from 'payload/uploads' import { fileURLToPath } from 'url' @@ -15,6 +16,7 @@ export default buildConfigWithDefaults({ // ...extend config here collections: [PostsCollection, MediaCollection], globals: [MenuGlobal], + email: createNodemailerAdapter(), onInit: async (payload) => { await payload.create({ collection: 'users', From 4acb1336552ff127f7856f3fcd1faf4c58902f78 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 18 Apr 2024 14:15:55 -0400 Subject: [PATCH 10/14] chore: export SendMailOptions --- packages/payload/src/config/types.ts | 2 +- packages/payload/src/email/types.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 6ed38693d2..eb5a7bd20d 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -16,7 +16,7 @@ import type { SanitizedCollectionConfig, } from '../collections/config/types.js' import type { DatabaseAdapterResult } from '../database/types.js' -import type { EmailAdapter } from '../email/types.js' +import type { EmailAdapter, SendMailOptions } from '../email/types.js' import type { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js' import type { Payload } from '../index.js' import type { PayloadRequest, Where } from '../types/index.js' diff --git a/packages/payload/src/email/types.ts b/packages/payload/src/email/types.ts index 0d4460bf60..6d3677ebc5 100644 --- a/packages/payload/src/email/types.ts +++ b/packages/payload/src/email/types.ts @@ -1,5 +1,7 @@ import type { SendMailOptions } from 'nodemailer' +export type { SendMailOptions } + export type EmailAdapter< TSendEmailOptions extends SendMailOptions, KSendEmailResponse = unknown, From 83c617b4528fcc71c3eae1fd00769b299c59fd25 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 18 Apr 2024 14:17:29 -0400 Subject: [PATCH 11/14] test: clean up email-nodemailer config --- test/email-nodemailer/config.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/test/email-nodemailer/config.ts b/test/email-nodemailer/config.ts index 1219f0acba..6cd51edc29 100644 --- a/test/email-nodemailer/config.ts +++ b/test/email-nodemailer/config.ts @@ -1,14 +1,8 @@ import { createNodemailerAdapter } from '@payloadcms/email-nodemailer' -import path from 'path' -import { getFileByPath } from 'payload/uploads' -import { fileURLToPath } from 'url' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { devUser } from '../credentials.js' -const filename = fileURLToPath(import.meta.url) -const dirname = path.dirname(filename) - export default buildConfigWithDefaults({ // ...extend config here collections: [], @@ -27,14 +21,6 @@ export default buildConfigWithDefaults({ subject: 'This was sent on init', }) - // Create image - const imageFilePath = path.resolve(dirname, '../uploads/image.png') - const imageFile = await getFileByPath(imageFilePath) - - await payload.create({ - collection: 'media', - data: {}, - file: imageFile, - }) + payload.logger.info({ msg: 'Email sent', email }) }, }) From 10819b8693188b6deb76729a22104badb281c11a Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Thu, 18 Apr 2024 15:43:10 -0400 Subject: [PATCH 12/14] chore: proper SendMailOptions export --- packages/email-nodemailer/src/index.ts | 4 ++-- packages/payload/src/exports/types.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/email-nodemailer/src/index.ts b/packages/email-nodemailer/src/index.ts index 9a8b88f4a0..cd326295fb 100644 --- a/packages/email-nodemailer/src/index.ts +++ b/packages/email-nodemailer/src/index.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ -import type { SendMailOptions, Transporter } from 'nodemailer' +import type { Transporter } from 'nodemailer' import type SMTPConnection from 'nodemailer/lib/smtp-connection' -import type { EmailAdapter } from 'payload/types' +import type { EmailAdapter, SendMailOptions } from 'payload/types' import nodemailer from 'nodemailer' import { InvalidConfiguration } from 'payload/errors' diff --git a/packages/payload/src/exports/types.ts b/packages/payload/src/exports/types.ts index 126a76166d..e66e919fa3 100644 --- a/packages/payload/src/exports/types.ts +++ b/packages/payload/src/exports/types.ts @@ -5,7 +5,7 @@ export type * from '../uploads/types.js' export type { DocumentPermissions, FieldPermissions } from '../auth/index.js' export type { MeOperationResult } from '../auth/operations/me.js' -export type { EmailAdapter } from '../email/types.js' +export type { EmailAdapter, SendMailOptions } from '../email/types.js' export type { CollapsedPreferences, From cbd155458970194dcfc8d2da9a2accc7d75955d3 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 22 Apr 2024 12:54:06 -0400 Subject: [PATCH 13/14] chore: adjust email pattern --- packages/email-nodemailer/src/index.ts | 19 +++++---- .../payload/src/auth/sendVerificationEmail.ts | 9 ++-- .../src/collections/operations/create.ts | 2 +- packages/payload/src/config/types.ts | 8 ++-- packages/payload/src/email/defaults.ts | 4 +- .../src/email/getStringifiedToAddress.ts | 4 +- packages/payload/src/email/sendEmail.ts | 5 +-- packages/payload/src/email/stdoutAdapter.ts | 28 +++++-------- packages/payload/src/email/types.ts | 33 +++++++++++---- packages/payload/src/exports/types.ts | 2 +- packages/payload/src/index.ts | 19 ++++----- packages/plugin-cloud/src/email.spec.ts | 9 +++- packages/plugin-cloud/src/email.ts | 6 +-- packages/plugin-cloud/src/plugin.spec.ts | 42 ++++++++++++------- 14 files changed, 109 insertions(+), 81 deletions(-) diff --git a/packages/email-nodemailer/src/index.ts b/packages/email-nodemailer/src/index.ts index cd326295fb..b734112daa 100644 --- a/packages/email-nodemailer/src/index.ts +++ b/packages/email-nodemailer/src/index.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import type { Transporter } from 'nodemailer' import type SMTPConnection from 'nodemailer/lib/smtp-connection' -import type { EmailAdapter, SendMailOptions } from 'payload/types' +import type { EmailAdapter } from 'payload/config' import nodemailer from 'nodemailer' import { InvalidConfiguration } from 'payload/errors' @@ -14,19 +14,19 @@ export type NodemailerAdapterArgs = { transportOptions?: SMTPConnection.Options } -export type NodemailerAdapter = EmailAdapter +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 ( +export const nodemailerAdapter = async ( args?: NodemailerAdapterArgs, ): Promise => { const { defaultFromAddress, defaultFromName, transport } = await buildEmail(args) - const adapter: NodemailerAdapter = { + const adapter: NodemailerAdapter = () => ({ defaultFromAddress, defaultFromName, sendEmail: async (message) => { @@ -35,14 +35,15 @@ export const createNodemailerAdapter = async ( ...message, }) }, - } - + }) return adapter } -async function buildEmail( - emailConfig?: NodemailerAdapterArgs, -): Promise<{ defaultFromAddress: string; defaultFromName: string; transport: Transporter }> { +async function buildEmail(emailConfig?: NodemailerAdapterArgs): Promise<{ + defaultFromAddress: string + defaultFromName: string + transport: Transporter +}> { if (!emailConfig) { const transport = await createMockAccount(emailConfig) if (!transport) throw new InvalidConfiguration('Unable to create Nodemailer test account.') diff --git a/packages/payload/src/auth/sendVerificationEmail.ts b/packages/payload/src/auth/sendVerificationEmail.ts index a76873c7a9..3d8bf91dbd 100644 --- a/packages/payload/src/auth/sendVerificationEmail.ts +++ b/packages/payload/src/auth/sendVerificationEmail.ts @@ -2,8 +2,7 @@ import { URL } from 'url' import type { Collection } from '../collections/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 { InitializedEmailAdapter } from '../email/types.js' import type { PayloadRequest } from '../types/index.js' import type { User, VerifyConfig } from './types.js' @@ -11,13 +10,13 @@ type Args = { collection: Collection config: SanitizedConfig disableEmail: boolean - email: EmailAdapter + email: InitializedEmailAdapter req: PayloadRequest token: string user: User } -async function sendVerificationEmail(args: Args): Promise { +export async function sendVerificationEmail(args: Args): Promise { // Verify token from e-mail const { collection: { config: collectionConfig }, @@ -74,5 +73,3 @@ async function sendVerificationEmail(args: Args): Promise { }) } } - -export default sendVerificationEmail diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 636c0b0f3e..824086bcae 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -12,7 +12,7 @@ import type { } from '../config/types.js' import executeAccess from '../../auth/executeAccess.js' -import sendVerificationEmail from '../../auth/sendVerificationEmail.js' +import { sendVerificationEmail } from '../../auth/sendVerificationEmail.js' import { registerLocalStrategy } from '../../auth/strategies/local/register.js' import { afterChange } from '../../fields/hooks/afterChange/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js' diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index eb5a7bd20d..21b4af3224 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -1,7 +1,7 @@ import type { I18nOptions, TFunction } from '@payloadcms/translations' import type { Options as ExpressFileUploadOptions } from 'express-fileupload' import type GraphQL from 'graphql' -import type { DestinationStream, LoggerOptions } from 'pino' +import type { DestinationStream, LoggerOptions, P } from 'pino' import type React from 'react' import type { default as sharp } from 'sharp' import type { DeepRequired } from 'ts-essentials' @@ -16,7 +16,7 @@ import type { SanitizedCollectionConfig, } from '../collections/config/types.js' import type { DatabaseAdapterResult } from '../database/types.js' -import type { EmailAdapter, SendMailOptions } from '../email/types.js' +import type { EmailAdapter, SendEmailOptions } from '../email/types.js' import type { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js' import type { Payload } from '../index.js' import type { PayloadRequest, Where } from '../types/index.js' @@ -531,7 +531,7 @@ export type Config = { * * @see https://payloadcms.com/docs/email/overview */ - email?: EmailAdapter | Promise> + email?: EmailAdapter | Promise /** Custom REST endpoints */ endpoints?: Endpoint[] /** @@ -690,3 +690,5 @@ export type EntityDescription = | EntityDescriptionFunction | Record | string + +export type { EmailAdapter, SendEmailOptions } diff --git a/packages/payload/src/email/defaults.ts b/packages/payload/src/email/defaults.ts index 2bb905ea5d..7ec42fc060 100644 --- a/packages/payload/src/email/defaults.ts +++ b/packages/payload/src/email/defaults.ts @@ -1,7 +1,7 @@ -import type { EmailAdapter } from './types.js' +import type { InitializedEmailAdapter } from './types.js' export const emailDefaults: Pick< - EmailAdapter, + InitializedEmailAdapter, 'defaultFromAddress' | 'defaultFromName' > = { defaultFromAddress: 'info@payloadcms.com', diff --git a/packages/payload/src/email/getStringifiedToAddress.ts b/packages/payload/src/email/getStringifiedToAddress.ts index 878c5267d1..c7612a2d70 100644 --- a/packages/payload/src/email/getStringifiedToAddress.ts +++ b/packages/payload/src/email/getStringifiedToAddress.ts @@ -1,6 +1,6 @@ -import type { SendMailOptions } from 'nodemailer' +import type { SendEmailOptions } from './types.js' -export const getStringifiedToAddress = (message: SendMailOptions): string | undefined => { +export const getStringifiedToAddress = (message: SendEmailOptions): string | undefined => { let stringifiedTo: string | undefined if (typeof message.to === 'string') { diff --git a/packages/payload/src/email/sendEmail.ts b/packages/payload/src/email/sendEmail.ts index 15a48b7f1e..6d0c65fbf9 100644 --- a/packages/payload/src/email/sendEmail.ts +++ b/packages/payload/src/email/sendEmail.ts @@ -1,10 +1,9 @@ -import type { SendMailOptions } from 'nodemailer' - import type { Payload } from '../types/index.js' +import type { SendEmailOptions } from './types.js' import { getStringifiedToAddress } from './getStringifiedToAddress.js' -export async function sendEmail(this: Payload, message: SendMailOptions): Promise { +export async function sendEmail(this: Payload, message: SendEmailOptions): Promise { let result try { diff --git a/packages/payload/src/email/stdoutAdapter.ts b/packages/payload/src/email/stdoutAdapter.ts index 4e218bf25c..122e0b9ab2 100644 --- a/packages/payload/src/email/stdoutAdapter.ts +++ b/packages/payload/src/email/stdoutAdapter.ts @@ -1,23 +1,15 @@ -import type { SendMailOptions } from 'nodemailer' - -import type { Payload } from '../index.js' import type { EmailAdapter } from './types.js' import { emailDefaults } from './defaults.js' import { getStringifiedToAddress } from './getStringifiedToAddress.js' -export type StdoutAdapter = EmailAdapter - -export const createStdoutAdapter = (payload: Payload) => { - const stdoutAdapter: StdoutAdapter = { - defaultFromAddress: emailDefaults.defaultFromAddress, - defaultFromName: emailDefaults.defaultFromName, - sendEmail: async (message) => { - const stringifiedTo = getStringifiedToAddress(message) - const res = `EMAIL NON-DELIVERY. To: '${stringifiedTo}', Subject: '${message.subject}'` - payload.logger.info({ msg: res }) - return Promise.resolve() - }, - } - return stdoutAdapter -} +export const stdoutAdapter: EmailAdapter = ({ payload }) => ({ + defaultFromAddress: emailDefaults.defaultFromAddress, + defaultFromName: emailDefaults.defaultFromName, + sendEmail: async (message) => { + const stringifiedTo = getStringifiedToAddress(message) + const res = `EMAIL NON-DELIVERY. To: '${stringifiedTo}', Subject: '${message.subject}'` + payload.logger.info({ msg: res }) + return Promise.resolve() + }, +}) diff --git a/packages/payload/src/email/types.ts b/packages/payload/src/email/types.ts index 6d3677ebc5..6d24f72516 100644 --- a/packages/payload/src/email/types.ts +++ b/packages/payload/src/email/types.ts @@ -1,12 +1,31 @@ -import type { SendMailOptions } from 'nodemailer' +import type { SendMailOptions as NodemailerSendMailOptions } from 'nodemailer' -export type { SendMailOptions } +import type { Payload } from '../types/index.js' -export type EmailAdapter< - TSendEmailOptions extends SendMailOptions, - KSendEmailResponse = unknown, -> = { +type Prettify = { + [K in keyof T]: T[K] +} & NonNullable + +/** + * Options for sending an email. Allows access to the PayloadRequest object + */ +export type SendEmailOptions = Prettify + +/** + * Email adapter after it has been initialized. This is used internally by Payload. + */ +export type InitializedEmailAdapter = ReturnType< + EmailAdapter +> + +/** + * Email adapter interface. Allows a generic type for the response of the sendEmail method. + * + * This is the interface to use if you are creating a new email adapter. + */ + +export type EmailAdapter = ({ payload }: { payload: Payload }) => { defaultFromAddress: string defaultFromName: string - sendEmail: (message: TSendEmailOptions) => Promise + sendEmail: (message: SendEmailOptions) => Promise } diff --git a/packages/payload/src/exports/types.ts b/packages/payload/src/exports/types.ts index e66e919fa3..3dfdec3999 100644 --- a/packages/payload/src/exports/types.ts +++ b/packages/payload/src/exports/types.ts @@ -5,7 +5,7 @@ export type * from '../uploads/types.js' export type { DocumentPermissions, FieldPermissions } from '../auth/index.js' export type { MeOperationResult } from '../auth/operations/me.js' -export type { EmailAdapter, SendMailOptions } from '../email/types.js' +export type { EmailAdapter as PayloadEmailAdapter, SendEmailOptions } from '../email/types.js' export type { CollapsedPreferences, diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index f9720edab7..33d673243c 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1,6 +1,5 @@ import type { ExecutionResult, GraphQLSchema, ValidationRule } from 'graphql' import type { OperationArgs, Request as graphQLRequest } from 'graphql-http' -import type { SendMailOptions } from 'nodemailer' import type pino from 'pino' import crypto from 'crypto' @@ -35,7 +34,7 @@ import type { } from './collections/operations/local/update.js' import type { InitOptions, SanitizedConfig } from './config/types.js' import type { BaseDatabaseAdapter, PaginatedDocs } from './database/types.js' -import type { EmailAdapter } from './email/types.js' +import type { InitializedEmailAdapter } 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,7 +48,7 @@ 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 { createStdoutAdapter } from './email/stdoutAdapter.js' +import { stdoutAdapter } from './email/stdoutAdapter.js' import { fieldAffectsData } from './exports/types.js' import localGlobalOperations from './globals/operations/local/index.js' import flattenFields from './utilities/flattenTopLevelFields.js' @@ -102,8 +101,7 @@ export class BasePayload { return duplicate(this, options) } - // Do these types need to be injected via GeneratedTypes? - email: EmailAdapter + email: InitializedEmailAdapter // TODO: re-implement or remove? // errorHandler: ErrorHandler @@ -252,7 +250,7 @@ export class BasePayload { secret: string - sendEmail: (message: SendMailOptions) => Promise + sendEmail: InitializedEmailAdapter['sendEmail'] types: { arrayTypes: any @@ -366,18 +364,19 @@ export class BasePayload { // Load email adapter if (this.config.email instanceof Promise) { - this.email = await this.config.email + const awaitedAdapter = await this.config.email + this.email = awaitedAdapter({ payload: this }) } else if (this.config.email) { - this.email = this.config.email + this.email = this.config.email({ payload: this }) } else { this.logger.warn( `No email adapter provided. Email will be written to stdout. More info at https://payloadcms.com/docs/email/overview.`, ) - this.email = createStdoutAdapter(this) + this.email = stdoutAdapter({ payload: this }) } - this.sendEmail = this.email.sendEmail + this.sendEmail = this.email['sendEmail'] serverInitTelemetry(this) diff --git a/packages/plugin-cloud/src/email.spec.ts b/packages/plugin-cloud/src/email.spec.ts index fa028b67ff..36cfa659ff 100644 --- a/packages/plugin-cloud/src/email.spec.ts +++ b/packages/plugin-cloud/src/email.spec.ts @@ -1,4 +1,5 @@ import type { Config } from 'payload/config' +import type { Payload } from 'payload' import nodemailer from 'nodemailer' import { defaults } from 'payload/config' @@ -12,6 +13,8 @@ describe('email', () => { const apiKey = 'test' let createTransportSpy: jest.SpyInstance + const mockedPayload: Payload = jest.fn() as unknown as Payload + beforeEach(() => { defaultConfig = defaults as Config @@ -71,8 +74,10 @@ describe('email', () => { defaultFromAddress, }) - expect(email.defaultFromName).toEqual(defaultFromName) - expect(email.defaultFromAddress).toEqual(defaultFromAddress) + const initializedEmail = email({ payload: mockedPayload }) + + expect(initializedEmail.defaultFromName).toEqual(defaultFromName) + expect(initializedEmail.defaultFromAddress).toEqual(defaultFromAddress) }) }) }) diff --git a/packages/plugin-cloud/src/email.ts b/packages/plugin-cloud/src/email.ts index 88be904302..92eb43287e 100644 --- a/packages/plugin-cloud/src/email.ts +++ b/packages/plugin-cloud/src/email.ts @@ -1,6 +1,6 @@ import type { NodemailerAdapter } from '@payloadcms/email-nodemailer' -import { createNodemailerAdapter } from '@payloadcms/email-nodemailer' +import { nodemailerAdapter } from '@payloadcms/email-nodemailer' import nodemailer from 'nodemailer' import type { PayloadCloudEmailOptions } from './types.js' @@ -12,7 +12,7 @@ export const payloadCloudEmail = async ( return undefined } - if (!args.apiKey) throw new Error('apiKey must be provided to use Payload Cloud Email ') + if (!args.apiKey) throw new Error('apiKey must be provided to use Payload Cloud Email') if (!args.defaultDomain) throw new Error('defaultDomain must be provided to use Payload Cloud Email') @@ -43,7 +43,7 @@ export const payloadCloudEmail = async ( const defaultFromAddress = args.defaultFromAddress || `cms@${customDomains.length ? customDomains[0] : defaultDomain}` - const emailAdapter = await createNodemailerAdapter({ + const emailAdapter = await nodemailerAdapter({ defaultFromAddress, defaultFromName, skipVerify, diff --git a/packages/plugin-cloud/src/plugin.spec.ts b/packages/plugin-cloud/src/plugin.spec.ts index 94d22b5eeb..7a6f9b05f4 100644 --- a/packages/plugin-cloud/src/plugin.spec.ts +++ b/packages/plugin-cloud/src/plugin.spec.ts @@ -1,10 +1,13 @@ import type { Config } from 'payload/config' +import type { Payload } from 'payload' import nodemailer from 'nodemailer' import { defaults } from 'payload/config' import { payloadCloud } from './plugin.js' -import { createNodemailerAdapter } from '@payloadcms/email-nodemailer' +import { NodemailerAdapter, nodemailerAdapter } from '@payloadcms/email-nodemailer' + +const mockedPayload: Payload = jest.fn() as unknown as Payload describe('plugin', () => { let createTransportSpy: jest.SpyInstance @@ -12,13 +15,16 @@ describe('plugin', () => { const skipVerify = true beforeEach(() => { - createTransportSpy = jest.spyOn(nodemailer, 'createTransport').mockImplementation(() => { + createTransportSpy = jest.spyOn(nodemailer, 'createTransport').mockImplementationOnce(() => { return { verify: jest.fn(), + transporter: { + name: 'Nodemailer - SMTP', + }, } as unknown as ReturnType }) - const createTestAccountSpy = jest.spyOn(nodemailer, 'createTestAccount').mockResolvedValue({ + const createTestAccountSpy = jest.spyOn(nodemailer, 'createTestAccount').mockResolvedValueOnce({ pass: 'password', user: 'user', web: 'ethereal.email', @@ -67,7 +73,11 @@ describe('plugin', () => { const plugin = payloadCloud() const config = await plugin(createConfig()) - assertCloudEmail(config) + expect(createTransportSpy).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'smtp.resend.com', + }), + ) }) // eslint-disable-next-line jest/expect-expect @@ -102,7 +112,7 @@ describe('plugin', () => { }) const configWithTransport = createConfig({ - email: await createNodemailerAdapter({ + email: await nodemailerAdapter({ defaultFromAddress: 'test@test.com', defaultFromName: 'Test', transport: existingTransport, @@ -124,7 +134,7 @@ describe('plugin', () => { const defaultFromName = 'Test' const defaultFromAddress = 'test@test.com' const configWithPartialEmail = createConfig({ - email: await createNodemailerAdapter({ + email: await nodemailerAdapter({ defaultFromAddress, defaultFromName, skipVerify, @@ -133,12 +143,18 @@ describe('plugin', () => { const plugin = payloadCloud() const config = await plugin(configWithPartialEmail) - const emailConfig = config.email as Awaited> + const emailConfig = config.email as Awaited> - expect(emailConfig.defaultFromName).toEqual(defaultFromName) - expect(emailConfig.defaultFromAddress).toEqual(defaultFromAddress) + const initializedEmail = emailConfig({ payload: mockedPayload }) - assertCloudEmail(config) + expect(initializedEmail.defaultFromName).toEqual(defaultFromName) + expect(initializedEmail.defaultFromAddress).toEqual(defaultFromAddress) + + expect(createTransportSpy).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'smtp.resend.com', + }), + ) }) }) }) @@ -152,10 +168,8 @@ function assertNoCloudStorage(config: Config) { expect(config.upload?.useTempFiles).toBeFalsy() } -function assertCloudEmail(config: Config) { - expect( - config.email && 'sendEmail' in config.email && typeof config.email.sendEmail === 'function', - ).toBe(true) +async function assertCloudEmail(config: Config) { + expect(config.email && 'name' in config.email).toStrictEqual('Nodemailer - SMTP') } function createConfig(overrides?: Partial): Config { From 951e9fd7f2d4e925969e773e4c29715fc400b5af Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 22 Apr 2024 14:13:38 -0400 Subject: [PATCH 14/14] test: email e2e updated nodemailer usage --- test/email/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/email/config.ts b/test/email/config.ts index 434bbe843d..36759cbdd1 100644 --- a/test/email/config.ts +++ b/test/email/config.ts @@ -1,4 +1,4 @@ -import { createNodemailerAdapter } from '@payloadcms/email-nodemailer' +import { nodemailerAdapter } from '@payloadcms/email-nodemailer' import path from 'path' import { getFileByPath } from 'payload/uploads' import { fileURLToPath } from 'url' @@ -16,7 +16,7 @@ export default buildConfigWithDefaults({ // ...extend config here collections: [PostsCollection, MediaCollection], globals: [MenuGlobal], - email: createNodemailerAdapter(), + email: nodemailerAdapter(), onInit: async (payload) => { await payload.create({ collection: 'users',