diff --git a/package.json b/package.json index 510c4e580..3ed775727 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/email-nodemailer/.eslintignore b/packages/email-nodemailer/.eslintignore new file mode 100644 index 000000000..247f3f12d --- /dev/null +++ b/packages/email-nodemailer/.eslintignore @@ -0,0 +1,10 @@ +.tmp +**/.git +**/.hg +**/.pnp.* +**/.svn +**/.yarn/** +**/build +**/dist/** +**/node_modules +**/temp diff --git a/packages/email-nodemailer/.eslintrc.cjs b/packages/email-nodemailer/.eslintrc.cjs new file mode 100644 index 000000000..d6b3a476b --- /dev/null +++ b/packages/email-nodemailer/.eslintrc.cjs @@ -0,0 +1,7 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + parserOptions: { + project: ['./tsconfig.json'], + tsconfigRootDir: __dirname, + }, +} diff --git a/packages/email-nodemailer/.prettierignore b/packages/email-nodemailer/.prettierignore new file mode 100644 index 000000000..247f3f12d --- /dev/null +++ b/packages/email-nodemailer/.prettierignore @@ -0,0 +1,10 @@ +.tmp +**/.git +**/.hg +**/.pnp.* +**/.svn +**/.yarn/** +**/build +**/dist/** +**/node_modules +**/temp diff --git a/packages/email-nodemailer/.swcrc b/packages/email-nodemailer/.swcrc new file mode 100644 index 000000000..14463f4b0 --- /dev/null +++ b/packages/email-nodemailer/.swcrc @@ -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" + } +} diff --git a/packages/email-nodemailer/LICENSE.md b/packages/email-nodemailer/LICENSE.md new file mode 100644 index 000000000..05e80b2b4 --- /dev/null +++ b/packages/email-nodemailer/LICENSE.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2018-2022 Payload CMS, LLC +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. diff --git a/packages/email-nodemailer/README.md b/packages/email-nodemailer/README.md new file mode 100644 index 000000000..1740cad35 --- /dev/null +++ b/packages/email-nodemailer/README.md @@ -0,0 +1 @@ +# Nodemailer Email Adapter diff --git a/packages/email-nodemailer/package.json b/packages/email-nodemailer/package.json new file mode 100644 index 000000000..f32b39245 --- /dev/null +++ b/packages/email-nodemailer/package.json @@ -0,0 +1,58 @@ +{ + "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": { + "@types/nodemailer": "6.4.14" + } +} diff --git a/packages/email-nodemailer/src/index.ts b/packages/email-nodemailer/src/index.ts new file mode 100644 index 000000000..307eaf5a1 --- /dev/null +++ b/packages/email-nodemailer/src/index.ts @@ -0,0 +1,126 @@ +/* eslint-disable no-console */ +import type { SendMailOptions, Transporter } from 'nodemailer' +import type SMTPConnection from 'nodemailer/lib/smtp-connection' +import type { EmailAdapter } from 'payload/types' + +import nodemailer from 'nodemailer' +import { InvalidConfiguration } from 'payload/errors' + +type Email = { + defaultFromAddress: string + defaultFromName: string + logMockCredentials?: boolean +} + +type EmailTransportOptions = Email & { + transport?: Transporter + transportOptions: SMTPConnection.Options +} + +export type NodemailerAdapterArgs = Email | EmailTransportOptions +export type NodemailerAdapter = EmailAdapter + +/** + * Creates an email adapter using nodemailer + * + * If no email configuration is provided, an ethereal email test account is returned + */ +export const createNodemailerAdapter = async ( + args?: NodemailerAdapterArgs, +): Promise => { + 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) { + return { + defaultFromAddress: 'info@payloadcms.com', + defaultFromName: 'Payload', + transport: await createMockAccount(emailConfig), + } + } + + ensureConfigHasFrom(emailConfig) + + // 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) + } + + 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.' }) + } +} + +const ensureConfigHasFrom = (emailConfig: NodemailerAdapterArgs) => { + if (!emailConfig?.defaultFromName || !emailConfig?.defaultFromAddress) { + throw new InvalidConfiguration( + 'Email fromName and fromAddress must be configured when transport is configured', + ) + } +} + +/** + * 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 + + if (emailConfig?.logMockCredentials) { + console.info('E-mail configured with mock configuration') + 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) { + console.error({ err, msg: 'There was a problem setting up the mock email handler' }) + } +} diff --git a/packages/email-nodemailer/tsconfig.json b/packages/email-nodemailer/tsconfig.json new file mode 100644 index 000000000..f4391b4fa --- /dev/null +++ b/packages/email-nodemailer/tsconfig.json @@ -0,0 +1,18 @@ +{ + "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. */, + }, + "exclude": [ + "dist", + "src/**/*.spec.js", + "src/**/*.spec.jsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ], + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"], +} diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 1d521f59c..6ed38693d 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -531,7 +531,7 @@ export type Config = { * * @see https://payloadcms.com/docs/email/overview */ - email?: EmailAdapter + email?: EmailAdapter | Promise> /** Custom REST endpoints */ endpoints?: Endpoint[] /** diff --git a/packages/payload/src/exports/types.ts b/packages/payload/src/exports/types.ts index 7fcdf1b57..126a76166 100644 --- a/packages/payload/src/exports/types.ts +++ b/packages/payload/src/exports/types.ts @@ -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 } from '../email/types.js' export type { CollapsedPreferences, diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index e89dc5c2e..e77dc65cb 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -49,9 +49,6 @@ 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 { createNodemailerAdapter } from './email/adapters/nodemailer/index.js' -import { emailDefaults } from './email/defaults.js' -import { sendEmail } from './email/sendEmail.js' import { fieldAffectsData } from './exports/types.js' import localGlobalOperations from './globals/operations/local/index.js' import flattenFields from './utilities/flattenTopLevelFields.js' @@ -366,8 +363,12 @@ export class BasePayload { await this.db.connect() } - // TODO: Move nodemailer adapter into separate package after verifying all existing functionality - this.email = await createNodemailerAdapter(emailDefaults) + // Load email adapter + if (this.config.email instanceof Promise) { + this.email = await this.config.email + } else { + this.email = this.config.email + } this.sendEmail = this.email.sendEmail diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b95b955f5..d96b996aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -440,6 +440,19 @@ importers: specifier: workspace:* version: link:../payload + packages/email-nodemailer: + dependencies: + nodemailer: + specifier: 6.9.10 + version: 6.9.10 + payload: + specifier: workspace:* + version: link:../payload + devDependencies: + '@types/nodemailer': + specifier: 6.4.14 + version: 6.4.14 + packages/eslint-config-payload: dependencies: '@types/eslint': @@ -1502,6 +1515,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 diff --git a/test/email-nodemailer/.eslintrc.cjs b/test/email-nodemailer/.eslintrc.cjs new file mode 100644 index 000000000..39a96642f --- /dev/null +++ b/test/email-nodemailer/.eslintrc.cjs @@ -0,0 +1,8 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + ignorePatterns: ['payload-types.ts'], + parserOptions: { + project: ['./tsconfig.eslint.json'], + tsconfigRootDir: __dirname, + }, +} diff --git a/test/email-nodemailer/.gitignore b/test/email-nodemailer/.gitignore new file mode 100644 index 000000000..cce01755f --- /dev/null +++ b/test/email-nodemailer/.gitignore @@ -0,0 +1,2 @@ +/media +/media-gif diff --git a/test/email-nodemailer/config.ts b/test/email-nodemailer/config.ts new file mode 100644 index 000000000..1219f0acb --- /dev/null +++ b/test/email-nodemailer/config.ts @@ -0,0 +1,40 @@ +import { createNodemailerAdapter } from '@payloadcms/email-nodemailer' +import path from 'path' +import { getFileByPath } from 'payload/uploads' +import { fileURLToPath } from 'url' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { devUser } from '../credentials.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +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', + }) + + // Create image + const imageFilePath = path.resolve(dirname, '../uploads/image.png') + const imageFile = await getFileByPath(imageFilePath) + + await payload.create({ + collection: 'media', + data: {}, + file: imageFile, + }) + }, +}) diff --git a/test/email-nodemailer/payload-types.ts b/test/email-nodemailer/payload-types.ts new file mode 100644 index 000000000..7cf43254e --- /dev/null +++ b/test/email-nodemailer/payload-types.ts @@ -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 +} diff --git a/test/email-nodemailer/tsconfig.eslint.json b/test/email-nodemailer/tsconfig.eslint.json new file mode 100644 index 000000000..b34cc7afb --- /dev/null +++ b/test/email-nodemailer/tsconfig.eslint.json @@ -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" + ] +} diff --git a/test/email/config.ts b/test/email/config.ts index 876addd69..e767d737c 100644 --- a/test/email/config.ts +++ b/test/email/config.ts @@ -31,6 +31,11 @@ export default buildConfigWithDefaults({ }, }) + const email = await payload.sendEmail({ + to: 'test@example.com', + subject: 'This was sent on init', + }) + // Create image const imageFilePath = path.resolve(dirname, '../uploads/image.png') const imageFile = await getFileByPath(imageFilePath) diff --git a/test/package.json b/test/package.json index 3ab9a2db4..4b768f6c9 100644 --- a/test/package.json +++ b/test/package.json @@ -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:*",