feat: create email-nodemailer package

This commit is contained in:
Elliot DeNolf
2024-04-17 17:10:24 -04:00
parent a1d68bd951
commit fb7925f272
21 changed files with 412 additions and 6 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,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"
}
}

View File

@@ -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<SendMailOptions, unknown>
/**
* 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<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) {
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' })
}
}

View File

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

View File

@@ -531,7 +531,7 @@ export type Config = {
* *
* @see https://payloadcms.com/docs/email/overview * @see https://payloadcms.com/docs/email/overview
*/ */
email?: EmailAdapter<any, unknown> email?: EmailAdapter<any, unknown> | Promise<EmailAdapter<any, unknown>>
/** Custom REST endpoints */ /** Custom REST endpoints */
endpoints?: Endpoint[] endpoints?: Endpoint[]
/** /**

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 } from '../email/types.js'
export type { export type {
CollapsedPreferences, CollapsedPreferences,

View File

@@ -49,9 +49,6 @@ 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 { 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 { 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'
@@ -366,8 +363,12 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
await this.db.connect() await this.db.connect()
} }
// TODO: Move nodemailer adapter into separate package after verifying all existing functionality // Load email adapter
this.email = await createNodemailerAdapter(emailDefaults) if (this.config.email instanceof Promise) {
this.email = await this.config.email
} else {
this.email = this.config.email
}
this.sendEmail = this.email.sendEmail this.sendEmail = this.email.sendEmail

16
pnpm-lock.yaml generated
View File

@@ -440,6 +440,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
payload:
specifier: workspace:*
version: link:../payload
devDependencies:
'@types/nodemailer':
specifier: 6.4.14
version: 6.4.14
packages/eslint-config-payload: packages/eslint-config-payload:
dependencies: dependencies:
'@types/eslint': '@types/eslint':
@@ -1502,6 +1515,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,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,
})
},
})

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

@@ -31,6 +31,11 @@ export default buildConfigWithDefaults({
}, },
}) })
const email = await payload.sendEmail({
to: 'test@example.com',
subject: 'This was sent on init',
})
// Create image // Create image
const imageFilePath = path.resolve(dirname, '../uploads/image.png') const imageFilePath = path.resolve(dirname, '../uploads/image.png')
const imageFile = await getFileByPath(imageFilePath) const imageFile = await getFileByPath(imageFilePath)

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:*",