Add mailing plugin with templates, outbox, and job processing

This commit is contained in:
2025-09-12 19:18:14 +02:00
parent ebaed4fd07
commit ed9d979d3e
33 changed files with 13904 additions and 0 deletions

291
README.md Normal file
View File

@@ -0,0 +1,291 @@
# @xtr-dev/payload-mailing
📧 **Template-based email system with scheduling and job processing for PayloadCMS**
## Features
**Template System**: Create reusable email templates with Handlebars syntax
**Outbox Scheduling**: Schedule emails for future delivery
**Job Integration**: Automatic processing via PayloadCMS jobs queue
**Retry Failed Sends**: Automatic retry mechanism for failed emails
**Template Variables**: Dynamic content with validation
**Developer API**: Simple methods for sending emails programmatically
## Installation
```bash
npm install @xtr-dev/payload-mailing
```
## Quick Start
### 1. Add the plugin to your Payload config
```typescript
import { buildConfig } from 'payload/config'
import { mailingPlugin } from '@xtr-dev/payload-mailing'
export default buildConfig({
// ... your config
plugins: [
mailingPlugin({
defaultFrom: 'noreply@yoursite.com',
transport: {
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
},
retryAttempts: 3,
retryDelay: 300000, // 5 minutes
queue: 'email-queue', // optional
}),
],
})
```
### 2. Send emails in your code
```typescript
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
// Send immediately
const emailId = await sendEmail(payload, {
templateId: 'welcome-email',
to: 'user@example.com',
variables: {
firstName: 'John',
welcomeUrl: 'https://yoursite.com/welcome'
}
})
// Schedule for later
const scheduledId = await scheduleEmail(payload, {
templateId: 'reminder-email',
to: 'user@example.com',
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
variables: {
eventName: 'Product Launch',
eventDate: new Date('2024-01-15')
}
})
```
## Configuration
### Plugin Options
```typescript
interface MailingPluginConfig {
collections?: {
templates?: string // default: 'email-templates'
outbox?: string // default: 'email-outbox'
}
defaultFrom?: string
transport?: Transporter | MailingTransportConfig
queue?: string // default: 'default'
retryAttempts?: number // default: 3
retryDelay?: number // default: 300000 (5 minutes)
}
```
### Transport Configuration
You can provide either a Nodemailer transporter instance or configuration:
```typescript
// Using configuration object
{
transport: {
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
}
}
// Or using a transporter instance
import nodemailer from 'nodemailer'
{
transport: nodemailer.createTransporter({
// your config
})
}
```
## Creating Email Templates
1. Go to your Payload admin panel
2. Navigate to **Mailing > Email Templates**
3. Create a new template with:
- **Name**: Descriptive name for the template
- **Subject**: Email subject (supports Handlebars)
- **HTML Template**: HTML content with Handlebars syntax
- **Text Template**: Plain text version (optional)
- **Variables**: Define available variables
### Template Example
**Subject**: `Welcome to {{siteName}}, {{firstName}}!`
**HTML Template**:
```html
<h1>Welcome {{firstName}}!</h1>
<p>Thanks for joining {{siteName}}. We're excited to have you!</p>
{{#if isPremium}}
<p><strong>Premium Benefits:</strong></p>
<ul>
<li>Priority support</li>
<li>Advanced features</li>
<li>Monthly reports</li>
</ul>
{{/if}}
<p>Your account was created on {{formatDate createdAt 'long'}}.</p>
<p>Visit your dashboard: <a href="{{dashboardUrl}}">Get Started</a></p>
<hr>
<p>Best regards,<br>The {{siteName}} Team</p>
```
## Handlebars Helpers
The plugin includes several built-in helpers:
- `{{formatDate date 'short'}}` - Format dates (short, long, or default)
- `{{formatCurrency amount 'USD'}}` - Format currency
- `{{capitalize string}}` - Capitalize first letter
- `{{#ifEquals value1 value2}}...{{/ifEquals}}` - Conditional equality
## API Methods
### sendEmail(payload, options)
Send an email immediately:
```typescript
const emailId = await sendEmail(payload, {
templateId: 'order-confirmation', // optional
to: 'customer@example.com',
cc: 'manager@example.com', // optional
bcc: 'archive@example.com', // optional
from: 'orders@yoursite.com', // optional, uses default
replyTo: 'support@yoursite.com', // optional
subject: 'Custom subject', // required if no template
html: '<h1>Custom HTML</h1>', // required if no template
text: 'Custom text version', // optional
variables: { // template variables
orderNumber: '12345',
customerName: 'John Doe'
},
priority: 1 // optional, 1-10 (1 = highest)
})
```
### scheduleEmail(payload, options)
Schedule an email for later delivery:
```typescript
const emailId = await scheduleEmail(payload, {
templateId: 'newsletter',
to: ['user1@example.com', 'user2@example.com'],
scheduledAt: new Date('2024-01-15T10:00:00Z'),
variables: {
month: 'January',
highlights: ['Feature A', 'Feature B']
}
})
```
### processOutbox(payload)
Manually process pending emails:
```typescript
await processOutbox(payload)
```
### retryFailedEmails(payload)
Manually retry failed emails:
```typescript
await retryFailedEmails(payload)
```
## Job Processing
The plugin automatically processes emails using PayloadCMS jobs:
- **Outbox Processing**: Every 5 minutes
- **Failed Email Retry**: Every 30 minutes
Ensure you have jobs configured in your Payload config:
```typescript
export default buildConfig({
jobs: {
// Configure your job processing
tasks: [],
// ... other job config
},
})
```
## Email Status Tracking
All emails are stored in the outbox collection with these statuses:
- `pending` - Waiting to be sent
- `processing` - Currently being sent
- `sent` - Successfully delivered
- `failed` - Failed to send (will retry if attempts < retryAttempts)
## Monitoring
Check the **Mailing > Email Outbox** collection in your admin panel to:
- View email delivery status
- See error messages for failed sends
- Track retry attempts
- Monitor scheduled emails
## Environment Variables
```bash
# Email configuration
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USER=your-email@gmail.com
EMAIL_PASS=your-app-password
EMAIL_FROM=noreply@yoursite.com
```
## TypeScript Support
The plugin includes full TypeScript definitions. Import types as needed:
```typescript
import {
MailingPluginConfig,
SendEmailOptions,
EmailTemplate,
OutboxEmail
} from '@xtr-dev/payload-mailing'
```
## License
MIT
## Contributing
Issues and pull requests welcome at [GitHub repository](https://github.com/xtr-dev/payload-mailing)

2
dev/.env.example Normal file
View File

@@ -0,0 +1,2 @@
DATABASE_URI=mongodb://127.0.0.1/payload-plugin-template
PAYLOAD_SECRET=YOUR_SECRET_HERE

View File

@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, importMap, params, searchParams })
export default NotFound

