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,15 +1,9 @@
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 = {
export const stdoutAdapter: EmailAdapter<void> = ({ payload }) => ({
defaultFromAddress: emailDefaults.defaultFromAddress,
defaultFromName: emailDefaults.defaultFromName,
sendEmail: async (message) => {
@@ -18,6 +12,4 @@ export const createStdoutAdapter = (payload: Payload) => {
payload.logger.info({ msg: res })
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<
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'
@@ -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 {