feat!: email adapter (#5901)

This commit is contained in:
Elliot DeNolf
2024-04-22 14:26:12 -04:00
committed by GitHub
38 changed files with 677 additions and 320 deletions

View File

@@ -18,6 +18,7 @@
"build:create-payload-app": "turbo build --filter create-payload-app", "build:create-payload-app": "turbo build --filter create-payload-app",
"build:db-mongodb": "turbo build --filter db-mongodb", "build:db-mongodb": "turbo build --filter db-mongodb",
"build:db-postgres": "turbo build --filter db-postgres", "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:eslint-config-payload": "turbo build --filter eslint-config-payload",
"build:graphql": "turbo build --filter graphql", "build:graphql": "turbo build --filter graphql",
"build:live-preview": "turbo build --filter live-preview", "build:live-preview": "turbo build --filter live-preview",

View File

@@ -0,0 +1,10 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp

View File

@@ -0,0 +1,7 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
}

View File

@@ -0,0 +1,10 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp

View 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"
}
}

View 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.

View File

@@ -0,0 +1 @@
# Nodemailer Email Adapter

View 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"
}
}

View 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.')
}
}

View 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" },
]
}

View File

@@ -60,7 +60,6 @@
"minimist": "1.2.8", "minimist": "1.2.8",
"mkdirp": "1.0.4", "mkdirp": "1.0.4",
"monaco-editor": "0.38.0", "monaco-editor": "0.38.0",
"nodemailer": "6.9.10",
"pino": "8.15.0", "pino": "8.15.0",
"pino-pretty": "10.2.0", "pino-pretty": "10.2.0",
"pluralize": "8.0.0", "pluralize": "8.0.0",

View File

@@ -57,7 +57,7 @@ export const forgotPasswordOperation = async (incomingArgs: Arguments): Promise<
disableEmail, disableEmail,
expiration, expiration,
req: { req: {
payload: { config, emailOptions, sendEmail: email }, payload: { config, email },
payload, payload,
}, },
req, req,
@@ -132,8 +132,8 @@ export const forgotPasswordOperation = async (incomingArgs: Arguments): Promise<
} }
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
email({ email.sendEmail({
from: `"${emailOptions.fromName}" <${emailOptions.fromAddress}>`, from: `"${email.defaultFromName}" <${email.defaultFromAddress}>`,
html, html,
subject, subject,
to: data.email, to: data.email,

View File

@@ -1,8 +1,8 @@
import { URL } from 'url' import { URL } from 'url'
import type { Collection } from '../collections/config/types.js' import type { Collection } from '../collections/config/types.js'
import type { EmailOptions, SanitizedConfig } from '../config/types.js' import type { SanitizedConfig } from '../config/types.js'
import type { Payload } from '../index.js' import type { InitializedEmailAdapter } from '../email/types.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'
@@ -10,22 +10,20 @@ type Args = {
collection: Collection collection: Collection
config: SanitizedConfig config: SanitizedConfig
disableEmail: boolean disableEmail: boolean
emailOptions: EmailOptions email: InitializedEmailAdapter
req: PayloadRequest req: PayloadRequest
sendEmail: Payload['sendEmail']
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 },
config, config,
disableEmail, disableEmail,
emailOptions, email,
req, req,
sendEmail,
token, token,
user, user,
} = args } = args
@@ -67,13 +65,11 @@ async function sendVerificationEmail(args: Args): Promise<void> {
} }
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
sendEmail({ email.sendEmail({
from: `"${emailOptions.fromName}" <${emailOptions.fromAddress}>`, from: `"${email.defaultFromName}" <${email.defaultFromName}>`,
html, html,
subject, subject,
to: user.email, to: user.email,
}) })
} }
} }
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'
@@ -84,7 +84,7 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
fallbackLocale, fallbackLocale,
locale, locale,
payload, payload,
payload: { config, emailOptions }, payload: { config, email },
}, },
req, req,
showHiddenFields, showHiddenFields,
@@ -272,9 +272,8 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
collection: { config: collectionConfig }, collection: { config: collectionConfig },
config: payload.config, config: payload.config,
disableEmail: disableVerificationEmail, disableEmail: disableVerificationEmail,
emailOptions, email: payload.email,
req, req,
sendEmail: payload.sendEmail,
token: verificationToken, token: verificationToken,
user: result, user: result,
}) })