View File

@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { generatePageMetadata, RootPage } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, importMap, params, searchParams })
export default Page

View File

@@ -0,0 +1,9 @@
import { BeforeDashboardClient as BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343 } from 'temp-project/client'
import { BeforeDashboardServer as BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f } from 'temp-project/rsc'
export const importMap = {
'temp-project/client#BeforeDashboardClient':
BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343,
'temp-project/rsc#BeforeDashboardServer':
BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f,
}

View File

@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

View File

@@ -0,0 +1,32 @@
import type { ServerFunctionClient } from 'payload'
import '@payloadcms/next/css'
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

12
dev/app/my-route/route.ts Normal file
View File

@@ -0,0 +1,12 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export const GET = async (request: Request) => {
const payload = await getPayload({
config: configPromise,
})
return Response.json({
message: 'This is an example of a custom route.',
})
}

15
dev/e2e.spec.ts Normal file
View File

@@ -0,0 +1,15 @@
import { expect, test } from '@playwright/test'
// this is an example Playwright e2e test
test('should render admin panel logo', async ({ page }) => {
await page.goto('/admin')
// login
await page.fill('#field-email', 'dev@payloadcms.com')
await page.fill('#field-password', 'test')
await page.click('.form-submit button')
// should show dashboard
await expect(page).toHaveTitle(/Dashboard/)
await expect(page.locator('.graphic-icon')).toBeVisible()
})

View File

@@ -0,0 +1,4 @@
export const devUser = {
email: 'dev@payloadcms.com',
password: 'test',
}

View File

@@ -0,0 +1,38 @@
import type { EmailAdapter, SendEmailOptions } from 'payload'
/**
* Logs all emails to stdout
*/
export const testEmailAdapter: EmailAdapter<void> = ({ payload }) => ({
name: 'test-email-adapter',
defaultFromAddress: 'dev@payloadcms.com',
defaultFromName: 'Payload Test',
sendEmail: async (message) => {
const stringifiedTo = getStringifiedToAddress(message)
const res = `Test email to: '${stringifiedTo}', Subject: '${message.subject}'`
payload.logger.info({ content: message, msg: res })
return Promise.resolve()
},
})
function 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: { address: string } | string) => {
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
}

