diff --git a/package.json b/package.json index 2f729abbf7..b1dee56e42 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..d4afa95cea --- /dev/null +++ b/packages/email-nodemailer/package.json @@ -0,0 +1,59 @@ +{ + "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": { + "payload": "workspace:*", + "@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..b734112daa --- /dev/null +++ b/packages/email-nodemailer/src/index.ts @@ -0,0 +1,123 @@ +/* eslint-disable no-console */ +import type { Transporter } from 'nodemailer' +import type SMTPConnection from 'nodemailer/lib/smtp-connection' +import type { EmailAdapter } from 'payload/config' + +import nodemailer from 'nodemailer' +import { InvalidConfiguration } from 'payload/errors' + +export type NodemailerAdapterArgs = { + defaultFromAddress: string + defaultFromName: string + skipVerify?: boolean + transport?: Transporter + transportOptions?: SMTPConnection.Options +} + +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 nodemailerAdapter = 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) { + const transport = await createMockAccount(emailConfig) + if (!transport) throw new InvalidConfiguration('Unable to create Nodemailer test account.') + + return { + defaultFromAddress: 'info@payloadcms.com', + defaultFromName: 'Payload', + transport, + } + } + + // 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) + } + + if (emailConfig.skipVerify !== false) { + 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.' }) + } +} + +/** + * 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 + + 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) { + 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 new file mode 100644 index 0000000000..79ddab46b9 --- /dev/null +++ b/packages/email-nodemailer/tsconfig.json @@ -0,0 +1,19 @@ +{ + "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. */, + "strict": true, + }, + "exclude": [ + "dist", + "node_modules", + ], + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"], + "references": [ + { "path": "../payload" }, + ] +} diff --git a/packages/payload/package.json b/packages/payload/package.json index 4f6ce26ee2..06bf7d2e7a 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -60,7 +60,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/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..3d8bf91dbd 100644 --- a/packages/payload/src/auth/sendVerificationEmail.ts +++ b/packages/payload/src/auth/sendVerificationEmail.ts @@ -1,8 +1,8 @@ import { URL } from 'url' import type { Collection } from '../collections/config/types.js' -import type { EmailOptions, SanitizedConfig } from '../config/types.js' -import type { Payload } from '../index.js' +import type { SanitizedConfig } from '../config/types.js' +import type { InitializedEmailAdapter } from '../email/types.js' import type { PayloadRequest } from '../types/index.js' import type { User, VerifyConfig } from './types.js' @@ -10,22 +10,20 @@ type Args = { collection: Collection config: SanitizedConfig disableEmail: boolean - emailOptions: EmailOptions + email: InitializedEmailAdapter req: PayloadRequest - sendEmail: Payload['sendEmail'] 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 }, config, disableEmail, - emailOptions, + email, req, - sendEmail, token, user, } = args @@ -67,13 +65,11 @@ 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, }) } } - -export default sendVerificationEmail diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index fd67ed628c..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' @@ -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 @@ -573,11 +529,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 | Promise /** Custom REST endpoints */ endpoints?: Endpoint[] /** @@ -736,3 +692,5 @@ export type EntityDescription = | EntityDescriptionFunction | Record | string + +export type { EmailAdapter, SendEmailOptions } 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..7ec42fc060 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 { InitializedEmailAdapter } from './types.js' -export const defaults: EmailOptions = { - fromAddress: 'info@payloadcms.com', - fromName: 'Payload', +export const emailDefaults: Pick< + InitializedEmailAdapter, + 'defaultFromAddress' | 'defaultFromName' +> = { + defaultFromAddress: 'info@payloadcms.com', + defaultFromName: 'Payload', } diff --git a/packages/payload/src/email/getStringifiedToAddress.ts b/packages/payload/src/email/getStringifiedToAddress.ts new file mode 100644 index 0000000000..c7612a2d70 --- /dev/null +++ b/packages/payload/src/email/getStringifiedToAddress.ts @@ -0,0 +1,23 @@ +import type { SendEmailOptions } from './types.js' + +export const getStringifiedToAddress = (message: SendEmailOptions): 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/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..6d0c65fbf9 100644 --- a/packages/payload/src/email/sendEmail.ts +++ b/packages/payload/src/email/sendEmail.ts @@ -1,13 +1,20 @@ -import type { SendMailOptions } from 'nodemailer' +import type { Payload } from '../types/index.js' +import type { SendEmailOptions } from './types.js' -export default async function sendEmail(message: SendMailOptions): Promise { +import { getStringifiedToAddress } from './getStringifiedToAddress.js' + +export async function sendEmail(this: Payload, message: SendEmailOptions): 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) { + const stringifiedTo = getStringifiedToAddress(message) + + 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/stdoutAdapter.ts b/packages/payload/src/email/stdoutAdapter.ts new file mode 100644 index 0000000000..122e0b9ab2 --- /dev/null +++ b/packages/payload/src/email/stdoutAdapter.ts @@ -0,0 +1,15 @@ +import type { EmailAdapter } from './types.js' + +import { emailDefaults } from './defaults.js' +import { getStringifiedToAddress } from './getStringifiedToAddress.js' + +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 72085167e8..6d24f72516 100644 --- a/packages/payload/src/email/types.ts +++ b/packages/payload/src/email/types.ts @@ -1,21 +1,31 @@ -import type { TestAccount, Transporter } from 'nodemailer' -import type Mail from 'nodemailer/lib/mailer' -import type SMTPConnection from 'nodemailer/lib/smtp-connection' +import type { SendMailOptions as NodemailerSendMailOptions } from 'nodemailer' -export type Message = { - from: string - html: string - subject: string - to: string -} +import type { Payload } from '../types/index.js' -export type MockEmailHandler = { account: TestAccount; transport: Transporter } -export type BuildEmailResult = Promise< - | { - fromAddress: string - fromName: string - transport: Mail - transportOptions?: SMTPConnection.Options - } - | MockEmailHandler +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: SendEmailOptions) => Promise +} diff --git a/packages/payload/src/exports/types.ts b/packages/payload/src/exports/types.ts index 7fcdf1b579..3dfdec3999 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 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 c9f0de0bb5..412f4eb861 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' @@ -34,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 { 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' @@ -50,9 +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 buildEmail from './email/build.js' -import { defaults as emailDefaults } from './email/defaults.js' -import sendEmail from './email/sendEmail.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' @@ -116,9 +113,7 @@ export class BasePayload { return duplicate(this, options) } - email: BuildEmailResult - - emailOptions: EmailOptions + email: InitializedEmailAdapter // TODO: re-implement or remove? // errorHandler: ErrorHandler @@ -267,7 +262,7 @@ export class BasePayload { secret: string - sendEmail: (message: SendMailOptions) => Promise + sendEmail: InitializedEmailAdapter['sendEmail'] types: { arrayTypes: any @@ -379,17 +374,21 @@ 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) { + // Load email adapter + if (this.config.email instanceof Promise) { + const awaitedAdapter = await this.config.email + this.email = awaitedAdapter({ payload: this }) + } else if (this.config.email) { + this.email = this.config.email({ payload: this }) + } else { this.logger.warn( - 'Email options provided in both init options and config. Using init options.', + `No email adapter provided. Email will be written to stdout. More info at https://payloadcms.com/docs/email/overview.`, ) + + this.email = stdoutAdapter({ payload: this }) } - 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/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..36cfa659ff 100644 --- a/packages/plugin-cloud/src/email.spec.ts +++ b/packages/plugin-cloud/src/email.spec.ts @@ -1,4 +1,6 @@ import type { Config } from 'payload/config' +import type { Payload } from 'payload' +import nodemailer from 'nodemailer' import { defaults } from 'payload/config' @@ -6,18 +8,36 @@ import { payloadCloudEmail } from './email.js' describe('email', () => { let defaultConfig: Config + const skipVerify = true + const defaultDomain = 'test.com' + const apiKey = 'test' + let createTransportSpy: jest.SpyInstance + + const mockedPayload: Payload = jest.fn() as unknown as Payload 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 +49,35 @@ 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) + 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 05c52344a7..92eb43287e 100644 --- a/packages/plugin-cloud/src/email.ts +++ b/packages/plugin-cloud/src/email.ts @@ -1,19 +1,31 @@ -import type { EmailTransport } from 'payload/config' +import type { NodemailerAdapter } from '@payloadcms/email-nodemailer' +import { nodemailerAdapter } 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 } - 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') - 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 nodemailerAdapter({ + 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..7a6f9b05f4 100644 --- a/packages/plugin-cloud/src/plugin.spec.ts +++ b/packages/plugin-cloud/src/plugin.spec.ts @@ -1,19 +1,44 @@ 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 { NodemailerAdapter, nodemailerAdapter } from '@payloadcms/email-nodemailer' + +const mockedPayload: Payload = jest.fn() as unknown as Payload describe('plugin', () => { + let createTransportSpy: jest.SpyInstance + + const skipVerify = true + + beforeEach(() => { + createTransportSpy = jest.spyOn(nodemailer, 'createTransport').mockImplementationOnce(() => { + return { + verify: jest.fn(), + transporter: { + name: 'Nodemailer - SMTP', + }, + } as unknown as ReturnType + }) + + const createTestAccountSpy = jest.spyOn(nodemailer, 'createTestAccount').mockResolvedValueOnce({ + 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 +51,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 +69,39 @@ 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) + expect(createTransportSpy).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'smtp.resend.com', + }), + ) }) // 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,38 +112,49 @@ describe('plugin', () => { }) const configWithTransport = createConfig({ - email: { - fromAddress: 'test@test.com', - fromName: 'Test', + email: await nodemailerAdapter({ + 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 nodemailerAdapter({ + 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) + 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', + }), + ) }) }) }) @@ -126,20 +168,8 @@ function assertNoCloudStorage(config: Config) { expect(config.upload?.useTempFiles).toBeFalsy() } -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') - } +async function assertCloudEmail(config: Config) { + expect(config.email && 'name' in config.email).toStrictEqual('Nodemailer - SMTP') } 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 3cf71f0444..9e408e406d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -443,6 +443,19 @@ importers: specifier: workspace:* version: link:../payload + packages/email-nodemailer: + dependencies: + nodemailer: + specifier: 6.9.10 + version: 6.9.10 + devDependencies: + '@types/nodemailer': + specifier: 6.4.14 + version: 6.4.14 + payload: + specifier: workspace:* + version: link:../payload + packages/eslint-config-payload: dependencies: '@types/eslint': @@ -794,9 +807,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 @@ -963,6 +973,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 @@ -1527,6 +1540,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..6cd51edc29 --- /dev/null +++ b/test/email-nodemailer/config.ts @@ -0,0 +1,26 @@ +import { createNodemailerAdapter } from '@payloadcms/email-nodemailer' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { devUser } from '../credentials.js' + +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', + }) + + payload.logger.info({ msg: 'Email sent', email }) + }, +}) 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 e767d737c9..36759cbdd1 100644 --- a/test/email/config.ts +++ b/test/email/config.ts @@ -1,3 +1,4 @@ +import { nodemailerAdapter } 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: nodemailerAdapter(), onInit: async (payload) => { await payload.create({ collection: 'users', 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:*",