mirror of
https://github.com/xtr-dev/payload-mailing.git
synced 2025-12-10 08:13:23 +00:00
Add mailing plugin with templates, outbox, and job processing
This commit is contained in:
291
README.md
Normal file
291
README.md
Normal 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
2
dev/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
DATABASE_URI=mongodb://127.0.0.1/payload-plugin-template
|
||||||
|
PAYLOAD_SECRET=YOUR_SECRET_HERE
|
||||||
25
dev/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
25
dev/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal 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
|
||||||
25
dev/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
25
dev/app/(payload)/admin/[[...segments]]/page.tsx
Normal 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
|
||||||
9
dev/app/(payload)/admin/importMap.js
Normal file
9
dev/app/(payload)/admin/importMap.js
Normal 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,
|
||||||
|
}
|
||||||
19
dev/app/(payload)/api/[...slug]/route.ts
Normal file
19
dev/app/(payload)/api/[...slug]/route.ts
Normal 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)
|
||||||
7
dev/app/(payload)/api/graphql-playground/route.ts
Normal file
7
dev/app/(payload)/api/graphql-playground/route.ts
Normal 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)
|
||||||
8
dev/app/(payload)/api/graphql/route.ts
Normal file
8
dev/app/(payload)/api/graphql/route.ts
Normal 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)
|
||||||
0
dev/app/(payload)/custom.scss
Normal file
0
dev/app/(payload)/custom.scss
Normal file
32
dev/app/(payload)/layout.tsx
Normal file
32
dev/app/(payload)/layout.tsx
Normal 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
12
dev/app/my-route/route.ts
Normal 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
15
dev/e2e.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
4
dev/helpers/credentials.ts
Normal file
4
dev/helpers/credentials.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const devUser = {
|
||||||
|
email: 'dev@payloadcms.com',
|
||||||
|
password: 'test',
|
||||||
|
}
|
||||||
38
dev/helpers/testEmailAdapter.ts
Normal file
38
dev/helpers/testEmailAdapter.ts
Normal 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
52
dev/int.spec.ts
Normal 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
5
dev/next-env.d.ts
vendored
Normal 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
21
dev/next.config.mjs
Normal 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
276
dev/payload-types.ts
Normal 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
75
dev/payload.config.ts
Normal 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
21
dev/seed.ts
Normal 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
35
dev/tsconfig.json
Normal 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
105
package.json
Normal 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
11873
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
180
src/collections/EmailOutbox.ts
Normal file
180
src/collections/EmailOutbox.ts
Normal 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
|
||||||
105
src/collections/EmailTemplates.ts
Normal file
105
src/collections/EmailTemplates.ts
Normal 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
25
src/index.ts
Normal 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
20
src/jobs/index.ts
Normal 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'
|
||||||
50
src/jobs/processOutboxJob.ts
Normal file
50
src/jobs/processOutboxJob.ts
Normal 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
98
src/plugin.ts
Normal 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
|
||||||
317
src/services/MailingService.ts
Normal file
317
src/services/MailingService.ts
Normal 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
93
src/types/index.ts
Normal 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
30
src/utils/helpers.ts
Normal 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
36
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user