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 */
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<SendMailOptions, unknown>
export type NodemailerAdapter = EmailAdapter<unknown>
/**
* 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<NodemailerAdapter> => {
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.')

View File

@@ -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<any, unknown>
email: InitializedEmailAdapter
req: PayloadRequest
token: string
user: User
}
async function sendVerificationEmail(args: Args): Promise<void> {
export async function sendVerificationEmail(args: Args): Promise<void> {
// Verify token from e-mail
const {
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'
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'

View File

@@ -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<any, unknown> | Promise<EmailAdapter<any, unknown>>
email?: EmailAdapter | Promise<EmailAdapter>
/** Custom REST endpoints */
endpoints?: Endpoint[]
/**
@@ -690,3 +690,5 @@ export type EntityDescription =
| EntityDescriptionFunction
| Record<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<
EmailAdapter<any, unknown>,
InitializedEmailAdapter,
'defaultFromAddress' | 'defaultFromName'
> = {
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
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 { SendEmailOptions } from './types.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
try {

View File

@@ -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<SendMailOptions, void>
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<void> = ({ 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()
},
})

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<
TSendEmailOptions extends SendMailOptions,
KSendEmailResponse = unknown,
> = {
type Prettify<T> = {
[K in keyof T]: T[K]
} & 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
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 { 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,

View File

@@ -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<TGeneratedTypes extends GeneratedTypes> {
return duplicate<T>(this, options)
}
// Do these types need to be injected via GeneratedTypes?
email: EmailAdapter<any, unknown>
email: InitializedEmailAdapter
// TODO: re-implement or remove?
// errorHandler: ErrorHandler
@@ -252,7 +250,7 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
secret: string
sendEmail: (message: SendMailOptions) => Promise<unknown>
sendEmail: InitializedEmailAdapter['sendEmail']
types: {
arrayTypes: any
@@ -366,18 +364,19 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
// 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)

View File

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

View File

@@ -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,

View File

@@ -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<typeof nodemailer.createTransport>
})
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<ReturnType<typeof createNodemailerAdapter>>
const emailConfig = config.email as Awaited<ReturnType<typeof nodemailerAdapter>>
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>): Config {