feat: implement resend rest email adapter (#5916)

This commit is contained in:
Elliot DeNolf
2024-04-29 22:06:53 -04:00
committed by GitHub
parent 4d7ef58e7e
commit 3d50caf985
20 changed files with 655 additions and 0 deletions

View File

@@ -13,6 +13,7 @@
"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:email-nodemailer": "turbo build --filter email-nodemailer",
"build:email-resend-rest": "turbo build --filter email-resend-rest",
"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,19 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"exclude": [
"/**/mocks",
"/**/*.spec.ts"
],
"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,30 @@
# Resend REST Email Adapter
This adapter allows you to send emails using the [Resend](https://resend.com) REST API.
## Installation
```sh
pnpm add @payloadcms/email-resend-rest`
```
## Usage
- Sign up for a [Resend](https://resend.com) account
- Set up a domain
- Create an API key
- Set API key as RESEND_API_KEY environment variable
- Configure your Payload config
```ts
// payload.config.js
import { resendAdapter } from '@payloadcms/email-resend-rest'
export default buildConfig({
email: resendAdapter({
defaultFromAddress: 'dev@payloadcms.com',
defaultFromName: 'Payload CMS',
apiKey: process.env.RESEND_API_KEY || '',
}),
})
```

View File

@@ -0,0 +1,17 @@
/** @type {import('jest').Config} */
const customJestConfig = {
rootDir: '.',
extensionsToTreatAsEsm: ['.ts', '.tsx'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
testEnvironment: 'node',
testMatch: ['<rootDir>/**/*spec.ts'],
testTimeout: 10000,
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest'],
},
verbose: true,
}
export default customJestConfig

View File

@@ -0,0 +1,57 @@
{
"name": "@payloadcms/email-resend-rest",
"version": "0.0.0",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
"url": "https://github.com/payloadcms/payload.git",
"directory": "packages/email-resend-rest"
},
"license": "MIT",
"author": "Payload CMS, Inc.",
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"require": "./src/index.ts",
"types": "./src/index.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm build:swc && pnpm build:types",
"build:swc": "swc ./src -d ./dist --config-file .swcrc-build",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf {dist,*.tsbuildinfo}",
"prepublishOnly": "pnpm clean && pnpm turbo build",
"test": "jest"
},
"devDependencies": {
"@types/jest": "29.5.12",
"jest": "^29.7.0",
"payload": "workspace:*"
},
"peerDependencies": {
"payload": "workspace:*"
},
"engines": {
"node": ">=18.20.2"
},
"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"
}
}

View File

@@ -0,0 +1,87 @@
import { resendAdapter } from './index.js'
import { Payload } from 'payload/types'
describe('email-resend', () => {
const defaultFromAddress = 'dev@payloadcms.com'
const defaultFromName = 'Payload CMS'
const apiKey = 'test-api-key'
const from = 'dev@payloadcms.com'
const to = from
const subject = 'This was sent on init'
const text = 'This is my message body'
const mockPayload = {} as unknown as Payload
afterEach(() => {
jest.clearAllMocks()
})
it('should handle sending an email', async () => {
global.fetch = jest.spyOn(global, 'fetch').mockImplementation(
jest.fn(() =>
Promise.resolve({
json: () => {
return { id: 'test-id' }
},
}),
) as jest.Mock,
) as jest.Mock
const adapter = resendAdapter({
defaultFromAddress,
defaultFromName,
apiKey,
})
await adapter({ payload: mockPayload }).sendEmail({
from,
to,
subject,
text,
})
// @ts-expect-error
expect(global.fetch.mock.calls[0][0]).toStrictEqual('https://api.resend.com/emails')
// @ts-expect-error
const request = global.fetch.mock.calls[0][1]
expect(request.headers.Authorization).toStrictEqual(`Bearer ${apiKey}`)
expect(JSON.parse(request.body)).toMatchObject({
from,
to,
subject,
text,
})
})
it('should throw an error if the email fails to send', async () => {
const errorResponse = {
message: 'error information',
name: 'validation_error',
statusCode: 403,
}
global.fetch = jest.spyOn(global, 'fetch').mockImplementation(
jest.fn(() =>
Promise.resolve({
json: () => errorResponse,
}),
) as jest.Mock,
) as jest.Mock
const adapter = resendAdapter({
defaultFromAddress,
defaultFromName,
apiKey,
})
await expect(() =>
adapter({ payload: mockPayload }).sendEmail({
from,
to,
subject,
text,
}),
).rejects.toThrow(
`Error sending email: ${errorResponse.statusCode} ${errorResponse.name} - ${errorResponse.message}`,
)
})
})

View File

@@ -0,0 +1,240 @@
import type { EmailAdapter } from 'payload/config'
import type { SendEmailOptions } from 'payload/types'
import { APIError } from 'payload/errors'
export type ResendAdapterArgs = {
apiKey: string
defaultFromAddress: string
defaultFromName: string
}
type ResendAdapter = EmailAdapter<ResendResponse>
type ResendError = {
message: string
name: string
statusCode: number
}
type ResendResponse = { id: string } | ResendError
/**
* Email adapter for [Resend](https://resend.com) REST API
*/
export const resendAdapter = (args: ResendAdapterArgs): ResendAdapter => {
const { apiKey, defaultFromAddress, defaultFromName } = args
const adapter: ResendAdapter = () => ({
name: 'resend-rest',
defaultFromAddress,
defaultFromName,
sendEmail: async (message) => {
// Map the Payload email options to Resend email options
const sendEmailOptions = mapPayloadEmailToResendEmail(
message,
defaultFromAddress,
defaultFromName,
)
const res = await fetch('https://api.resend.com/emails', {
body: JSON.stringify(sendEmailOptions),
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
method: 'POST',
})
const data = (await res.json()) as ResendResponse
if ('id' in data) {
return data
} else {
const statusCode = data.statusCode || res.status
let formattedError = `Error sending email: ${statusCode}`
if (data.name && data.message) {
formattedError += ` ${data.name} - ${data.message}`
}
throw new APIError(formattedError, statusCode)
}
},
})
return adapter
}
function mapPayloadEmailToResendEmail(
message: SendEmailOptions,
defaultFromAddress: string,
defaultFromName: string,
): ResendSendEmailOptions {
return {
// Required
from: mapFromAddress(message.from, defaultFromName, defaultFromAddress),
subject: message.subject ?? '',
to: mapAddresses(message.to),
// Other To fields
bcc: mapAddresses(message.bcc),
cc: mapAddresses(message.cc),
// Optional
attachments: mapAttachments(message.attachments),
html: message.html?.toString() || '',
text: message.text?.toString() || '',
} as ResendSendEmailOptions
}
function mapFromAddress(
address: SendEmailOptions['from'],
defaultFromName: string,
defaultFromAddress: string,
): ResendSendEmailOptions['from'] {
if (!address) {
return `${defaultFromName} <${defaultFromAddress}>`
}
if (typeof address === 'string') {
return address
}
return `${address.name} <${address.address}>`
}
function mapAddresses(addresses: SendEmailOptions['to']): ResendSendEmailOptions['to'] {
if (!addresses) {
return ''
}
if (typeof addresses === 'string') {
return addresses
}
if (Array.isArray(addresses)) {
return addresses.map((address) => (typeof address === 'string' ? address : address.address))
}
return [addresses.address]
}
function mapAttachments(
attachments: SendEmailOptions['attachments'],
): ResendSendEmailOptions['attachments'] {
if (!attachments) {
return []
}
return attachments.map((attachment) => {
if (!attachment.filename || !attachment.content) {
throw new APIError('Attachment is missing filename or content', 400)
}
if (typeof attachment.content === 'string') {
return {
content: Buffer.from(attachment.content),
filename: attachment.filename,
}
}
if (attachment.content instanceof Buffer) {
return {
content: attachment.content,
filename: attachment.filename,
}
}
throw new APIError('Attachment content must be a string or a buffer', 400)
})
}
type ResendSendEmailOptions = {
/**
* Filename and content of attachments (max 40mb per email)
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
attachments?: Attachment[]
/**
* Blind carbon copy recipient email address. For multiple addresses, send as an array of strings.
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
bcc?: string | string[]
/**
* Carbon copy recipient email address. For multiple addresses, send as an array of strings.
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
cc?: string | string[]
/**
* Sender email address. To include a friendly name, use the format `"Your Name <sender@domain.com>"`
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
from: string
/**
* Custom headers to add to the email.
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
headers?: Record<string, string>
/**
* The HTML version of the message.
*
* @link https://resend.com/api-reference/emails/send-email#body-parameters
*/
html?: string
/**
* Reply-to email address. For multiple addresses, send as an array of strings.
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
reply_to?: string | string[]
/**
* Email subject.
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
subject: string
/**
* Email tags
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
tags?: Tag[]
/**
* The plain text version of the message.
*
* @link https://resend.com/api-reference/emails/send-email#body-parameters
*/
text?: string
/**
* Recipient email address. For multiple addresses, send as an array of strings. Max 50.
*
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
*/
to: string | string[]
}
type Attachment = {
/** Content of an attached file. */
content?: Buffer | string
/** Name of attached file. */
filename?: false | string | undefined
/** Path where the attachment file is hosted */
path?: string
}
export type Tag = {
/**
* The name of the email tag. It can only contain ASCII letters (az, AZ), numbers (09), underscores (_), or dashes (-). It can contain no more than 256 characters.
*/
name: string
/**
* The value of the email tag. It can only contain ASCII letters (az, AZ), numbers (09), underscores (_), or dashes (-). It can contain no more than 256 characters.
*/
value: string
}

View File

@@ -0,0 +1,20 @@
{
"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",
"src/**/*.spec.ts",
],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"],
"references": [
{ "path": "../payload" },
]
}

15
pnpm-lock.yaml generated
View File

@@ -452,6 +452,18 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../payload version: link:../payload
packages/email-resend-rest:
devDependencies:
'@types/jest':
specifier: 29.5.12
version: 29.5.12
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.12.5)(ts-node@10.9.1)
payload:
specifier: workspace:*
version: link:../payload
packages/eslint-config-payload: packages/eslint-config-payload:
dependencies: dependencies:
'@types/eslint': '@types/eslint':
@@ -1604,6 +1616,9 @@ importers:
'@payloadcms/email-nodemailer': '@payloadcms/email-nodemailer':
specifier: workspace:* specifier: workspace:*
version: link:../packages/email-nodemailer version: link:../packages/email-nodemailer
'@payloadcms/email-resend-rest':
specifier: workspace:*
version: link:../packages/email-resend-rest
'@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-resend-rest/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,31 @@
import { resendAdapter } from '@payloadcms/email-resend-rest'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
export default buildConfigWithDefaults({
// ...extend config here
collections: [],
email: resendAdapter({
defaultFromAddress: 'dev@payloadcms.com',
defaultFromName: 'Payload CMS',
apiKey: process.env.RESEND_API_KEY || '',
}),
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
const email = await payload.sendEmail({
to: 'dev@payloadcms.com',
subject: 'This was sent on init',
text: 'This is my message body',
})
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

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