chore: adjust email pattern

This commit is contained in:
Elliot DeNolf
2024-04-22 12:54:06 -04:00
parent 10819b8693
commit cbd1554589
14 changed files with 109 additions and 81 deletions

View File

@@ -1,7 +1,7 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import type { Transporter } from 'nodemailer' import type { Transporter } from 'nodemailer'
import type SMTPConnection from 'nodemailer/lib/smtp-connection' 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 nodemailer from 'nodemailer'
import { InvalidConfiguration } from 'payload/errors' import { InvalidConfiguration } from 'payload/errors'
@@ -14,19 +14,19 @@ export type NodemailerAdapterArgs = {
transportOptions?: SMTPConnection.Options transportOptions?: SMTPConnection.Options
} }
export type NodemailerAdapter = EmailAdapter<SendMailOptions, unknown> export type NodemailerAdapter = EmailAdapter<unknown>
/** /**
* Creates an email adapter using nodemailer * Creates an email adapter using nodemailer
* *
* If no email configuration is provided, an ethereal email test account is returned * If no email configuration is provided, an ethereal email test account is returned
*/ */
export const createNodemailerAdapter = async ( export const nodemailerAdapter = async (
args?: NodemailerAdapterArgs, args?: NodemailerAdapterArgs,
): Promise<NodemailerAdapter> => { ): Promise<NodemailerAdapter> => {
const { defaultFromAddress, defaultFromName, transport } = await buildEmail(args) const { defaultFromAddress, defaultFromName, transport } = await buildEmail(args)
const adapter: NodemailerAdapter = { const adapter: NodemailerAdapter = () => ({
defaultFromAddress, defaultFromAddress,
defaultFromName, defaultFromName,
sendEmail: async (message) => { sendEmail: async (message) => {
@@ -35,14 +35,15 @@ export const createNodemailerAdapter = async (
...message, ...message,
}) })
}, },
} })
return adapter return adapter
} }
async function buildEmail( async function buildEmail(emailConfig?: NodemailerAdapterArgs): Promise<{
emailConfig?: NodemailerAdapterArgs, defaultFromAddress: string
): Promise<{ defaultFromAddress: string; defaultFromName: string; transport: Transporter }> { defaultFromName: string
transport: Transporter
}> {
if (!emailConfig) { if (!emailConfig) {
const transport = await createMockAccount(emailConfig) const transport = await createMockAccount(emailConfig)
if (!transport) throw new InvalidConfiguration('Unable to create Nodemailer test account.') if (!transport) throw new InvalidConfiguration('Unable to create Nodemailer test account.')

View File

@@ -2,8 +2,7 @@ import { URL } from 'url'
import type { Collection } from '../collections/config/types.js' import type { Collection } from '../collections/config/types.js'
import type { SanitizedConfig } from '../config/types.js' import type { SanitizedConfig } from '../config/types.js'
import type { EmailAdapter } from '../email/types.js' import type { InitializedEmailAdapter } from '../email/types.js'
import type { Payload } from '../index.js'
import type { PayloadRequest } from '../types/index.js' import type { PayloadRequest } from '../types/index.js'
import type { User, VerifyConfig } from './types.js' import type { User, VerifyConfig } from './types.js'
@@ -11,13 +10,13 @@ type Args = {
collection: Collection collection: Collection
config: SanitizedConfig config: SanitizedConfig
disableEmail: boolean disableEmail: boolean
email: EmailAdapter<any, unknown> email: InitializedEmailAdapter
req: PayloadRequest req: PayloadRequest
token: string token: string
user: User user: User
} }
async function sendVerificationEmail(args: Args): Promise<void> { export async function sendVerificationEmail(args: Args): Promise<void> {
// Verify token from e-mail // Verify token from e-mail
const { const {
collection: { config: collectionConfig }, collection: { config: collectionConfig },
@@ -74,5 +73,3 @@ async function sendVerificationEmail(args: Args): Promise<void> {
}) })
} }
} }
export default sendVerificationEmail