View File

@@ -1,9 +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 { Transporter } from 'nodemailer' import type { DestinationStream, LoggerOptions, P } from 'pino'
import type SMTPConnection from 'nodemailer/lib/smtp-connection'
import type { DestinationStream, LoggerOptions } 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'
@@ -18,6 +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, 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'
@@ -34,12 +33,6 @@ type Prettify<T> = {
[K in keyof T]: T[K] [K in keyof T]: T[K]
} & NonNullable<unknown> } & NonNullable<unknown>
type Email = {
fromAddress: string
fromName: string
logMockCredentials?: boolean
}
// eslint-disable-next-line no-use-before-define // eslint-disable-next-line no-use-before-define
export type Plugin = (config: Config) => Config | Promise<Config> export type Plugin = (config: Config) => Config | Promise<Config>
@@ -85,36 +78,6 @@ export type GeneratePreviewURL = (
options: GeneratePreviewURLOptions, options: GeneratePreviewURLOptions,
) => Promise<null | string> | null | string ) => 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 = { export type GraphQLInfo = {
Mutation: { Mutation: {
fields: Record<string, any> fields: Record<string, any>
@@ -162,13 +125,6 @@ export type InitOptions = {
*/ */
disableOnInit?: boolean 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 * 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 * 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 */ /** Default richtext editor to use for richText fields */
editor: RichTextAdapter<any, any, any> 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 * @see https://payloadcms.com/docs/email/overview
*/ */
email?: EmailOptions email?: EmailAdapter | Promise<EmailAdapter>
/** Custom REST endpoints */ /** Custom REST endpoints */
endpoints?: Endpoint[] endpoints?: Endpoint[]
/** /**
@@ -736,3 +692,5 @@ export type EntityDescription =
| EntityDescriptionFunction | EntityDescriptionFunction
| Record<string, string> | Record<string, string>
| string | string
export type { EmailAdapter, SendEmailOptions }

View File

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

View File

@@ -1,6 +1,9 @@
import type { EmailOptions } from '../config/types.js' import type { InitializedEmailAdapter } from './types.js'
export const defaults: EmailOptions = { export const emailDefaults: Pick<
fromAddress: 'info@payloadcms.com', InitializedEmailAdapter,
fromName: 'Payload', 'defaultFromAddress' | 'defaultFromName'
> = {
defaultFromAddress: 'info@payloadcms.com',
defaultFromName: 'Payload',
} }

View 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
}

View File

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

View File

@@ -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 let result
try { try {
const email = await this.email result = await this.email.sendEmail(message)
result = await email.transport.sendMail(message) } catch (err: unknown) {
} catch (err) { const stringifiedTo = getStringifiedToAddress(message)
this.logger.error(err, `Failed to send mail to ${message.to}, subject: ${message.subject}`)
this.logger.error({
err,
msg: `Failed to send mail to ${stringifiedTo}, subject: ${message.subject ?? 'No Subject'}`,
})
return err return err
} }

View 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()
},
})

View File

@@ -1,21 +1,31 @@
import type { TestAccount, Transporter } from 'nodemailer' import type { SendMailOptions as NodemailerSendMailOptions } from 'nodemailer'
import type Mail from 'nodemailer/lib/mailer'
import type SMTPConnection from 'nodemailer/lib/smtp-connection'
export type Message = { import type { Payload } from '../types/index.js'
from: string
html: string
subject: string
to: string
}
export type MockEmailHandler = { account: TestAccount; transport: Transporter } type Prettify<T> = {
export type BuildEmailResult = Promise< [K in keyof T]: T[K]
| { } & NonNullable<unknown>
fromAddress: string
fromName: string /**
transport: Mail * Options for sending an email. Allows access to the PayloadRequest object
transportOptions?: SMTPConnection.Options */
} export type SendEmailOptions = Prettify<NodemailerSendMailOptions>
| MockEmailHandler
/**
* 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>
}

View File

@@ -3,7 +3,9 @@ export type * from '../admin/types.js'
export type * from '../uploads/types.js' 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 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'
@@ -34,9 +33,9 @@ import type {
ManyOptions as UpdateManyOptions, ManyOptions as UpdateManyOptions,
Options as UpdateOptions, Options as UpdateOptions,
} from './collections/operations/local/update.js' } 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 { 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 { 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'
@@ -50,9 +49,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 buildEmail from './email/build.js' import { stdoutAdapter } from './email/stdoutAdapter.js'
import { defaults as emailDefaults } from './email/defaults.js'
import sendEmail from './email/sendEmail.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'
@@ -116,9 +113,7 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
return duplicate<T>(this, options) return duplicate<T>(this, options)
} }
email: BuildEmailResult email: InitializedEmailAdapter
emailOptions: EmailOptions
// TODO: re-implement or remove? // TODO: re-implement or remove?
// errorHandler: ErrorHandler // errorHandler: ErrorHandler
@@ -267,7 +262,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
@@ -379,17 +374,21 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
await this.db.connect() await this.db.connect()
} }
// Configure email service // Load email adapter
const emailOptions = options.email ? { ...options.email } : this.config.email if (this.config.email instanceof Promise) {
if (options.email && this.config.email) { 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( 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.sendEmail = this.email['sendEmail']
this.email = buildEmail(this.emailOptions, this.logger)
this.sendEmail = sendEmail.bind(this)
serverInitTelemetry(this) serverInitTelemetry(this)

View File

@@ -28,6 +28,7 @@
"@aws-sdk/client-s3": "^3.525.0", "@aws-sdk/client-s3": "^3.525.0",
"@aws-sdk/credential-providers": "^3.525.0", "@aws-sdk/credential-providers": "^3.525.0",
"@aws-sdk/lib-storage": "^3.525.0", "@aws-sdk/lib-storage": "^3.525.0",
"@payloadcms/email-nodemailer": "workspace:^",
"amazon-cognito-identity-js": "^6.1.2", "amazon-cognito-identity-js": "^6.1.2",
"nodemailer": "6.9.10", "nodemailer": "6.9.10",
"resend": "^0.17.2" "resend": "^0.17.2"

View File

@@ -1,4 +1,6 @@
import type { Config } from 'payload/config' import type { Config } from 'payload/config'
import type { Payload } from 'payload'
import nodemailer from 'nodemailer'
import { defaults } from 'payload/config' import { defaults } from 'payload/config'
@@ -6,18 +8,36 @@ import { payloadCloudEmail } from './email.js'
describe('email', () => { describe('email', () => {
let defaultConfig: Config 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(() => { beforeEach(() => {
// @ts-expect-error No need for db or editor defaultConfig = defaults as Config
defaultConfig = { ...defaults }
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', () => { describe('not in Payload Cloud', () => {
it('should return undefined', () => { it('should return undefined', async () => {
const email = payloadCloudEmail({ const email = await payloadCloudEmail({
apiKey: 'test', apiKey,
config: defaultConfig, config: defaultConfig,
defaultDomain: 'test', defaultDomain,
skipVerify,
}) })
expect(email).toBeUndefined() expect(email).toBeUndefined()
@@ -29,35 +49,35 @@ describe('email', () => {
process.env.PAYLOAD_CLOUD = 'true' process.env.PAYLOAD_CLOUD = 'true'
}) })
it('should respect PAYLOAD_CLOUD env var', () => { it('should respect PAYLOAD_CLOUD env var', async () => {
const email = payloadCloudEmail({ const email = await payloadCloudEmail({
apiKey: 'test', apiKey,
config: defaultConfig, config: defaultConfig,
defaultDomain: 'test', defaultDomain,
skipVerify,
}) })
expect(email?.fromName).toBeDefined() expect(email).toBeDefined()
expect(email?.fromAddress).toBeDefined()
expect(email?.transport?.transporter.name).toEqual('SMTP')
}) })
it('should allow setting fromName and fromAddress', () => { it('should allow setting fromName and fromAddress', async () => {
const fromName = 'custom from name' const defaultFromName = 'custom from name'
const fromAddress = 'custom@fromaddress.com' const defaultFromAddress = 'custom@fromaddress.com'
const configWithFrom: Config = { const configWithFrom: Config = {
...defaultConfig, ...defaultConfig,
email: {
fromAddress,
fromName,
},
} }
const email = payloadCloudEmail({ const email = await payloadCloudEmail({
apiKey: 'test', apiKey,
config: configWithFrom, config: configWithFrom,
defaultDomain: 'test', defaultDomain,
skipVerify,
defaultFromName,
defaultFromAddress,
}) })
expect(email?.fromName).toEqual(fromName) const initializedEmail = email({ payload: mockedPayload })
expect(email?.fromAddress).toEqual(fromAddress)
expect(initializedEmail.defaultFromName).toEqual(defaultFromName)
expect(initializedEmail.defaultFromAddress).toEqual(defaultFromAddress)
}) })
}) })
}) })

View File

@@ -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 nodemailer from 'nodemailer'
import type { PayloadCloudEmailOptions } from './types.js' 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) { if (process.env.PAYLOAD_CLOUD !== 'true' || !args) {
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')
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( const customDomainEnvs = Object.keys(process.env).filter(
(e) => e.startsWith('PAYLOAD_CLOUD_EMAIL_DOMAIN_') && !e.endsWith('API_KEY'), (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 defaultFromName = args.defaultFromName || 'Payload CMS'
const fromAddress = const defaultFromAddress =
config.email?.fromAddress || `cms@${customDomains.length ? customDomains[0] : defaultDomain}` args.defaultFromAddress || `cms@${customDomains.length ? customDomains[0] : defaultDomain}`
const existingTransport = config.email && 'transport' in config.email && config.email?.transport const emailAdapter = await nodemailerAdapter({
defaultFromAddress,
if (existingTransport) { defaultFromName,
return { skipVerify,
fromAddress,
fromName,
transport: existingTransport,
}
}
return {
fromAddress,
fromName,
transport: nodemailer.createTransport({ transport: nodemailer.createTransport({
auth: { auth: {
pass: apiKey, pass: apiKey,
@@ -53,5 +56,7 @@ export const payloadCloudEmail = (args: PayloadCloudEmailOptions): EmailTranspor
port: 465, port: 465,
secure: true, secure: true,
}), }),
} })
return emailAdapter
} }

View File

@@ -1,19 +1,44 @@
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 { NodemailerAdapter, nodemailerAdapter } from '@payloadcms/email-nodemailer'
const mockedPayload: Payload = jest.fn() as unknown as Payload
describe('plugin', () => { 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', () => { describe('not in Payload Cloud', () => {
// eslint-disable-next-line jest/expect-expect // eslint-disable-next-line jest/expect-expect
it('should return unmodified config', () => { it('should return unmodified config', async () => {
const plugin = payloadCloud() const plugin = payloadCloud()
const config = plugin(createConfig()) const config = await plugin(createConfig())
assertNoCloudStorage(config) assertNoCloudStorage(config)
assertNoCloudEmail(config) expect(config.email).toBeUndefined()
}) })
}) })
@@ -26,17 +51,17 @@ describe('plugin', () => {
describe('storage', () => { describe('storage', () => {
// eslint-disable-next-line jest/expect-expect // 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 plugin = payloadCloud()
const config = plugin(createConfig()) const config = await plugin(createConfig())
assertCloudStorage(config) assertCloudStorage(config)
}) })
// eslint-disable-next-line jest/expect-expect // eslint-disable-next-line jest/expect-expect
it('should allow opt-out', () => { it('should allow opt-out', async () => {
const plugin = payloadCloud({ storage: false }) const plugin = payloadCloud({ storage: false })
const config = plugin(createConfig()) const config = await plugin(createConfig())
assertNoCloudStorage(config) assertNoCloudStorage(config)
}) })
@@ -44,33 +69,39 @@ describe('plugin', () => {
describe('email', () => { describe('email', () => {
// eslint-disable-next-line jest/expect-expect // 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 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 // eslint-disable-next-line jest/expect-expect
it('should allow opt-out', () => { it('should allow opt-out', async () => {
const plugin = payloadCloud({ email: false }) 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 // 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_EMAIL_API_KEY
delete process.env.PAYLOAD_CLOUD_DEFAULT_DOMAIN delete process.env.PAYLOAD_CLOUD_DEFAULT_DOMAIN
const plugin = payloadCloud() 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({ const existingTransport = nodemailer.createTransport({
name: 'existing-transport', name: 'existing-transport',
// eslint-disable-next-line @typescript-eslint/require-await // eslint-disable-next-line @typescript-eslint/require-await
@@ -81,38 +112,49 @@ describe('plugin', () => {
}) })
const configWithTransport = createConfig({ const configWithTransport = createConfig({
email: { email: await nodemailerAdapter({
fromAddress: 'test@test.com', defaultFromAddress: 'test@test.com',
fromName: 'Test', defaultFromName: 'Test',
transport: existingTransport, transport: existingTransport,
}, skipVerify,
}),
}) })
const plugin = payloadCloud() const plugin = payloadCloud()
const config = plugin(configWithTransport) const config = await plugin(configWithTransport)
expect( expect(logSpy).toHaveBeenCalledWith(
config.email && 'transport' in config.email && config.email.transport?.transporter.name, expect.stringContaining('Payload Cloud Email is enabled but'),
).toEqual('existing-transport') )
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({ const configWithPartialEmail = createConfig({
email: { email: await nodemailerAdapter({
fromAddress: 'test@test.com', defaultFromAddress,
fromName: 'Test', defaultFromName,
}, skipVerify,
}),
}) })
const plugin = payloadCloud() 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) const initializedEmail = emailConfig({ payload: mockedPayload })
expect(config.email?.fromAddress).toEqual(configWithPartialEmail.email?.fromAddress)
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() expect(config.upload?.useTempFiles).toBeFalsy()
} }
function assertCloudEmail(config: Config) { async function assertCloudEmail(config: Config) {
if (config.email && 'transport' in config.email) { expect(config.email && 'name' in config.email).toStrictEqual('Nodemailer - SMTP')
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')
}
} }
function createConfig(overrides?: Partial<Config>): Config { function createConfig(overrides?: Partial<Config>): Config {

View File

@@ -13,7 +13,7 @@ import { getStaticHandler } from './staticHandler.js'
export const payloadCloud = export const payloadCloud =
(pluginOptions?: PluginOptions) => (pluginOptions?: PluginOptions) =>
(incomingConfig: Config): Config => { async (incomingConfig: Config): Promise<Config> => {
let config = { ...incomingConfig } let config = { ...incomingConfig }
if (process.env.PAYLOAD_CLOUD !== 'true') { if (process.env.PAYLOAD_CLOUD !== 'true') {
@@ -83,10 +83,13 @@ export const payloadCloud =
const apiKey = process.env.PAYLOAD_CLOUD_EMAIL_API_KEY const apiKey = process.env.PAYLOAD_CLOUD_EMAIL_API_KEY
const defaultDomain = process.env.PAYLOAD_CLOUD_DEFAULT_DOMAIN const defaultDomain = process.env.PAYLOAD_CLOUD_DEFAULT_DOMAIN
if (pluginOptions?.email !== false && apiKey && defaultDomain) { if (pluginOptions?.email !== false && apiKey && defaultDomain) {
config.email = payloadCloudEmail({ config.email = await payloadCloudEmail({
apiKey, apiKey,
config, config,
defaultDomain, defaultDomain,
defaultFromAddress: pluginOptions?.email?.defaultFromAddress,
defaultFromName: pluginOptions?.email?.defaultFromName,
skipVerify: pluginOptions?.email?.skipVerify,
}) })
} }

View File

@@ -42,13 +42,22 @@ export interface PayloadCloudEmailOptions {
apiKey: string apiKey: string
config: Config config: Config
defaultDomain: string defaultDomain: string
defaultFromAddress?: string
defaultFromName?: string
skipVerify?: boolean
} }
export interface PluginOptions { export interface PluginOptions {
/** Payload Cloud Email /** Payload Cloud Email
* @default true * @default true
*/ */
email?: false email?:
| {
defaultFromAddress: string
defaultFromName: string
skipVerify?: boolean
}
| false
/** /**
* Payload Cloud API endpoint * Payload Cloud API endpoint

22
pnpm-lock.yaml generated
View File

@@ -443,6 +443,19 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../payload 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: packages/eslint-config-payload:
dependencies: dependencies:
'@types/eslint': '@types/eslint':
@@ -794,9 +807,6 @@ importers:
monaco-editor: monaco-editor:
specifier: 0.38.0 specifier: 0.38.0
version: 0.38.0 version: 0.38.0
nodemailer:
specifier: 6.9.10
version: 6.9.10
pino: pino:
specifier: 8.15.0 specifier: 8.15.0
version: 8.15.0 version: 8.15.0
@@ -963,6 +973,9 @@ importers:
'@aws-sdk/lib-storage': '@aws-sdk/lib-storage':
specifier: ^3.525.0 specifier: ^3.525.0
version: 3.550.0(@aws-sdk/client-s3@3.550.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: amazon-cognito-identity-js:
specifier: ^6.1.2 specifier: ^6.1.2
version: 6.3.12 version: 6.3.12
@@ -1527,6 +1540,9 @@ importers:
'@payloadcms/db-postgres': '@payloadcms/db-postgres':
specifier: workspace:* specifier: workspace:*
version: link:../packages/db-postgres version: link:../packages/db-postgres
'@payloadcms/email-nodemailer':
specifier: workspace:*
version: link:../packages/email-nodemailer
'@payloadcms/eslint-config': '@payloadcms/eslint-config':
specifier: workspace:* specifier: workspace:*
version: link:../packages/eslint-config-payload version: link:../packages/eslint-config-payload

View 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
View File

@@ -0,0 +1,2 @@
/media
/media-gif

View 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 })
},
})

View 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
}

View 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"
]
}

View File

@@ -1,3 +1,4 @@
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
import path from 'path' import path from 'path'
import { getFileByPath } from 'payload/uploads' import { getFileByPath } from 'payload/uploads'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -15,6 +16,7 @@ export default buildConfigWithDefaults({
// ...extend config here // ...extend config here
collections: [PostsCollection, MediaCollection], collections: [PostsCollection, MediaCollection],
globals: [MenuGlobal], globals: [MenuGlobal],
email: nodemailerAdapter(),
onInit: async (payload) => { onInit: async (payload) => {
await payload.create({ await payload.create({
collection: 'users', collection: 'users',

View File

@@ -15,6 +15,7 @@
"@payloadcms/db-mongodb": "workspace:*", "@payloadcms/db-mongodb": "workspace:*",
"@payloadcms/db-postgres": "workspace:*", "@payloadcms/db-postgres": "workspace:*",
"@payloadcms/eslint-config": "workspace:*", "@payloadcms/eslint-config": "workspace:*",
"@payloadcms/email-nodemailer": "workspace:*",
"@payloadcms/graphql": "workspace:*", "@payloadcms/graphql": "workspace:*",
"@payloadcms/live-preview": "workspace:*", "@payloadcms/live-preview": "workspace:*",
"@payloadcms/live-preview-react": "workspace:*", "@payloadcms/live-preview-react": "workspace:*",