feat!: email adapter (#5901)
This commit is contained in:
@@ -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",
|
||||
|
||||
10
packages/email-nodemailer/.eslintignore
Normal file
10
packages/email-nodemailer/.eslintignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
7
packages/email-nodemailer/.eslintrc.cjs
Normal file
7
packages/email-nodemailer/.eslintrc.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
10
packages/email-nodemailer/.prettierignore
Normal file
10
packages/email-nodemailer/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
15
packages/email-nodemailer/.swcrc
Normal file
15
packages/email-nodemailer/.swcrc
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
22
packages/email-nodemailer/LICENSE.md
Normal file
22
packages/email-nodemailer/LICENSE.md
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018-2022 Payload CMS, LLC <info@payloadcms.com>
|
||||
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.
|
||||
1
packages/email-nodemailer/README.md
Normal file
1
packages/email-nodemailer/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# Nodemailer Email Adapter
|
||||
59
packages/email-nodemailer/package.json
Normal file
59
packages/email-nodemailer/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
123
packages/email-nodemailer/src/index.ts
Normal file
123
packages/email-nodemailer/src/index.ts
Normal file
@@ -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<unknown>
|
||||
|
||||
/**
|
||||
* 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<NodemailerAdapter> => {
|
||||
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.')
|
||||
}
|
||||
}
|
||||
19
packages/email-nodemailer/tsconfig.json
Normal file
19
packages/email-nodemailer/tsconfig.json
Normal file
@@ -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" },
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
export async function sendVerificationEmail(args: Args): Promise<void> {
|
||||
// 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<void> {
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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 <TSlug extends keyof GeneratedTypes['collec
|
||||
fallbackLocale,
|
||||
locale,
|
||||
payload,
|
||||
payload: { config, emailOptions },
|
||||
payload: { config, email },
|
||||
},
|
||||
req,
|
||||
showHiddenFields,
|
||||
@@ -272,9 +272,8 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
|
||||
collection: { config: collectionConfig },
|
||||
config: payload.config,
|
||||
disableEmail: disableVerificationEmail,
|
||||
emailOptions,
|
||||
email: payload.email,
|
||||
req,
|
||||
sendEmail: payload.sendEmail,
|
||||
token: verificationToken,
|
||||
user: result,
|
||||
})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { I18nOptions, TFunction } from '@payloadcms/translations'
|
||||
import type { Options as ExpressFileUploadOptions } from 'express-fileupload'
|
||||
import type GraphQL from 'graphql'
|
||||
import type { Transporter } from 'nodemailer'
|
||||
import type SMTPConnection from 'nodemailer/lib/smtp-connection'
|
||||
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'
|
||||
@@ -18,6 +16,7 @@ import type {
|
||||
SanitizedCollectionConfig,
|
||||
} from '../collections/config/types.js'
|
||||
import type { DatabaseAdapterResult } from '../database/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'
|
||||
@@ -34,12 +33,6 @@ type Prettify<T> = {
|
||||
[K in keyof T]: T[K]
|
||||
} & NonNullable<unknown>
|
||||
|
||||
type Email = {
|
||||
fromAddress: string
|
||||
fromName: string
|
||||
logMockCredentials?: boolean
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
export type Plugin = (config: Config) => Config | Promise<Config>
|
||||
|
||||
@@ -85,36 +78,6 @@ export type GeneratePreviewURL = (
|
||||
options: GeneratePreviewURLOptions,
|
||||
) => Promise<null | string> | 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<string, any>
|
||||
@@ -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<any, any, any>
|
||||
/**
|
||||
* 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<EmailAdapter>
|
||||
/** Custom REST endpoints */
|
||||
endpoints?: Endpoint[]
|
||||
/**
|
||||
@@ -736,3 +692,5 @@ export type EntityDescription =
|
||||
| EntityDescriptionFunction
|
||||
| Record<string, string>
|
||||
| string
|
||||
|
||||
export type { EmailAdapter, SendEmailOptions }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
23
packages/payload/src/email/getStringifiedToAddress.ts
Normal file
23
packages/payload/src/email/getStringifiedToAddress.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -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<MockEmailHandler> => {
|
||||
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
|
||||
@@ -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<unknown> {
|
||||
import { getStringifiedToAddress } from './getStringifiedToAddress.js'
|
||||
|
||||
export async function sendEmail(this: Payload, message: SendEmailOptions): Promise<unknown> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
15
packages/payload/src/email/stdoutAdapter.ts
Normal file
15
packages/payload/src/email/stdoutAdapter.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { EmailAdapter } from './types.js'
|
||||
|
||||
import { emailDefaults } from './defaults.js'
|
||||
import { getStringifiedToAddress } from './getStringifiedToAddress.js'
|
||||
|
||||
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()
|
||||
},
|
||||
})
|
||||
@@ -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<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: SendEmailOptions) => Promise<TSendEmailResponse>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TGeneratedTypes extends GeneratedTypes> {
|
||||
return duplicate<T>(this, options)
|
||||
}
|
||||
|
||||
email: BuildEmailResult
|
||||
|
||||
emailOptions: EmailOptions
|
||||
email: InitializedEmailAdapter
|
||||
|
||||
// TODO: re-implement or remove?
|
||||
// errorHandler: ErrorHandler
|
||||
@@ -267,7 +262,7 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
|
||||
|
||||
secret: string
|
||||
|
||||
sendEmail: (message: SendMailOptions) => Promise<unknown>
|
||||
sendEmail: InitializedEmailAdapter['sendEmail']
|
||||
|
||||
types: {
|
||||
arrayTypes: any
|
||||
@@ -379,17 +374,21 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
|
||||
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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<typeof nodemailer.createTransport>
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<NodemailerAdapter> | 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
|
||||
}
|
||||
|
||||
@@ -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<typeof nodemailer.createTransport>
|
||||
})
|
||||
|
||||
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<ReturnType<typeof nodemailerAdapter>>
|
||||
|
||||
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>): Config {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { getStaticHandler } from './staticHandler.js'
|
||||
|
||||
export const payloadCloud =
|
||||
(pluginOptions?: PluginOptions) =>
|
||||
(incomingConfig: Config): Config => {
|
||||
async (incomingConfig: Config): Promise<Config> => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
8
test/email-nodemailer/.eslintrc.cjs
Normal file
8
test/email-nodemailer/.eslintrc.cjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
ignorePatterns: ['payload-types.ts'],
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.eslint.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
2
test/email-nodemailer/.gitignore
vendored
Normal file
2
test/email-nodemailer/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/media
|
||||
/media-gif
|
||||
26
test/email-nodemailer/config.ts
Normal file
26
test/email-nodemailer/config.ts
Normal file
@@ -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 })
|
||||
},
|
||||
})
|
||||
50
test/email-nodemailer/payload-types.ts
Normal file
50
test/email-nodemailer/payload-types.ts
Normal file
@@ -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
|
||||
}
|
||||
13
test/email-nodemailer/tsconfig.eslint.json
Normal file
13
test/email-nodemailer/tsconfig.eslint.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
Reference in New Issue
Block a user