From cbd155458970194dcfc8d2da9a2accc7d75955d3 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Mon, 22 Apr 2024 12:54:06 -0400 Subject: [PATCH] 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 cd326295f..b734112da 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 a76873c7a..3d8bf91db 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 636c0b0f3..824086bca 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 eb5a7bd20..21b4af322 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 2bb905ea5..7ec42fc06 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 878c5267d..c7612a2d7 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 15a48b7f1..6d0c65fbf 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 4e218bf25..122e0b9ab 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 6d3677ebc..6d24f7251 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 e66e919fa..3dfdec399 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 f9720edab..33d673243 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 fa028b67f..36cfa659f 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 88be90430..92eb43287 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 94d22b5ee..7a6f9b05f 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 {