View File

@@ -12,7 +12,7 @@ import type {
} from '../config/types.js' } from '../config/types.js'
import executeAccess from '../../auth/executeAccess.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 { registerLocalStrategy } from '../../auth/strategies/local/register.js'
import { afterChange } from '../../fields/hooks/afterChange/index.js' import { afterChange } from '../../fields/hooks/afterChange/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js' import { afterRead } from '../../fields/hooks/afterRead/index.js'

View File

@@ -1,7 +1,7 @@
import type { I18nOptions, TFunction } from '@payloadcms/translations' import type { I18nOptions, TFunction } from '@payloadcms/translations'
import type { Options as ExpressFileUploadOptions } from 'express-fileupload' import type { Options as ExpressFileUploadOptions } from 'express-fileupload'
import type GraphQL from 'graphql' import type GraphQL from 'graphql'
import type { DestinationStream, LoggerOptions } from 'pino' import type { DestinationStream, LoggerOptions, P } from 'pino'
import type React from 'react' import type React from 'react'
import type { default as sharp } from 'sharp' import type { default as sharp } from 'sharp'
import type { DeepRequired } from 'ts-essentials' import type { DeepRequired } from 'ts-essentials'
@@ -16,7 +16,7 @@ import type {
SanitizedCollectionConfig, SanitizedCollectionConfig,
} from '../collections/config/types.js' } from '../collections/config/types.js'
import type { DatabaseAdapterResult } from '../database/types.js' import type { DatabaseAdapterResult } from '../database/types.js'
import type { EmailAdapter, 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 { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js'
import type { Payload } from '../index.js' import type { Payload } from '../index.js'
import type { PayloadRequest, Where } from '../types/index.js' import type { PayloadRequest, Where } from '../types/index.js'
@@ -531,7 +531,7 @@ export type Config = {
* *
* @see https://payloadcms.com/docs/email/overview * @see https://payloadcms.com/docs/email/overview
*/ */
email?: EmailAdapter<any, unknown> | Promise<EmailAdapter<any, unknown>> email?: EmailAdapter | Promise<EmailAdapter>
/** Custom REST endpoints */ /** Custom REST endpoints */
endpoints?: Endpoint[] endpoints?: Endpoint[]
/** /**
@@ -690,3 +690,5 @@ export type EntityDescription =
| EntityDescriptionFunction | EntityDescriptionFunction
| Record<string, string> | Record<string, string>
| string | string
export type { EmailAdapter, SendEmailOptions }

View File

@@ -1,7 +1,7 @@
import type { EmailAdapter } from './types.js' import type { InitializedEmailAdapter } from './types.js'
export const emailDefaults: Pick< export const emailDefaults: Pick<
EmailAdapter<any, unknown>, InitializedEmailAdapter,
'defaultFromAddress' | 'defaultFromName' 'defaultFromAddress' | 'defaultFromName'
> = { > = {
defaultFromAddress: 'info@payloadcms.com', defaultFromAddress: 'info@payloadcms.com',

View File

@@ -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 let stringifiedTo: string | undefined
if (typeof message.to === 'string') { if (typeof message.to === 'string') {

View File

@@ -1,10 +1,9 @@
import type { SendMailOptions } from 'nodemailer'
import type { Payload } from '../types/index.js' import type { Payload } from '../types/index.js'
import type { SendEmailOptions } from './types.js'
import { getStringifiedToAddress } from './getStringifiedToAddress.js' import { getStringifiedToAddress } from './getStringifiedToAddress.js'
export async function sendEmail(this: Payload, message: SendMailOptions): Promise<unknown> { export async function sendEmail(this: Payload, message: SendEmailOptions): Promise<unknown> {
let result let result
try { try {

View File

@@ -1,15 +1,9 @@
import type { SendMailOptions } from 'nodemailer'
import type { Payload } from '../index.js'
import type { EmailAdapter } from './types.js' import type { EmailAdapter } from './types.js'
import { emailDefaults } from './defaults.js' import { emailDefaults } from './defaults.js'
import { getStringifiedToAddress } from './getStringifiedToAddress.js' import { getStringifiedToAddress } from './getStringifiedToAddress.js'
export type StdoutAdapter = EmailAdapter<SendMailOptions, void> export const stdoutAdapter: EmailAdapter<void> = ({ payload }) => ({
export const createStdoutAdapter = (payload: Payload) => {
const stdoutAdapter: StdoutAdapter = {
defaultFromAddress: emailDefaults.defaultFromAddress, defaultFromAddress: emailDefaults.defaultFromAddress,
defaultFromName: emailDefaults.defaultFromName, defaultFromName: emailDefaults.defaultFromName,
sendEmail: async (message) => { sendEmail: async (message) => {
@@ -18,6 +12,4 @@ export const createStdoutAdapter = (payload: Payload) => {
payload.logger.info({ msg: res }) payload.logger.info({ msg: res })
return Promise.resolve() return Promise.resolve()
}, },
} })
return stdoutAdapter
}

View File

@@ -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< type Prettify<T> = {
TSendEmailOptions extends SendMailOptions, [K in keyof T]: T[K]
KSendEmailResponse = unknown, } & NonNullable<unknown>
> = {
/**
* Options for sending an email. Allows access to the PayloadRequest object
*/
export type SendEmailOptions = Prettify<NodemailerSendMailOptions>
/**
* Email adapter after it has been initialized. This is used internally by Payload.
*/
export type InitializedEmailAdapter<TSendEmailResponse = unknown> = ReturnType<
EmailAdapter<TSendEmailResponse>
>
/**
* 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<TSendEmailResponse = unknown> = ({ payload }: { payload: Payload }) => {
defaultFromAddress: string defaultFromAddress: string
defaultFromName: string defaultFromName: string
sendEmail: (message: TSendEmailOptions) => Promise<KSendEmailResponse> sendEmail: (message: SendEmailOptions) => Promise<TSendEmailResponse>
} }

View File

@@ -5,7 +5,7 @@ export type * from '../uploads/types.js'
export type { DocumentPermissions, FieldPermissions } from '../auth/index.js' export type { DocumentPermissions, FieldPermissions } from '../auth/index.js'
export type { MeOperationResult } from '../auth/operations/me.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 { export type {
CollapsedPreferences, CollapsedPreferences,

View File

@@ -1,6 +1,5 @@
import type { ExecutionResult, GraphQLSchema, ValidationRule } from 'graphql' import type { ExecutionResult, GraphQLSchema, ValidationRule } from 'graphql'
import type { OperationArgs, Request as graphQLRequest } from 'graphql-http' import type { OperationArgs, Request as graphQLRequest } from 'graphql-http'
import type { SendMailOptions } from 'nodemailer'
import type pino from 'pino' import type pino from 'pino'
import crypto from 'crypto' import crypto from 'crypto'
@@ -35,7 +34,7 @@ import type {
} from './collections/operations/local/update.js' } from './collections/operations/local/update.js'
import type { InitOptions, SanitizedConfig } from './config/types.js' import type { InitOptions, SanitizedConfig } from './config/types.js'
import type { BaseDatabaseAdapter, PaginatedDocs } from './database/types.js' import type { BaseDatabaseAdapter, PaginatedDocs } from './database/types.js'
import type { 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 { TypeWithID as GlobalTypeWithID, Globals } from './globals/config/types.js'
import type { Options as FindGlobalOptions } from './globals/operations/local/findOne.js' import type { Options as FindGlobalOptions } from './globals/operations/local/findOne.js'
import type { Options as FindGlobalVersionByIDOptions } from './globals/operations/local/findVersionByID.js' import type { Options as FindGlobalVersionByIDOptions } from './globals/operations/local/findVersionByID.js'
@@ -49,7 +48,7 @@ import { APIKeyAuthentication } from './auth/strategies/apiKey.js'
import { JWTAuthentication } from './auth/strategies/jwt.js' import { JWTAuthentication } from './auth/strategies/jwt.js'
import localOperations from './collections/operations/local/index.js' import localOperations from './collections/operations/local/index.js'
import { validateSchema } from './config/validate.js' import { validateSchema } from './config/validate.js'
import { createStdoutAdapter } from './email/stdoutAdapter.js' import { stdoutAdapter } from './email/stdoutAdapter.js'
import { fieldAffectsData } from './exports/types.js' import { fieldAffectsData } from './exports/types.js'
import localGlobalOperations from './globals/operations/local/index.js' import localGlobalOperations from './globals/operations/local/index.js'
import flattenFields from './utilities/flattenTopLevelFields.js' import flattenFields from './utilities/flattenTopLevelFields.js'
@@ -102,8 +101,7 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
return duplicate<T>(this, options) return duplicate<T>(this, options)
} }
// Do these types need to be injected via GeneratedTypes? email: InitializedEmailAdapter
email: EmailAdapter<any, unknown>
// TODO: re-implement or remove? // TODO: re-implement or remove?
// errorHandler: ErrorHandler // errorHandler: ErrorHandler
@@ -252,7 +250,7 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
secret: string secret: string
sendEmail: (message: SendMailOptions) => Promise<unknown> sendEmail: InitializedEmailAdapter['sendEmail']
types: { types: {
arrayTypes: any arrayTypes: any
@@ -366,18 +364,19 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
// Load email adapter // Load email adapter
if (this.config.email instanceof Promise) { 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) { } else if (this.config.email) {
this.email = this.config.email this.email = this.config.email({ payload: this })
} else { } else {
this.logger.warn( this.logger.warn(
`No email adapter provided. Email will be written to stdout. More info at https://payloadcms.com/docs/email/overview.`, `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) serverInitTelemetry(this)

View File

@@ -1,4 +1,5 @@
import type { Config } from 'payload/config' import type { Config } from 'payload/config'
import type { Payload } from 'payload'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { defaults } from 'payload/config' import { defaults } from 'payload/config'
@@ -12,6 +13,8 @@ describe('email', () => {
const apiKey = 'test' const apiKey = 'test'
let createTransportSpy: jest.SpyInstance let createTransportSpy: jest.SpyInstance
const mockedPayload: Payload = jest.fn() as unknown as Payload
beforeEach(() => { beforeEach(() => {
defaultConfig = defaults as Config defaultConfig = defaults as Config
@@ -71,8 +74,10 @@ describe('email', () => {
defaultFromAddress, defaultFromAddress,
}) })
expect(email.defaultFromName).toEqual(defaultFromName) const initializedEmail = email({ payload: mockedPayload })
expect(email.defaultFromAddress).toEqual(defaultFromAddress)
expect(initializedEmail.defaultFromName).toEqual(defaultFromName)
expect(initializedEmail.defaultFromAddress).toEqual(defaultFromAddress)
}) })
}) })
}) })

View File

@@ -1,6 +1,6 @@
import type { NodemailerAdapter } from '@payloadcms/email-nodemailer' import type { NodemailerAdapter } from '@payloadcms/email-nodemailer'
import { createNodemailerAdapter } from '@payloadcms/email-nodemailer' import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import type { PayloadCloudEmailOptions } from './types.js' import type { PayloadCloudEmailOptions } from './types.js'
@@ -12,7 +12,7 @@ export const payloadCloudEmail = async (
return undefined 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) if (!args.defaultDomain)
throw new Error('defaultDomain must be provided to use Payload Cloud Email') throw new Error('defaultDomain must be provided to use Payload Cloud Email')
@@ -43,7 +43,7 @@ export const payloadCloudEmail = async (
const defaultFromAddress = const defaultFromAddress =
args.defaultFromAddress || `cms@${customDomains.length ? customDomains[0] : defaultDomain}` args.defaultFromAddress || `cms@${customDomains.length ? customDomains[0] : defaultDomain}`
const emailAdapter = await createNodemailerAdapter({ const emailAdapter = await nodemailerAdapter({
defaultFromAddress, defaultFromAddress,
defaultFromName, defaultFromName,
skipVerify, skipVerify,

View File

@@ -1,10 +1,13 @@
import type { Config } from 'payload/config' import type { Config } from 'payload/config'
import type { Payload } from 'payload'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { defaults } from 'payload/config' import { defaults } from 'payload/config'
import { payloadCloud } from './plugin.js' 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', () => { describe('plugin', () => {
let createTransportSpy: jest.SpyInstance let createTransportSpy: jest.SpyInstance
@@ -12,13 +15,16 @@ describe('plugin', () => {
const skipVerify = true const skipVerify = true
beforeEach(() => { beforeEach(() => {
createTransportSpy = jest.spyOn(nodemailer, 'createTransport').mockImplementation(() => { createTransportSpy = jest.spyOn(nodemailer, 'createTransport').mockImplementationOnce(() => {
return { return {
verify: jest.fn(), verify: jest.fn(),
transporter: {
name: 'Nodemailer - SMTP',
},
} as unknown as ReturnType<typeof nodemailer.createTransport> } as unknown as ReturnType<typeof nodemailer.createTransport>
}) })
const createTestAccountSpy = jest.spyOn(nodemailer, 'createTestAccount').mockResolvedValue({ const createTestAccountSpy = jest.spyOn(nodemailer, 'createTestAccount').mockResolvedValueOnce({
pass: 'password', pass: 'password',
user: 'user', user: 'user',
web: 'ethereal.email', web: 'ethereal.email',
@@ -67,7 +73,11 @@ describe('plugin', () => {
const plugin = payloadCloud() const plugin = payloadCloud()
const config = await 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 // eslint-disable-next-line jest/expect-expect
@@ -102,7 +112,7 @@ describe('plugin', () => {
}) })
const configWithTransport = createConfig({ const configWithTransport = createConfig({
email: await createNodemailerAdapter({ email: await nodemailerAdapter({
defaultFromAddress: 'test@test.com', defaultFromAddress: 'test@test.com',
defaultFromName: 'Test', defaultFromName: 'Test',
transport: existingTransport, transport: existingTransport,
@@ -124,7 +134,7 @@ describe('plugin', () => {
const defaultFromName = 'Test' const defaultFromName = 'Test'
const defaultFromAddress = 'test@test.com' const defaultFromAddress = 'test@test.com'
const configWithPartialEmail = createConfig({ const configWithPartialEmail = createConfig({
email: await createNodemailerAdapter({ email: await nodemailerAdapter({
defaultFromAddress, defaultFromAddress,
defaultFromName, defaultFromName,
skipVerify, skipVerify,
@@ -133,12 +143,18 @@ describe('plugin', () => {
const plugin = payloadCloud() const plugin = payloadCloud()
const config = await plugin(configWithPartialEmail) const config = await plugin(configWithPartialEmail)
const emailConfig = config.email as Awaited<ReturnType<typeof createNodemailerAdapter>> const emailConfig = config.email as Awaited<ReturnType<typeof nodemailerAdapter>>
expect(emailConfig.defaultFromName).toEqual(defaultFromName) const initializedEmail = emailConfig({ payload: mockedPayload })
expect(emailConfig.defaultFromAddress).toEqual(defaultFromAddress)
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() expect(config.upload?.useTempFiles).toBeFalsy()
} }
function assertCloudEmail(config: Config) { async function assertCloudEmail(config: Config) {
expect( expect(config.email && 'name' in config.email).toStrictEqual('Nodemailer - SMTP')
config.email && 'sendEmail' in config.email && typeof config.email.sendEmail === 'function',
).toBe(true)
} }
function createConfig(overrides?: Partial<Config>): Config { function createConfig(overrides?: Partial<Config>): Config {