52
dev/int.spec.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { Payload } from 'payload'
import config from '@payload-config'
import { createPayloadRequest, getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js'
let payload: Payload
afterAll(async () => {
await payload.destroy()
})
beforeAll(async () => {
payload = await getPayload({ config })
})
describe('Plugin integration tests', () => {
test('should query custom endpoint added by plugin', async () => {
const request = new Request('http://localhost:3000/api/my-plugin-endpoint', {
method: 'GET',
})
const payloadRequest = await createPayloadRequest({ config, request })
const response = await customEndpointHandler(payloadRequest)
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toMatchObject({
message: 'Hello from custom endpoint',
})
})
test('can create post with custom text field added by plugin', async () => {
const post = await payload.create({
collection: 'posts',
data: {
addedByPlugin: 'added by plugin',
},
})
expect(post.addedByPlugin).toBe('added by plugin')
})
test('plugin creates and seeds plugin-collection', async () => {
expect(payload.collections['plugin-collection']).toBeDefined()
const { docs } = await payload.find({ collection: 'plugin-collection' })
expect(docs).toHaveLength(1)
})
})

5
dev/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

21
dev/next.config.mjs Normal file
View File

@@ -0,0 +1,21 @@
import { withPayload } from '@payloadcms/next/withPayload'
import { fileURLToPath } from 'url'
import path from 'path'
const dirname = path.dirname(fileURLToPath(import.meta.url))
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
serverExternalPackages: ['mongodb-memory-server'],
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

276
dev/payload-types.ts Normal file
View File

@@ -0,0 +1,276 @@
/* tslint:disable */
/* eslint-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 {
auth: {
users: UserAuthOperations;
};
collections: {
posts: Post;
media: Media;
'plugin-collection': PluginCollection;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
'plugin-collection': PluginCollectionSelect<false> | PluginCollectionSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
addedByPlugin?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "plugin-collection".
*/
export interface PluginCollection {
id: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'plugin-collection';
value: string | PluginCollection;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
addedByPlugin?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "plugin-collection_select".
*/
export interface PluginCollectionSelect<T extends boolean = true> {
id?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

75
dev/payload.config.ts Normal file
View File

@@ -0,0 +1,75 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path'
import { buildConfig } from 'payload'
import { tempProject } from 'temp-project'
import sharp from 'sharp'
import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
import { seed } from './seed.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
if (!process.env.ROOT_DIR) {
process.env.ROOT_DIR = dirname
}
const buildConfigWithMemoryDB = async () => {
if (process.env.NODE_ENV === 'test') {
const memoryDB = await MongoMemoryReplSet.create({
replSet: {
count: 3,
dbName: 'payloadmemory',
},
})
process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true`
}
return buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [
{
slug: 'posts',
fields: [],
},
{
slug: 'media',
fields: [],
upload: {
staticDir: path.resolve(dirname, 'media'),
},
},
],
db: mongooseAdapter({
ensureIndexes: true,
url: process.env.DATABASE_URI || '',
}),
editor: lexicalEditor(),
email: testEmailAdapter,
onInit: async (payload) => {
await seed(payload)
},
plugins: [
tempProject({
collections: {
posts: true,
},
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
sharp,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
}
export default buildConfigWithMemoryDB()

21
dev/seed.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { Payload } from 'payload'
import { devUser } from './helpers/credentials.js'
export const seed = async (payload: Payload) => {
const { totalDocs } = await payload.count({
collection: 'users',
where: {
email: {
equals: devUser.email,
},
},
})
if (!totalDocs) {
await payload.create({
collection: 'users',
data: devUser,
})
}
}

35
dev/tsconfig.json Normal file
View File

@@ -0,0 +1,35 @@
{
"extends": "../tsconfig.json",
"exclude": [],
"include": [
"**/*.js",
"**/*.jsx",
"**/*.mjs",
"**/*.cjs",
"**/*.ts",
"**/*.tsx",
"../src/**/*.ts",
"../src/**/*.tsx",
"next.config.mjs",
".next/types/**/*.ts"
],
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@payload-config": [
"./payload.config.ts"
],
"temp-project": [
"../src/index.ts"
],
"temp-project/client": [
"../src/exports/client.ts"
],
"temp-project/rsc": [
"../src/exports/rsc.ts"
]
},
"noEmit": true,
"emitDeclarationOnly": false,
}
}

105
package.json Normal file
View File

@@ -0,0 +1,105 @@
{
"name": "@xtr-dev/payload-mailing",
"version": "1.0.0",
"description": "Template-based email system with scheduling and job processing for PayloadCMS",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "npm run copyfiles && npm run build:types && npm run build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --outDir dist --rootDir ./src",
"clean": "rimraf {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"dev": "next dev dev --turbo",
"dev:generate-importmap": "npm run dev:payload generate:importmap",
"dev:generate-types": "npm run dev:payload generate:types",
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
"generate:importmap": "npm run dev:generate-importmap",
"generate:types": "npm run dev:generate-types",
"lint": "eslint",
"lint:fix": "eslint ./src --fix",
"prepublishOnly": "npm run clean && npm run build",
"test": "npm run test:int && npm run test:e2e",
"test:e2e": "playwright test",
"test:int": "vitest"
},
"keywords": [
"payloadcms",
"email",
"templates",
"scheduling",
"jobs",
"mailing",
"handlebars"
],
"author": "XTR Development",
"license": "MIT",
"peerDependencies": {
"payload": "^3.37.0"
},
"dependencies": {
"handlebars": "^4.7.8",
"nodemailer": "^6.9.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@payloadcms/db-mongodb": "^3.37.0",
"@payloadcms/db-postgres": "^3.37.0",
"@payloadcms/db-sqlite": "^3.37.0",
"@payloadcms/eslint-config": "^3.9.0",
"@payloadcms/next": "^3.37.0",
"@payloadcms/richtext-lexical": "^3.37.0",
"@payloadcms/ui": "^3.37.0",
"@playwright/test": "^1.52.0",
"@swc-node/register": "^1.10.9",
"@swc/cli": "^0.6.0",
"@types/node": "^22.5.4",
"@types/nodemailer": "^6.4.14",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"eslint": "^9.23.0",
"eslint-config-next": "^15.4.4",
"graphql": "^16.8.1",
"mongodb-memory-server": "^10.1.4",
"next": "^15.4.4",
"open": "^10.1.0",
"payload": "^3.37.0",
"prettier": "^3.4.2",
"qs-esm": "^7.0.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"rimraf": "^3.0.2",
"sharp": "^0.34.2",
"sort-package-json": "^2.10.0",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.2"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",
"npm": ">=8.0.0"
},
"files": [
"dist/**/*",
"README.md"
],
"repository": {
"type": "git",
"url": "https://github.com/xtr-dev/payload-mailing.git"
},
"bugs": {
"url": "https://github.com/xtr-dev/payload-mailing/issues"
},
"homepage": "https://github.com/xtr-dev/payload-mailing#readme",
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184"
}

11873
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
import { CollectionConfig } from 'payload/types'
const EmailOutbox: CollectionConfig = {
slug: 'email-outbox',
admin: {
useAsTitle: 'subject',
defaultColumns: ['subject', 'to', 'status', 'scheduledAt', 'sentAt'],
group: 'Mailing',
},
access: {
read: () => true,
create: () => true,
update: () => true,
delete: () => true,
},
fields: [
{
name: 'template',
type: 'relationship',
relationTo: 'email-templates',
admin: {
description: 'Email template used (optional if custom content provided)',
},
},
{
name: 'to',
type: 'text',
required: true,
admin: {
description: 'Recipient email address(es), comma-separated',
},
},
{
name: 'cc',
type: 'text',
admin: {
description: 'CC email address(es), comma-separated',
},
},
{
name: 'bcc',
type: 'text',
admin: {
description: 'BCC email address(es), comma-separated',
},
},
{
name: 'from',
type: 'text',
admin: {
description: 'Sender email address (optional, uses default if not provided)',
},
},
{
name: 'replyTo',
type: 'text',
admin: {
description: 'Reply-to email address',
},
},
{
name: 'subject',
type: 'text',
required: true,
admin: {
description: 'Email subject line',
},
},
{
name: 'html',
type: 'textarea',
required: true,
admin: {
description: 'Rendered HTML content of the email',
rows: 8,
},
},
{
name: 'text',
type: 'textarea',
admin: {
description: 'Plain text version of the email',
rows: 6,
},
},
{
name: 'variables',
type: 'json',
admin: {
description: 'Template variables used to render this email',
},
},
{
name: 'scheduledAt',
type: 'date',
admin: {
description: 'When this email should be sent (leave empty for immediate)',
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'sentAt',
type: 'date',
admin: {
description: 'When this email was actually sent',
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'status',
type: 'select',
required: true,
options: [
{ label: 'Pending', value: 'pending' },
{ label: 'Processing', value: 'processing' },
{ label: 'Sent', value: 'sent' },
{ label: 'Failed', value: 'failed' },
],
defaultValue: 'pending',
admin: {
description: 'Current status of this email',
},
},
{
name: 'attempts',
type: 'number',
defaultValue: 0,
admin: {
description: 'Number of send attempts made',
},
},
{
name: 'lastAttemptAt',
type: 'date',
admin: {
description: 'When the last send attempt was made',
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'error',
type: 'textarea',
admin: {
description: 'Last error message if send failed',
rows: 3,
},
},
{
name: 'priority',
type: 'number',
defaultValue: 5,
admin: {
description: 'Email priority (1=highest, 10=lowest)',
},
},
],
timestamps: true,
indexes: [
{
fields: {
status: 1,
scheduledAt: 1,
},
},
{
fields: {
priority: -1,
createdAt: 1,
},
},
],
}
export default EmailOutbox

View File

@@ -0,0 +1,105 @@
import { CollectionConfig } from 'payload/types'
const EmailTemplates: CollectionConfig = {
slug: 'email-templates',
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'subject', 'updatedAt'],
group: 'Mailing',
},
access: {
read: () => true,
create: () => true,
update: () => true,
delete: () => true,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
admin: {
description: 'A descriptive name for this email template',
},
},
{
name: 'subject',
type: 'text',
required: true,
admin: {
description: 'Email subject line (supports Handlebars variables)',
},
},
{
name: 'htmlTemplate',
type: 'textarea',
required: true,
admin: {
description: 'HTML email template (supports Handlebars syntax)',
rows: 10,
},
},
{
name: 'textTemplate',
type: 'textarea',
admin: {
description: 'Plain text email template (supports Handlebars syntax)',
rows: 8,
},
},
{
name: 'variables',
type: 'array',
admin: {
description: 'Define variables that can be used in this template',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
admin: {
description: 'Variable name (e.g., "firstName", "orderTotal")',
},
},
{
name: 'type',
type: 'select',
required: true,
options: [
{ label: 'Text', value: 'text' },
{ label: 'Number', value: 'number' },
{ label: 'Boolean', value: 'boolean' },
{ label: 'Date', value: 'date' },
],
defaultValue: 'text',
},
{
name: 'required',
type: 'checkbox',
defaultValue: false,
admin: {
description: 'Is this variable required when sending emails?',
},
},
{
name: 'description',
type: 'text',
admin: {
description: 'Optional description of what this variable represents',
},
},
],
},
{
name: 'previewData',
type: 'json',
admin: {
description: 'Sample data for previewing this template (JSON format)',
},
},
],
timestamps: true,
}
export default EmailTemplates

25
src/index.ts Normal file
View File

@@ -0,0 +1,25 @@
// Main plugin export
export { default as mailingPlugin } from './plugin'
export { mailingPlugin } from './plugin'
// Types
export * from './types'
// Services
export { MailingService } from './services/MailingService'
// Collections
export { default as EmailTemplates } from './collections/EmailTemplates'
export { default as EmailOutbox } from './collections/EmailOutbox'
// Jobs
export * from './jobs'
// Utility functions for developers
export {
getMailing,
sendEmail,
scheduleEmail,
processOutbox,
retryFailedEmails,
} from './utils/helpers'

20
src/jobs/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Job } from 'payload/jobs'
import { processOutboxJob, ProcessOutboxJobData } from './processOutboxJob'
import { MailingService } from '../services/MailingService'
export const createMailingJobs = (mailingService: MailingService): Job[] => {
return [
{
slug: 'processOutbox',
handler: async ({ job, req }) => {
return processOutboxJob(
job as { data: ProcessOutboxJobData },
{ req, mailingService }
)
},
interfaceName: 'ProcessOutboxJob',
},
]
}
export * from './processOutboxJob'

View File

@@ -0,0 +1,50 @@
import { PayloadRequest } from 'payload/types'
import { MailingService } from '../services/MailingService'
export interface ProcessOutboxJobData {
type: 'process-outbox' | 'retry-failed'
}
export const processOutboxJob = async (
job: { data: ProcessOutboxJobData },
context: { req: PayloadRequest; mailingService: MailingService }
) => {
const { mailingService } = context
const { type } = job.data
try {
if (type === 'process-outbox') {
await mailingService.processOutbox()
console.log('Outbox processing completed successfully')
} else if (type === 'retry-failed') {
await mailingService.retryFailedEmails()
console.log('Failed email retry completed successfully')
}
} catch (error) {
console.error(`${type} job failed:`, error)
throw error
}
}
export const scheduleOutboxJob = async (
payload: any,
queueName: string,
jobType: 'process-outbox' | 'retry-failed',
delay?: number
) => {
if (!payload.jobs) {
console.warn('PayloadCMS jobs not configured - emails will not be processed automatically')
return
}
try {
await payload.jobs.queue({
queue: queueName,
task: 'processOutbox',
input: { type: jobType },
waitUntil: delay ? new Date(Date.now() + delay) : undefined,
})
} catch (error) {
console.error(`Failed to schedule ${jobType} job:`, error)
}
}

98
src/plugin.ts Normal file
View File

@@ -0,0 +1,98 @@
import { Config } from 'payload/config'
import { MailingPluginConfig, MailingContext } from './types'
import { MailingService } from './services/MailingService'
import { createMailingJobs } from './jobs'
import EmailTemplates from './collections/EmailTemplates'
import EmailOutbox from './collections/EmailOutbox'
import { scheduleOutboxJob } from './jobs/processOutboxJob'
export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
const templatesSlug = pluginConfig.collections?.templates || 'email-templates'
const outboxSlug = pluginConfig.collections?.outbox || 'email-outbox'
const queueName = pluginConfig.queue || 'default'
// Update collection slugs if custom ones are provided
const templatesCollection = {
...EmailTemplates,
slug: templatesSlug,
}
const outboxCollection = {
...EmailOutbox,
slug: outboxSlug,
fields: EmailOutbox.fields.map(field => {
if (field.name === 'template' && field.type === 'relationship') {
return {
...field,
relationTo: templatesSlug,
}
}
return field
}),
}
return {
...config,
collections: [
...(config.collections || []),
templatesCollection,
outboxCollection,
],
jobs: {
...(config.jobs || {}),
tasks: [
...(config.jobs?.tasks || []),
// Jobs will be added via onInit hook
],
},
onInit: async (payload) => {
// Call original onInit if it exists
if (config.onInit) {
await config.onInit(payload)
}
// Initialize mailing service
const mailingService = new MailingService(payload, pluginConfig)
// Add mailing jobs
const mailingJobs = createMailingJobs(mailingService)
if (payload.jobs) {
mailingJobs.forEach(job => {
payload.jobs.addTask(job)
})
}
// Schedule periodic outbox processing (every 5 minutes)
const schedulePeriodicJob = async () => {
await scheduleOutboxJob(payload, queueName, 'process-outbox', 5 * 60 * 1000) // 5 minutes
setTimeout(schedulePeriodicJob, 5 * 60 * 1000) // Schedule next run
}
// Schedule periodic retry job (every 30 minutes)
const scheduleRetryJob = async () => {
await scheduleOutboxJob(payload, queueName, 'retry-failed', 30 * 60 * 1000) // 30 minutes
setTimeout(scheduleRetryJob, 30 * 60 * 1000) // Schedule next run
}
// Start periodic jobs if jobs are enabled
if (payload.jobs) {
setTimeout(schedulePeriodicJob, 5 * 60 * 1000) // Start after 5 minutes
setTimeout(scheduleRetryJob, 15 * 60 * 1000) // Start after 15 minutes
}
// Add mailing context to payload for developer access
;(payload as any).mailing = {
service: mailingService,
config: pluginConfig,
collections: {
templates: templatesSlug,
outbox: outboxSlug,
},
} as MailingContext
console.log('PayloadCMS Mailing Plugin initialized successfully')
},
}
}
export default mailingPlugin

View File

@@ -0,0 +1,317 @@
import { Payload } from 'payload'
import Handlebars from 'handlebars'
import nodemailer, { Transporter } from 'nodemailer'
import {
MailingPluginConfig,
SendEmailOptions,
MailingService as IMailingService,
EmailTemplate,
OutboxEmail,
MailingTransportConfig
} from '../types'
export class MailingService implements IMailingService {
private payload: Payload
private config: MailingPluginConfig
private transporter: Transporter
private templatesCollection: string
private outboxCollection: string
constructor(payload: Payload, config: MailingPluginConfig) {
this.payload = payload
this.config = config
this.templatesCollection = config.collections?.templates || 'email-templates'
this.outboxCollection = config.collections?.outbox || 'email-outbox'
this.initializeTransporter()
this.registerHandlebarsHelpers()
}
private initializeTransporter(): void {
if (this.config.transport) {
if ('sendMail' in this.config.transport) {
this.transporter = this.config.transport
} else {
this.transporter = nodemailer.createTransporter(this.config.transport as MailingTransportConfig)
}
} else {
throw new Error('Email transport configuration is required')
}
}
private registerHandlebarsHelpers(): void {
Handlebars.registerHelper('formatDate', (date: Date, format?: string) => {
if (!date) return ''
const d = new Date(date)
if (format === 'short') {
return d.toLocaleDateString()
}
if (format === 'long') {
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
return d.toLocaleString()
})
Handlebars.registerHelper('formatCurrency', (amount: number, currency = 'USD') => {
if (typeof amount !== 'number') return amount
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(amount)
})
Handlebars.registerHelper('ifEquals', function(arg1: any, arg2: any, options: any) {
return (arg1 === arg2) ? options.fn(this) : options.inverse(this)
})
Handlebars.registerHelper('capitalize', (str: string) => {
if (typeof str !== 'string') return str
return str.charAt(0).toUpperCase() + str.slice(1)
})
}
async sendEmail(options: SendEmailOptions): Promise<string> {
const outboxId = await this.scheduleEmail({
...options,
scheduledAt: new Date()
})
await this.processOutboxItem(outboxId)
return outboxId
}
async scheduleEmail(options: SendEmailOptions): Promise<string> {
let html = options.html || ''
let text = options.text || ''
let subject = options.subject || ''
if (options.templateId) {
const template = await this.getTemplate(options.templateId)
if (template) {
const variables = options.variables || {}
html = this.renderTemplate(template.htmlTemplate, variables)
text = template.textTemplate ? this.renderTemplate(template.textTemplate, variables) : ''
subject = this.renderTemplate(template.subject, variables)
}
}
if (!subject && !options.subject) {
throw new Error('Email subject is required')
}
if (!html && !options.html) {
throw new Error('Email HTML content is required')
}
const outboxData = {
template: options.templateId || undefined,
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
cc: options.cc ? (Array.isArray(options.cc) ? options.cc.join(', ') : options.cc) : undefined,
bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc.join(', ') : options.bcc) : undefined,
from: options.from || this.config.defaultFrom,
replyTo: options.replyTo,
subject: subject || options.subject,
html,
text,
variables: options.variables,
scheduledAt: options.scheduledAt?.toISOString(),
status: 'pending' as const,
attempts: 0,
priority: options.priority || 5,
}
const result = await this.payload.create({
collection: this.outboxCollection,
data: outboxData,
})
return result.id as string
}
async processOutbox(): Promise<void> {
const currentTime = new Date().toISOString()
const { docs: pendingEmails } = await this.payload.find({
collection: this.outboxCollection,
where: {
and: [
{
status: {
equals: 'pending',
},
},
{
or: [
{
scheduledAt: {
exists: false,
},
},
{
scheduledAt: {
less_than_equal: currentTime,
},
},
],
},
],
},
sort: 'priority,-createdAt',
limit: 50,
})
for (const email of pendingEmails) {
await this.processOutboxItem(email.id)
}
}
async retryFailedEmails(): Promise<void> {
const maxAttempts = this.config.retryAttempts || 3
const retryDelay = this.config.retryDelay || 300000 // 5 minutes
const retryTime = new Date(Date.now() - retryDelay).toISOString()
const { docs: failedEmails } = await this.payload.find({
collection: this.outboxCollection,
where: {
and: [
{
status: {
equals: 'failed',
},
},
{
attempts: {
less_than: maxAttempts,
},
},
{
or: [
{
lastAttemptAt: {
exists: false,
},
},
{
lastAttemptAt: {
less_than: retryTime,
},
},
],
},
],
},
limit: 20,
})
for (const email of failedEmails) {
await this.processOutboxItem(email.id)
}
}
private async processOutboxItem(outboxId: string): Promise<void> {
try {
await this.payload.update({
collection: this.outboxCollection,
id: outboxId,
data: {
status: 'processing',
lastAttemptAt: new Date().toISOString(),
},
})
const email = await this.payload.findByID({
collection: this.outboxCollection,
id: outboxId,
}) as OutboxEmail
const mailOptions = {
from: email.from || this.config.defaultFrom,
to: email.to,
cc: email.cc || undefined,
bcc: email.bcc || undefined,
replyTo: email.replyTo || undefined,
subject: email.subject,
html: email.html,
text: email.text || undefined,
}
await this.transporter.sendMail(mailOptions)
await this.payload.update({
collection: this.outboxCollection,
id: outboxId,
data: {
status: 'sent',
sentAt: new Date().toISOString(),
error: null,
},
})
} catch (error) {
const attempts = await this.incrementAttempts(outboxId)
const maxAttempts = this.config.retryAttempts || 3
await this.payload.update({
collection: this.outboxCollection,
id: outboxId,
data: {
status: attempts >= maxAttempts ? 'failed' : 'pending',
error: error instanceof Error ? error.message : 'Unknown error',
lastAttemptAt: new Date().toISOString(),
},
})
if (attempts >= maxAttempts) {
console.error(`Email ${outboxId} failed permanently after ${attempts} attempts:`, error)
}
}
}
private async incrementAttempts(outboxId: string): Promise<number> {
const email = await this.payload.findByID({
collection: this.outboxCollection,
id: outboxId,
}) as OutboxEmail
const newAttempts = (email.attempts || 0) + 1
await this.payload.update({
collection: this.outboxCollection,
id: outboxId,
data: {
attempts: newAttempts,
},
})
return newAttempts
}
private async getTemplate(templateId: string): Promise<EmailTemplate | null> {
try {
const template = await this.payload.findByID({
collection: this.templatesCollection,
id: templateId,
})
return template as EmailTemplate
} catch (error) {
console.error(`Template ${templateId} not found:`, error)
return null
}
}
private renderTemplate(template: string, variables: Record<string, any>): string {
try {
const compiled = Handlebars.compile(template)
return compiled(variables)
} catch (error) {
console.error('Template rendering error:', error)
return template
}
}
}

93
src/types/index.ts Normal file
View File

@@ -0,0 +1,93 @@
import { Payload } from 'payload'
import { Transporter } from 'nodemailer'
export interface MailingPluginConfig {
collections?: {
templates?: string
outbox?: string
}
defaultFrom?: string
transport?: Transporter | MailingTransportConfig
queue?: string
retryAttempts?: number
retryDelay?: number
}
export interface MailingTransportConfig {
host: string
port: number
secure?: boolean
auth?: {
user: string
pass: string
}
}
export interface EmailTemplate {
id: string
name: string
subject: string
htmlTemplate: string
textTemplate?: string
variables?: TemplateVariable[]
createdAt: string
updatedAt: string
}
export interface TemplateVariable {
name: string
type: 'text' | 'number' | 'boolean' | 'date'
required: boolean
description?: string
}
export interface OutboxEmail {
id: string
templateId?: string
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
from?: string
replyTo?: string
subject: string
html: string
text?: string
variables?: Record<string, any>
scheduledAt?: string
sentAt?: string
status: 'pending' | 'processing' | 'sent' | 'failed'
attempts: number
lastAttemptAt?: string
error?: string
priority?: number
createdAt: string
updatedAt: string
}
export interface SendEmailOptions {
templateId?: string
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
from?: string
replyTo?: string
subject?: string
html?: string
text?: string
variables?: Record<string, any>
scheduledAt?: Date
priority?: number
}
export interface MailingService {
sendEmail(options: SendEmailOptions): Promise<string>
scheduleEmail(options: SendEmailOptions): Promise<string>
processOutbox(): Promise<void>
retryFailedEmails(): Promise<void>
}
export interface MailingContext {
payload: Payload
config: MailingPluginConfig
service: MailingService
}

30
src/utils/helpers.ts Normal file
View File

@@ -0,0 +1,30 @@
import { Payload } from 'payload'
import { SendEmailOptions } from '../types'
export const getMailing = (payload: Payload) => {
const mailing = (payload as any).mailing
if (!mailing) {
throw new Error('Mailing plugin not initialized. Make sure you have added the mailingPlugin to your Payload config.')
}
return mailing
}
export const sendEmail = async (payload: Payload, options: SendEmailOptions): Promise<string> => {
const mailing = getMailing(payload)
return mailing.service.sendEmail(options)
}
export const scheduleEmail = async (payload: Payload, options: SendEmailOptions): Promise<string> => {
const mailing = getMailing(payload)
return mailing.service.scheduleEmail(options)
}
export const processOutbox = async (payload: Payload): Promise<void> => {
const mailing = getMailing(payload)
return mailing.service.processOutbox()
}
export const retryFailedEmails = async (payload: Payload): Promise<void> => {
const mailing = getMailing(payload)
return mailing.service.retryFailedEmails()
}

36
tsconfig.json Normal file
View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"baseUrl": ".",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"rootDir": "./",
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"esModuleInterop": true,
"module": "NodeNext",
"moduleResolution": "nodenext",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"emitDeclarationOnly": true,
"target": "ES2022",
"composite": true,
"declaration": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"./src/**/*.ts",
"./src/**/*.tsx",
"./dev/next-env.d.ts"
],
"exclude": [
"node_modules",
"dist",
"temp-plugin-template"
]
}