mirror of
https://github.com/xtr-dev/payload-mailing.git
synced 2025-12-10 00:03:23 +00:00
Remove email outbox collection and process job; refactor email templates with rich text support and slug generation
This commit is contained in:
29
.env.example
Normal file
29
.env.example
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# PayloadCMS Configuration
|
||||||
|
PAYLOAD_SECRET=your-secret-key-here
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
# Leave DATABASE_URI empty to use in-memory MongoDB (recommended for development)
|
||||||
|
# DATABASE_URI=mongodb://localhost:27017/payload-mailing-dev
|
||||||
|
|
||||||
|
# Force in-memory database (optional, enabled by default if DATABASE_URI is not set)
|
||||||
|
USE_MEMORY_DB=true
|
||||||
|
|
||||||
|
# Email Configuration (Development)
|
||||||
|
EMAIL_HOST=localhost
|
||||||
|
EMAIL_PORT=1025
|
||||||
|
EMAIL_USER=test
|
||||||
|
EMAIL_PASS=test
|
||||||
|
EMAIL_FROM=noreply@test.com
|
||||||
|
|
||||||
|
# Development Settings
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Database Options:
|
||||||
|
# 1. In-memory MongoDB (default) - No installation required, data resets on restart
|
||||||
|
# 2. Local MongoDB - Install MongoDB and set DATABASE_URI
|
||||||
|
# 3. Remote MongoDB - Set DATABASE_URI to remote connection string
|
||||||
|
|
||||||
|
# Optional: Use MailHog for email testing
|
||||||
|
# MailHog runs on localhost:1025 for SMTP and localhost:8025 for web UI
|
||||||
|
# Download from: https://github.com/mailhog/MailHog
|
||||||
19
.swcrc
Normal file
19
.swcrc
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"jsc": {
|
||||||
|
"parser": {
|
||||||
|
"syntax": "typescript",
|
||||||
|
"tsx": true,
|
||||||
|
"decorators": true,
|
||||||
|
"dynamicImport": true
|
||||||
|
},
|
||||||
|
"target": "es2022",
|
||||||
|
"keepClassNames": true,
|
||||||
|
"externalHelpers": false,
|
||||||
|
"loose": false
|
||||||
|
},
|
||||||
|
"module": {
|
||||||
|
"type": "es6"
|
||||||
|
},
|
||||||
|
"sourceMaps": false,
|
||||||
|
"minify": false
|
||||||
|
}
|
||||||
169
DEVELOPMENT.md
Normal file
169
DEVELOPMENT.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# PayloadCMS Mailing Plugin - Development Guide
|
||||||
|
|
||||||
|
🚀 **Zero-Setup Development** with in-memory MongoDB - no database installation required!
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start dev server with in-memory MongoDB
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Alternative: Use startup script with helpful info
|
||||||
|
npm run dev:start
|
||||||
|
|
||||||
|
# Alternative: Force memory database
|
||||||
|
npm run dev:memory
|
||||||
|
```
|
||||||
|
|
||||||
|
## What You Get
|
||||||
|
|
||||||
|
### 🎯 **Instant Setup**
|
||||||
|
- ✅ **In-memory MongoDB** - no installation needed
|
||||||
|
- ✅ **Example templates** automatically created
|
||||||
|
- ✅ **Test interface** at `/mailing-test`
|
||||||
|
- ✅ **Admin panel** at `/admin`
|
||||||
|
- ✅ **API endpoints** for testing
|
||||||
|
|
||||||
|
### 📧 **Pre-loaded Templates**
|
||||||
|
1. **Welcome Email** - User onboarding with premium features
|
||||||
|
2. **Order Confirmation** - E-commerce receipt with items
|
||||||
|
3. **Password Reset** - Security email with expiring link
|
||||||
|
|
||||||
|
### 🔧 **Development Tools**
|
||||||
|
- **Web UI**: http://localhost:3000/mailing-test
|
||||||
|
- **Admin Panel**: http://localhost:3000/admin
|
||||||
|
- **GraphQL Playground**: http://localhost:3000/api/graphql-playground
|
||||||
|
- **API Endpoints**:
|
||||||
|
- `POST /api/test-email` - Send/schedule emails
|
||||||
|
- `POST /api/process-outbox` - Process email queue
|
||||||
|
|
||||||
|
## Database Options
|
||||||
|
|
||||||
|
### 🚀 **In-Memory (Default - Recommended)**
|
||||||
|
```bash
|
||||||
|
npm run dev # Automatic
|
||||||
|
```
|
||||||
|
- Zero setup required
|
||||||
|
- Fast startup (5-10 seconds)
|
||||||
|
- Data resets on restart
|
||||||
|
- Perfect for development
|
||||||
|
|
||||||
|
### 🔗 **External MongoDB**
|
||||||
|
```bash
|
||||||
|
DATABASE_URI=mongodb://localhost:27017/payload-mailing npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Email Testing
|
||||||
|
|
||||||
|
### MailHog (Recommended)
|
||||||
|
```bash
|
||||||
|
# Install and run MailHog
|
||||||
|
go install github.com/mailhog/MailHog@latest
|
||||||
|
MailHog
|
||||||
|
|
||||||
|
# View emails at: http://localhost:8025
|
||||||
|
```
|
||||||
|
|
||||||
|
### Console Output
|
||||||
|
The dev setup logs emails to console if no SMTP server is available.
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Start server**: `npm run dev`
|
||||||
|
2. **Make changes** to plugin source (`src/`)
|
||||||
|
3. **Rebuild plugin**: `npm run build`
|
||||||
|
4. **Test changes** in web interface or admin panel
|
||||||
|
5. **View results** in MailHog or console
|
||||||
|
|
||||||
|
## Plugin Testing
|
||||||
|
|
||||||
|
### Via Web Interface
|
||||||
|
1. Go to http://localhost:3000/mailing-test
|
||||||
|
2. Select a template
|
||||||
|
3. Fill in variables
|
||||||
|
4. Send or schedule email
|
||||||
|
5. Check MailHog for results
|
||||||
|
|
||||||
|
### Via Admin Panel
|
||||||
|
1. Go to http://localhost:3000/admin
|
||||||
|
2. Navigate to **Mailing > Email Templates**
|
||||||
|
3. View/edit templates
|
||||||
|
4. Navigate to **Mailing > Email Outbox**
|
||||||
|
5. Monitor email status
|
||||||
|
|
||||||
|
### Via API
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/test-email \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"type": "send",
|
||||||
|
"templateId": "TEMPLATE_ID",
|
||||||
|
"to": "test@example.com",
|
||||||
|
"variables": {
|
||||||
|
"firstName": "John",
|
||||||
|
"siteName": "Test App"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Startup Messages
|
||||||
|
|
||||||
|
When you start the dev server, look for these messages:
|
||||||
|
|
||||||
|
```
|
||||||
|
🚀 PayloadCMS Mailing Plugin - Development Mode
|
||||||
|
==================================================
|
||||||
|
📦 Using in-memory MongoDB (no installation required)
|
||||||
|
|
||||||
|
🔧 Starting development server...
|
||||||
|
🚀 Starting MongoDB in-memory database...
|
||||||
|
✅ MongoDB in-memory database started
|
||||||
|
📊 Database URI: mongodb://***@localhost:port/payload-mailing-dev
|
||||||
|
📧 Mailing plugin configured with test transport
|
||||||
|
🎯 Test interface will be available at: /mailing-test
|
||||||
|
|
||||||
|
✅ Example email templates created successfully
|
||||||
|
PayloadCMS Mailing Plugin initialized successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Server won't start
|
||||||
|
- Ensure port 3000 is available
|
||||||
|
- Check for Node.js version compatibility
|
||||||
|
- Run `npm install` to ensure dependencies
|
||||||
|
|
||||||
|
### Database issues
|
||||||
|
- In-memory database automatically handles setup
|
||||||
|
- For external MongoDB, verify `DATABASE_URI` is correct
|
||||||
|
- Check MongoDB is running if using external database
|
||||||
|
|
||||||
|
### Email issues
|
||||||
|
- Verify MailHog is running on port 1025
|
||||||
|
- Check console logs for error messages
|
||||||
|
- Ensure template variables are correctly formatted
|
||||||
|
|
||||||
|
## Plugin Development
|
||||||
|
|
||||||
|
The plugin source is in `src/` directory:
|
||||||
|
- `src/plugin.ts` - Main plugin configuration
|
||||||
|
- `src/collections/` - Email templates and outbox collections
|
||||||
|
- `src/services/` - Mailing service with Handlebars processing
|
||||||
|
- `src/jobs/` - Background job processing
|
||||||
|
- `src/utils/` - Helper functions for developers
|
||||||
|
|
||||||
|
Make changes, rebuild with `npm run build`, and test!
|
||||||
|
|
||||||
|
## Success Indicators
|
||||||
|
|
||||||
|
✅ Server starts without errors
|
||||||
|
✅ Admin panel loads at `/admin`
|
||||||
|
✅ Test interface loads at `/mailing-test`
|
||||||
|
✅ Templates appear in the interface
|
||||||
|
✅ Emails can be sent/scheduled
|
||||||
|
✅ Outbox shows email status
|
||||||
|
|
||||||
|
You're ready to develop and test the PayloadCMS Mailing Plugin! 🎉
|
||||||
224
README.md
224
README.md
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
📧 **Template-based email system with scheduling and job processing for PayloadCMS**
|
📧 **Template-based email system with scheduling and job processing for PayloadCMS**
|
||||||
|
|
||||||
|
⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
✅ **Template System**: Create reusable email templates with Handlebars syntax
|
✅ **Template System**: Create reusable email templates with Handlebars syntax
|
||||||
@@ -52,9 +54,9 @@ export default buildConfig({
|
|||||||
```typescript
|
```typescript
|
||||||
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
|
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
|
||||||
|
|
||||||
// Send immediately
|
// Send immediately using template slug
|
||||||
const emailId = await sendEmail(payload, {
|
const emailId = await sendEmail(payload, {
|
||||||
templateId: 'welcome-email',
|
templateSlug: 'welcome-email',
|
||||||
to: 'user@example.com',
|
to: 'user@example.com',
|
||||||
variables: {
|
variables: {
|
||||||
firstName: 'John',
|
firstName: 'John',
|
||||||
@@ -64,7 +66,7 @@ const emailId = await sendEmail(payload, {
|
|||||||
|
|
||||||
// Schedule for later
|
// Schedule for later
|
||||||
const scheduledId = await scheduleEmail(payload, {
|
const scheduledId = await scheduleEmail(payload, {
|
||||||
templateId: 'reminder-email',
|
templateSlug: 'reminder-email',
|
||||||
to: 'user@example.com',
|
to: 'user@example.com',
|
||||||
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||||
variables: {
|
variables: {
|
||||||
@@ -82,13 +84,17 @@ const scheduledId = await scheduleEmail(payload, {
|
|||||||
interface MailingPluginConfig {
|
interface MailingPluginConfig {
|
||||||
collections?: {
|
collections?: {
|
||||||
templates?: string // default: 'email-templates'
|
templates?: string // default: 'email-templates'
|
||||||
outbox?: string // default: 'email-outbox'
|
emails?: string // default: 'emails'
|
||||||
}
|
}
|
||||||
defaultFrom?: string
|
defaultFrom?: string
|
||||||
transport?: Transporter | MailingTransportConfig
|
transport?: Transporter | MailingTransportConfig
|
||||||
queue?: string // default: 'default'
|
queue?: string // default: 'default'
|
||||||
retryAttempts?: number // default: 3
|
retryAttempts?: number // default: 3
|
||||||
retryDelay?: number // default: 300000 (5 minutes)
|
retryDelay?: number // default: 300000 (5 minutes)
|
||||||
|
emailWrapper?: EmailWrapperHook // optional email layout wrapper
|
||||||
|
richTextEditor?: RichTextField['editor'] // optional custom rich text editor
|
||||||
|
onReady?: (payload: any) => Promise<void> // optional callback after plugin initialization
|
||||||
|
initOrder?: 'before' | 'after' // default: 'before'
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -125,34 +131,117 @@ import nodemailer from 'nodemailer'
|
|||||||
2. Navigate to **Mailing > Email Templates**
|
2. Navigate to **Mailing > Email Templates**
|
||||||
3. Create a new template with:
|
3. Create a new template with:
|
||||||
- **Name**: Descriptive name for the template
|
- **Name**: Descriptive name for the template
|
||||||
|
- **Slug**: Unique identifier for the template (auto-generated)
|
||||||
- **Subject**: Email subject (supports Handlebars)
|
- **Subject**: Email subject (supports Handlebars)
|
||||||
- **HTML Template**: HTML content with Handlebars syntax
|
- **Content**: Rich text editor with Handlebars syntax (automatically generates HTML and text versions)
|
||||||
- **Text Template**: Plain text version (optional)
|
|
||||||
- **Variables**: Define available variables
|
|
||||||
|
|
||||||
### Template Example
|
### Template Example
|
||||||
|
|
||||||
**Subject**: `Welcome to {{siteName}}, {{firstName}}!`
|
**Subject**: `Welcome to {{siteName}}, {{firstName}}!`
|
||||||
|
|
||||||
**HTML Template**:
|
**Content** (using rich text editor with Handlebars):
|
||||||
```html
|
```
|
||||||
<h1>Welcome {{firstName}}!</h1>
|
# Welcome {{firstName}}! 🎉
|
||||||
<p>Thanks for joining {{siteName}}. We're excited to have you!</p>
|
|
||||||
|
|
||||||
{{#if isPremium}}
|
Thanks for joining {{siteName}}. We're excited to have you!
|
||||||
<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>
|
**What you can do:**
|
||||||
<p>Visit your dashboard: <a href="{{dashboardUrl}}">Get Started</a></p>
|
• Create beautiful emails with rich text formatting
|
||||||
|
• Use the emailWrapper hook to add custom layouts
|
||||||
|
• Queue and schedule emails effortlessly
|
||||||
|
|
||||||
<hr>
|
Your account was created on {{formatDate createdAt "long"}}.
|
||||||
<p>Best regards,<br>The {{siteName}} Team</p>
|
|
||||||
|
Best regards,
|
||||||
|
The {{siteName}} Team
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### Email Wrapper Hook
|
||||||
|
|
||||||
|
Use the `emailWrapper` hook to apply consistent layouts to all emails:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
mailingPlugin({
|
||||||
|
// ... other config
|
||||||
|
emailWrapper: (email) => {
|
||||||
|
const wrappedHtml = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>${email.subject}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; background: white; }
|
||||||
|
.header { background: #007bff; color: white; padding: 20px; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background: #f8f9fa; padding: 15px; text-align: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>My Company</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
${email.html}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
© 2024 My Company. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
return {
|
||||||
|
...email,
|
||||||
|
html: wrappedHtml,
|
||||||
|
text: `MY COMPANY\n\n${email.text}\n\n© 2024 My Company`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Rich Text Editor
|
||||||
|
|
||||||
|
Override the rich text editor used for templates:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||||
|
import { FixedToolbarFeature, HeadingFeature } from '@payloadcms/richtext-lexical'
|
||||||
|
|
||||||
|
mailingPlugin({
|
||||||
|
// ... other config
|
||||||
|
richTextEditor: lexicalEditor({
|
||||||
|
features: ({ defaultFeatures }) => [
|
||||||
|
...defaultFeatures,
|
||||||
|
FixedToolbarFeature(),
|
||||||
|
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3'] }),
|
||||||
|
// Add more features as needed
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Initialization Hooks
|
||||||
|
|
||||||
|
Control plugin initialization order and add post-initialization logic:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
mailingPlugin({
|
||||||
|
// ... other config
|
||||||
|
initOrder: 'after', // Initialize after main Payload onInit
|
||||||
|
onReady: async (payload) => {
|
||||||
|
// Called after plugin is fully initialized
|
||||||
|
console.log('Mailing plugin ready!')
|
||||||
|
|
||||||
|
// Custom initialization logic here
|
||||||
|
await setupCustomEmailSettings(payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## Handlebars Helpers
|
## Handlebars Helpers
|
||||||
@@ -172,20 +261,20 @@ Send an email immediately:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const emailId = await sendEmail(payload, {
|
const emailId = await sendEmail(payload, {
|
||||||
templateId: 'order-confirmation', // optional
|
templateSlug: 'order-confirmation', // optional - use template slug
|
||||||
to: 'customer@example.com',
|
to: ['customer@example.com'], // string or array of emails
|
||||||
cc: 'manager@example.com', // optional
|
cc: ['manager@example.com'], // optional - array of emails
|
||||||
bcc: 'archive@example.com', // optional
|
bcc: ['archive@example.com'], // optional - array of emails
|
||||||
from: 'orders@yoursite.com', // optional, uses default
|
from: 'orders@yoursite.com', // optional, uses default
|
||||||
replyTo: 'support@yoursite.com', // optional
|
replyTo: 'support@yoursite.com', // optional
|
||||||
subject: 'Custom subject', // required if no template
|
subject: 'Custom subject', // required if no template
|
||||||
html: '<h1>Custom HTML</h1>', // required if no template
|
html: '<h1>Custom HTML</h1>', // required if no template
|
||||||
text: 'Custom text version', // optional
|
text: 'Custom text version', // optional
|
||||||
variables: { // template variables
|
variables: { // template variables
|
||||||
orderNumber: '12345',
|
orderNumber: '12345',
|
||||||
customerName: 'John Doe'
|
customerName: 'John Doe'
|
||||||
},
|
},
|
||||||
priority: 1 // optional, 1-10 (1 = highest)
|
priority: 1 // optional, 1-10 (1 = highest)
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -195,7 +284,7 @@ Schedule an email for later delivery:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const emailId = await scheduleEmail(payload, {
|
const emailId = await scheduleEmail(payload, {
|
||||||
templateId: 'newsletter',
|
templateSlug: 'newsletter',
|
||||||
to: ['user1@example.com', 'user2@example.com'],
|
to: ['user1@example.com', 'user2@example.com'],
|
||||||
scheduledAt: new Date('2024-01-15T10:00:00Z'),
|
scheduledAt: new Date('2024-01-15T10:00:00Z'),
|
||||||
variables: {
|
variables: {
|
||||||
@@ -205,12 +294,13 @@ const emailId = await scheduleEmail(payload, {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### processOutbox(payload)
|
### processEmails(payload)
|
||||||
|
|
||||||
Manually process pending emails:
|
Manually process pending emails:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
await processOutbox(payload)
|
import { processEmails } from '@xtr-dev/payload-mailing'
|
||||||
|
await processEmails(payload)
|
||||||
```
|
```
|
||||||
|
|
||||||
### retryFailedEmails(payload)
|
### retryFailedEmails(payload)
|
||||||
@@ -218,31 +308,31 @@ await processOutbox(payload)
|
|||||||
Manually retry failed emails:
|
Manually retry failed emails:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
import { retryFailedEmails } from '@xtr-dev/payload-mailing'
|
||||||
await retryFailedEmails(payload)
|
await retryFailedEmails(payload)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Job Processing
|
## Job Processing
|
||||||
|
|
||||||
The plugin automatically processes emails using PayloadCMS jobs:
|
The plugin automatically adds a unified email processing job to PayloadCMS:
|
||||||
|
|
||||||
- **Outbox Processing**: Every 5 minutes
|
- **Job Name**: `process-email-queue`
|
||||||
- **Failed Email Retry**: Every 30 minutes
|
- **Function**: Processes both pending emails and retries failed emails
|
||||||
|
- **Trigger**: Manual via admin panel or API call
|
||||||
|
|
||||||
Ensure you have jobs configured in your Payload config:
|
The job is automatically registered when the plugin initializes. To trigger it manually:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export default buildConfig({
|
// Queue the job for processing
|
||||||
jobs: {
|
await payload.jobs.queue({
|
||||||
// Configure your job processing
|
task: 'process-email-queue',
|
||||||
tasks: [],
|
input: {}
|
||||||
// ... other job config
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## Email Status Tracking
|
## Email Status Tracking
|
||||||
|
|
||||||
All emails are stored in the outbox collection with these statuses:
|
All emails are stored in the emails collection with these statuses:
|
||||||
|
|
||||||
- `pending` - Waiting to be sent
|
- `pending` - Waiting to be sent
|
||||||
- `processing` - Currently being sent
|
- `processing` - Currently being sent
|
||||||
@@ -251,7 +341,7 @@ All emails are stored in the outbox collection with these statuses:
|
|||||||
|
|
||||||
## Monitoring
|
## Monitoring
|
||||||
|
|
||||||
Check the **Mailing > Email Outbox** collection in your admin panel to:
|
Check the **Mailing > Emails** collection in your admin panel to:
|
||||||
|
|
||||||
- View email delivery status
|
- View email delivery status
|
||||||
- See error messages for failed sends
|
- See error messages for failed sends
|
||||||
@@ -278,14 +368,46 @@ import {
|
|||||||
MailingPluginConfig,
|
MailingPluginConfig,
|
||||||
SendEmailOptions,
|
SendEmailOptions,
|
||||||
EmailTemplate,
|
EmailTemplate,
|
||||||
OutboxEmail
|
QueuedEmail,
|
||||||
|
EmailObject,
|
||||||
|
EmailWrapperHook
|
||||||
} from '@xtr-dev/payload-mailing'
|
} from '@xtr-dev/payload-mailing'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Recent Changes
|
||||||
|
|
||||||
|
### v0.0.x (Latest)
|
||||||
|
|
||||||
|
**🔄 Breaking Changes:**
|
||||||
|
- Removed email layouts system in favor of `emailWrapper` hook for better flexibility
|
||||||
|
- Email fields (`to`, `cc`, `bcc`) now use `hasMany: true` for proper array handling
|
||||||
|
- Templates now use slug-based lookup instead of ID-based for developer-friendly API
|
||||||
|
- Email collection renamed from "outbox" to "emails"
|
||||||
|
- Unified job processing: single `process-email-queue` job handles both pending and failed emails
|
||||||
|
|
||||||
|
**✨ New Features:**
|
||||||
|
- Rich text editor with automatic HTML/text conversion
|
||||||
|
- Template slugs for easier template reference
|
||||||
|
- `emailWrapper` hook for consistent email layouts
|
||||||
|
- Custom rich text editor configuration support
|
||||||
|
- Initialization hooks (`onReady`, `initOrder`) for better plugin lifecycle control
|
||||||
|
- Improved Handlebars variable interpolation with defensive programming
|
||||||
|
|
||||||
|
**🐛 Bug Fixes:**
|
||||||
|
- Fixed text version uppercase conversion in headings
|
||||||
|
- Fixed Handlebars interpolation issues in text version
|
||||||
|
- Improved plugin initialization order to prevent timing issues
|
||||||
|
|
||||||
|
**💡 Improvements:**
|
||||||
|
- Better admin UI with proper array input controls
|
||||||
|
- More robust error handling and logging
|
||||||
|
- Enhanced TypeScript definitions
|
||||||
|
- Simplified template creation workflow
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Issues and pull requests welcome at [GitHub repository](https://github.com/xtr-dev/payload-mailing)
|
Issues and pull requests welcome at [GitHub repository](https://github.com/xtr-dev/payload-mailing)
|
||||||
|
|||||||
208
dev/README.md
Normal file
208
dev/README.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# PayloadCMS Mailing Plugin - Development Setup
|
||||||
|
|
||||||
|
This directory contains a complete PayloadCMS application for testing and developing the mailing plugin.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start development server (with in-memory MongoDB):**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative startup methods:**
|
||||||
|
```bash
|
||||||
|
# Force in-memory database (from root directory)
|
||||||
|
npm run dev:memory
|
||||||
|
|
||||||
|
# With startup script and helpful info
|
||||||
|
npm run dev:start
|
||||||
|
|
||||||
|
# From dev directory
|
||||||
|
cd dev && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Optional: Set up environment file:**
|
||||||
|
```bash
|
||||||
|
cp ../.env.example .env
|
||||||
|
# Edit .env if you want to use external MongoDB
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Access the application:**
|
||||||
|
- Admin Panel: http://localhost:3000/admin
|
||||||
|
- Mailing Test Page: http://localhost:3000/mailing-test
|
||||||
|
- GraphQL Playground: http://localhost:3000/api/graphql-playground
|
||||||
|
|
||||||
|
## Features Included
|
||||||
|
|
||||||
|
### ✅ **Mailing Plugin Integration**
|
||||||
|
- Configured with test email transport
|
||||||
|
- Example email templates automatically created
|
||||||
|
- Collections: `email-templates` and `email-outbox`
|
||||||
|
|
||||||
|
### ✅ **Test Interface**
|
||||||
|
- Web UI at `/mailing-test` for testing emails
|
||||||
|
- Send emails immediately or schedule for later
|
||||||
|
- View outbox status and email history
|
||||||
|
- Process outbox manually
|
||||||
|
|
||||||
|
### ✅ **API Endpoints**
|
||||||
|
- `POST /api/test-email` - Send/schedule test emails
|
||||||
|
- `GET /api/test-email` - Get templates and outbox status
|
||||||
|
- `POST /api/process-outbox` - Manually process outbox
|
||||||
|
- `GET /api/process-outbox` - Get outbox statistics
|
||||||
|
|
||||||
|
### ✅ **Example Templates**
|
||||||
|
1. **Welcome Email** - New user onboarding
|
||||||
|
2. **Order Confirmation** - E-commerce order receipt
|
||||||
|
3. **Password Reset** - Security password reset
|
||||||
|
|
||||||
|
## Email Testing
|
||||||
|
|
||||||
|
### Option 1: MailHog (Recommended)
|
||||||
|
```bash
|
||||||
|
# Install MailHog
|
||||||
|
go install github.com/mailhog/MailHog@latest
|
||||||
|
|
||||||
|
# Run MailHog
|
||||||
|
MailHog
|
||||||
|
```
|
||||||
|
- SMTP: localhost:1025 (configured in dev)
|
||||||
|
- Web UI: http://localhost:8025
|
||||||
|
|
||||||
|
### Option 2: Console Logs
|
||||||
|
The dev environment uses `testEmailAdapter` which logs emails to console.
|
||||||
|
|
||||||
|
## Testing the Plugin
|
||||||
|
|
||||||
|
1. **Via Web Interface:**
|
||||||
|
- Go to http://localhost:3000/mailing-test
|
||||||
|
- Select a template
|
||||||
|
- Fill in variables
|
||||||
|
- Send or schedule email
|
||||||
|
|
||||||
|
2. **Via Admin Panel:**
|
||||||
|
- Go to http://localhost:3000/admin
|
||||||
|
- Navigate to "Mailing > Email Templates"
|
||||||
|
- Create/edit templates
|
||||||
|
- Navigate to "Mailing > Email Outbox"
|
||||||
|
- View scheduled/sent emails
|
||||||
|
|
||||||
|
3. **Via API:**
|
||||||
|
```bash
|
||||||
|
# Send welcome email
|
||||||
|
curl -X POST http://localhost:3000/api/test-email \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"type": "send",
|
||||||
|
"templateId": "TEMPLATE_ID",
|
||||||
|
"to": "test@example.com",
|
||||||
|
"variables": {
|
||||||
|
"firstName": "John",
|
||||||
|
"siteName": "Test Site"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Make changes to plugin source** (`../src/`)
|
||||||
|
2. **Rebuild plugin:** `npm run build` (in root)
|
||||||
|
3. **Restart dev server:** The dev server watches for changes
|
||||||
|
4. **Test changes** via web interface or API
|
||||||
|
|
||||||
|
## Database Configuration
|
||||||
|
|
||||||
|
The development setup automatically uses **MongoDB in-memory database** by default - no MongoDB installation required!
|
||||||
|
|
||||||
|
### 🚀 **In-Memory MongoDB (Default)**
|
||||||
|
- ✅ **Zero setup** - Works out of the box
|
||||||
|
- ✅ **No installation** required
|
||||||
|
- ✅ **Fast startup** - Ready in seconds
|
||||||
|
- ⚠️ **Data resets** on server restart
|
||||||
|
- 💾 **Perfect for development** and testing
|
||||||
|
|
||||||
|
### 🔧 **Database Options**
|
||||||
|
|
||||||
|
1. **In-Memory (Recommended for Development):**
|
||||||
|
```bash
|
||||||
|
# Automatic - just start the server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Or explicitly enable
|
||||||
|
USE_MEMORY_DB=true npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Local MongoDB:**
|
||||||
|
```bash
|
||||||
|
# Install MongoDB locally, then:
|
||||||
|
DATABASE_URI=mongodb://localhost:27017/payload-mailing-dev npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Remote MongoDB:**
|
||||||
|
```bash
|
||||||
|
# Set your connection string:
|
||||||
|
DATABASE_URI=mongodb+srv://user:pass@cluster.mongodb.net/dbname npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 💡 **Database Startup Messages**
|
||||||
|
When you start the dev server, you'll see helpful messages:
|
||||||
|
```
|
||||||
|
🚀 Starting MongoDB in-memory database...
|
||||||
|
✅ MongoDB in-memory database started
|
||||||
|
📊 Database URI: mongodb://***@localhost:port/payload-mailing-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Jobs Processing
|
||||||
|
|
||||||
|
The plugin automatically processes the outbox every 5 minutes and retries failed emails every 30 minutes. You can also trigger manual processing via:
|
||||||
|
- Web interface "Process Outbox" button
|
||||||
|
- API endpoint `POST /api/process-outbox`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Email not sending:
|
||||||
|
1. Check MailHog is running on port 1025
|
||||||
|
2. Check console logs for errors
|
||||||
|
3. Verify template variables are correct
|
||||||
|
4. Check outbox collection for error messages
|
||||||
|
|
||||||
|
### Plugin not loading:
|
||||||
|
1. Ensure plugin is built: `npm run build` in root
|
||||||
|
2. Check console for initialization message
|
||||||
|
3. Verify plugin configuration in `payload.config.ts`
|
||||||
|
|
||||||
|
### Templates not appearing:
|
||||||
|
1. Check seed function ran successfully
|
||||||
|
2. Verify database connection
|
||||||
|
3. Check admin panel collections
|
||||||
|
|
||||||
|
## Plugin API Usage
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
|
||||||
|
|
||||||
|
// Send immediate email
|
||||||
|
const emailId = await sendEmail(payload, {
|
||||||
|
templateId: 'welcome-template-id',
|
||||||
|
to: 'user@example.com',
|
||||||
|
variables: {
|
||||||
|
firstName: 'John',
|
||||||
|
siteName: 'My App'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Schedule email
|
||||||
|
const scheduledId = await scheduleEmail(payload, {
|
||||||
|
templateId: 'reminder-template-id',
|
||||||
|
to: 'user@example.com',
|
||||||
|
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||||
|
variables: {
|
||||||
|
eventName: 'Product Launch'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
@@ -1,9 +1,51 @@
|
|||||||
import { BeforeDashboardClient as BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343 } from 'temp-project/client'
|
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
import { BeforeDashboardServer as BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f } from 'temp-project/rsc'
|
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
|
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
|
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
|
||||||
export const importMap = {
|
export const importMap = {
|
||||||
'temp-project/client#BeforeDashboardClient':
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343,
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
'temp-project/rsc#BeforeDashboardServer':
|
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f,
|
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864
|
||||||
}
|
}
|
||||||
|
|||||||
75
dev/app/api/create-test-user/route.ts
Normal file
75
dev/app/api/create-test-user/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { getPayload } from 'payload'
|
||||||
|
import config from '@payload-config'
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
// Generate random user data if not provided
|
||||||
|
const userData = {
|
||||||
|
email: body.email || `user-${Date.now()}@example.com`,
|
||||||
|
password: body.password || 'TestPassword123!',
|
||||||
|
firstName: body.firstName || 'Test',
|
||||||
|
lastName: body.lastName || 'User',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the user
|
||||||
|
const user = await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: userData,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if email was queued
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500)) // Brief delay for email processing
|
||||||
|
|
||||||
|
const { docs: emails } = await payload.find({
|
||||||
|
collection: 'emails' as const,
|
||||||
|
where: {
|
||||||
|
to: {
|
||||||
|
equals: userData.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
sort: '-createdAt',
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
},
|
||||||
|
emailQueued: emails.length > 0,
|
||||||
|
email: emails.length > 0 ? {
|
||||||
|
id: emails[0].id,
|
||||||
|
subject: emails[0].subject,
|
||||||
|
status: emails[0].status,
|
||||||
|
} : null,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating test user:', error)
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to create user',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return Response.json({
|
||||||
|
message: 'Use POST to create a test user',
|
||||||
|
example: {
|
||||||
|
email: 'optional@example.com',
|
||||||
|
password: 'optional',
|
||||||
|
firstName: 'optional',
|
||||||
|
lastName: 'optional',
|
||||||
|
},
|
||||||
|
note: 'All fields are optional. Random values will be generated if not provided.',
|
||||||
|
})
|
||||||
|
}
|
||||||
75
dev/app/api/process-emails/route.ts
Normal file
75
dev/app/api/process-emails/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { getPayload } from 'payload'
|
||||||
|
import config from '@payload-config'
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
|
||||||
|
// Queue the combined email queue processing job
|
||||||
|
const job = await payload.jobs.queue({
|
||||||
|
task: 'process-email-queue',
|
||||||
|
input: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Email queue processing job queued successfully (will process both pending and failed emails)',
|
||||||
|
jobId: job.id,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Process emails error:', error)
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to process emails',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
|
||||||
|
// Get email queue statistics
|
||||||
|
const pending = await payload.count({
|
||||||
|
collection: 'emails' as const,
|
||||||
|
where: { status: { equals: 'pending' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const processing = await payload.count({
|
||||||
|
collection: 'emails' as const,
|
||||||
|
where: { status: { equals: 'processing' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const sent = await payload.count({
|
||||||
|
collection: 'emails' as const,
|
||||||
|
where: { status: { equals: 'sent' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = await payload.count({
|
||||||
|
collection: 'emails' as const,
|
||||||
|
where: { status: { equals: 'failed' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
statistics: {
|
||||||
|
pending: pending.totalDocs,
|
||||||
|
processing: processing.totalDocs,
|
||||||
|
sent: sent.totalDocs,
|
||||||
|
failed: failed.totalDocs,
|
||||||
|
total: pending.totalDocs + processing.totalDocs + sent.totalDocs + failed.totalDocs,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get email stats error:', error)
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to get email statistics',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
86
dev/app/api/test-email/route.ts
Normal file
86
dev/app/api/test-email/route.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { getPayload } from 'payload'
|
||||||
|
import config from '@payload-config'
|
||||||
|
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
const body = await request.json()
|
||||||
|
const { type = 'send', templateSlug, to, variables, scheduledAt } = body
|
||||||
|
|
||||||
|
let result
|
||||||
|
if (type === 'send') {
|
||||||
|
// Send immediately
|
||||||
|
result = await sendEmail(payload, {
|
||||||
|
templateSlug,
|
||||||
|
to,
|
||||||
|
variables,
|
||||||
|
})
|
||||||
|
} else if (type === 'schedule') {
|
||||||
|
// Schedule for later
|
||||||
|
result = await scheduleEmail(payload, {
|
||||||
|
templateSlug,
|
||||||
|
to,
|
||||||
|
variables,
|
||||||
|
scheduledAt: scheduledAt ? new Date(scheduledAt) : new Date(Date.now() + 60000), // Default to 1 minute
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return Response.json({ error: 'Invalid type. Use "send" or "schedule"' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: true,
|
||||||
|
emailId: result,
|
||||||
|
message: type === 'send' ? 'Email sent successfully' : 'Email scheduled successfully',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Test email error:', error)
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to send email',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
|
||||||
|
// Get email templates
|
||||||
|
const { docs: templates } = await payload.find({
|
||||||
|
collection: 'email-templates' as const,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get email queue status
|
||||||
|
const { docs: queuedEmails, totalDocs } = await payload.find({
|
||||||
|
collection: 'emails' as const,
|
||||||
|
limit: 10,
|
||||||
|
sort: '-createdAt',
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
templates,
|
||||||
|
outbox: {
|
||||||
|
emails: queuedEmails,
|
||||||
|
total: totalDocs,
|
||||||
|
},
|
||||||
|
mailing: {
|
||||||
|
pluginActive: !!(payload as any).mailing,
|
||||||
|
service: !!(payload as any).mailing?.service,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get mailing status error:', error)
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to get mailing status',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
344
dev/app/mailing-test/page.tsx
Normal file
344
dev/app/mailing-test/page.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
subject: string
|
||||||
|
variables?: Array<{
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
required: boolean
|
||||||
|
description?: string
|
||||||
|
}>
|
||||||
|
previewData?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueuedEmail {
|
||||||
|
id: string
|
||||||
|
subject: string
|
||||||
|
to: string[]
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
sentAt?: string
|
||||||
|
attempts: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MailingTestPage() {
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
|
const [queuedEmails, setQueuedEmails] = useState<QueuedEmail[]>([])
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('')
|
||||||
|
const [toEmail, setToEmail] = useState<string>('test@example.com')
|
||||||
|
const [variables, setVariables] = useState<Record<string, any>>({})
|
||||||
|
const [emailType, setEmailType] = useState<'send' | 'schedule'>('send')
|
||||||
|
const [scheduleDate, setScheduleDate] = useState<string>('')
|
||||||
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
|
const [message, setMessage] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/test-email')
|
||||||
|
const data = await response.json()
|
||||||
|
setTemplates(data.templates || [])
|
||||||
|
setQueuedEmails(data.outbox?.emails || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTemplateChange = (templateSlug: string) => {
|
||||||
|
setSelectedTemplate(templateSlug)
|
||||||
|
const template = templates.find(t => t.slug === templateSlug)
|
||||||
|
if (template?.previewData) {
|
||||||
|
setVariables(template.previewData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendTestEmail = async () => {
|
||||||
|
if (!selectedTemplate || !toEmail) {
|
||||||
|
setMessage('Please select a template and enter an email address')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/test-email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: emailType,
|
||||||
|
templateSlug: selectedTemplate,
|
||||||
|
to: toEmail,
|
||||||
|
variables,
|
||||||
|
scheduledAt: emailType === 'schedule' ? scheduleDate : undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setMessage(`✅ ${result.message} (ID: ${result.emailId})`)
|
||||||
|
fetchData() // Refresh email queue
|
||||||
|
} else {
|
||||||
|
setMessage(`❌ Error: ${result.error}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(`❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processEmailQueue = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/process-emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
setMessage(result.success ? `✅ ${result.message}` : `❌ ${result.error}`)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchData() // Refresh after a delay to see status changes
|
||||||
|
}, 1000)
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(`❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTemplateData = templates.find(t => t.slug === selectedTemplate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', fontFamily: 'Arial, sans-serif', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<h1>📧 PayloadCMS Mailing Plugin Test</h1>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px',
|
||||||
|
margin: '10px 0',
|
||||||
|
backgroundColor: message.startsWith('✅') ? '#d4edda' : '#f8d7da',
|
||||||
|
border: `1px solid ${message.startsWith('✅') ? '#c3e6cb' : '#f5c6cb'}`,
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
|
||||||
|
{/* Send Email Form */}
|
||||||
|
<div style={{ border: '1px solid #ddd', padding: '20px', borderRadius: '8px' }}>
|
||||||
|
<h2>Send Test Email</h2>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '15px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '5px' }}>Email Template:</label>
|
||||||
|
<select
|
||||||
|
value={selectedTemplate}
|
||||||
|
onChange={(e) => handleTemplateChange(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||||
|
>
|
||||||
|
<option value="">Select a template...</option>
|
||||||
|
{templates.map(template => (
|
||||||
|
<option key={template.id} value={template.slug}>{template.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '15px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '5px' }}>To Email:</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={toEmail}
|
||||||
|
onChange={(e) => setToEmail(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '15px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '5px' }}>Type:</label>
|
||||||
|
<label style={{ marginRight: '15px' }}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="send"
|
||||||
|
checked={emailType === 'send'}
|
||||||
|
onChange={(e) => setEmailType(e.target.value as 'send' | 'schedule')}
|
||||||
|
/>
|
||||||
|
Send Now
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="schedule"
|
||||||
|
checked={emailType === 'schedule'}
|
||||||
|
onChange={(e) => setEmailType(e.target.value as 'send' | 'schedule')}
|
||||||
|
/>
|
||||||
|
Schedule
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{emailType === 'schedule' && (
|
||||||
|
<div style={{ marginBottom: '15px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '5px' }}>Schedule Date:</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={scheduleDate}
|
||||||
|
onChange={(e) => setScheduleDate(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTemplateData?.variables && (
|
||||||
|
<div style={{ marginBottom: '15px' }}>
|
||||||
|
<h3>Template Variables:</h3>
|
||||||
|
{selectedTemplateData.variables.map(variable => (
|
||||||
|
<div key={variable.name} style={{ marginBottom: '10px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '5px' }}>
|
||||||
|
{variable.name} {variable.required && <span style={{ color: 'red' }}>*</span>}
|
||||||
|
{variable.description && <small style={{ color: '#666' }}> - {variable.description}</small>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={variable.type === 'number' ? 'number' : variable.type === 'date' ? 'datetime-local' : 'text'}
|
||||||
|
value={variables[variable.name] || ''}
|
||||||
|
onChange={(e) => setVariables({
|
||||||
|
...variables,
|
||||||
|
[variable.name]: variable.type === 'number' ? Number(e.target.value) :
|
||||||
|
variable.type === 'boolean' ? e.target.checked :
|
||||||
|
e.target.value
|
||||||
|
})}
|
||||||
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={sendTestEmail}
|
||||||
|
disabled={loading || !selectedTemplate || !toEmail}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: loading ? '#ccc' : '#007bff',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Sending...' : emailType === 'send' ? 'Send Email' : 'Schedule Email'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={processEmailQueue}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: loading ? '#ccc' : '#17a2b8',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
marginTop: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Queuing...' : 'Process Email Queue (Pending + Failed)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Queue */}
|
||||||
|
<div style={{ border: '1px solid #ddd', padding: '20px', borderRadius: '8px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
||||||
|
<h2>Email Queue</h2>
|
||||||
|
<button
|
||||||
|
onClick={fetchData}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{queuedEmails.length === 0 ? (
|
||||||
|
<p style={{ color: '#666' }}>No emails in queue</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ maxHeight: '500px', overflowY: 'auto' }}>
|
||||||
|
{queuedEmails.map(email => (
|
||||||
|
<div
|
||||||
|
key={email.id}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #eee',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: email.status === 'sent' ? '#f8f9fa' :
|
||||||
|
email.status === 'failed' ? '#fff3cd' :
|
||||||
|
email.status === 'processing' ? '#e3f2fd' : 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 'bold', marginBottom: '5px' }}>{email.subject}</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
To: {Array.isArray(email.to) ? email.to.join(', ') : email.to} | Status: <strong>{email.status}</strong> | Attempts: {email.attempts}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||||
|
Created: {new Date(email.createdAt).toLocaleString()}
|
||||||
|
{email.sentAt && ` | Sent: ${new Date(email.sentAt).toLocaleString()}`}
|
||||||
|
</div>
|
||||||
|
{email.error && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#dc3545', marginTop: '5px' }}>
|
||||||
|
Error: {email.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Templates Overview */}
|
||||||
|
<div style={{ marginTop: '30px', border: '1px solid #ddd', padding: '20px', borderRadius: '8px' }}>
|
||||||
|
<h2>Available Templates</h2>
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<p style={{ color: '#666' }}>No templates available</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '15px' }}>
|
||||||
|
{templates.map(template => (
|
||||||
|
<div key={template.id} style={{ border: '1px solid #eee', padding: '15px', borderRadius: '4px' }}>
|
||||||
|
<h3 style={{ margin: '0 0 10px 0' }}>{template.name}</h3>
|
||||||
|
<p style={{ margin: '0 0 10px 0', color: '#666', fontSize: '14px' }}>{template.subject}</p>
|
||||||
|
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||||
|
Variables: {template.variables?.length || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export const devUser = {
|
export const devUser = {
|
||||||
email: 'dev@payloadcms.com',
|
email: 'dev@payloadcms.com',
|
||||||
password: 'test',
|
password: 'test',
|
||||||
|
firstName: 'Dev',
|
||||||
|
lastName: 'User',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import config from '@payload-config'
|
|||||||
import { createPayloadRequest, getPayload } from 'payload'
|
import { createPayloadRequest, getPayload } from 'payload'
|
||||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
||||||
|
|
||||||
import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js'
|
// import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js'
|
||||||
|
|
||||||
let payload: Payload
|
let payload: Payload
|
||||||
|
|
||||||
@@ -17,19 +17,11 @@ beforeAll(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Plugin integration tests', () => {
|
describe('Plugin integration tests', () => {
|
||||||
test('should query custom endpoint added by plugin', async () => {
|
test('should have mailing plugin initialized', async () => {
|
||||||
const request = new Request('http://localhost:3000/api/my-plugin-endpoint', {
|
expect(payload).toBeDefined()
|
||||||
method: 'GET',
|
expect((payload as any).mailing).toBeDefined()
|
||||||
})
|
expect((payload as any).mailing.service).toBeDefined()
|
||||||
|
expect((payload as any).mailing.config).toBeDefined()
|
||||||
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 () => {
|
test('can create post with custom text field added by plugin', async () => {
|
||||||
|
|||||||
1
dev/next-env.d.ts
vendored
1
dev/next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -6,25 +6,85 @@
|
|||||||
* and re-run `payload generate:types` to regenerate this file.
|
* and re-run `payload generate:types` to regenerate this file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported timezones in IANA format.
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "supportedTimezones".
|
||||||
|
*/
|
||||||
|
export type SupportedTimezones =
|
||||||
|
| 'Pacific/Midway'
|
||||||
|
| 'Pacific/Niue'
|
||||||
|
| 'Pacific/Honolulu'
|
||||||
|
| 'Pacific/Rarotonga'
|
||||||
|
| 'America/Anchorage'
|
||||||
|
| 'Pacific/Gambier'
|
||||||
|
| 'America/Los_Angeles'
|
||||||
|
| 'America/Tijuana'
|
||||||
|
| 'America/Denver'
|
||||||
|
| 'America/Phoenix'
|
||||||
|
| 'America/Chicago'
|
||||||
|
| 'America/Guatemala'
|
||||||
|
| 'America/New_York'
|
||||||
|
| 'America/Bogota'
|
||||||
|
| 'America/Caracas'
|
||||||
|
| 'America/Santiago'
|
||||||
|
| 'America/Buenos_Aires'
|
||||||
|
| 'America/Sao_Paulo'
|
||||||
|
| 'Atlantic/South_Georgia'
|
||||||
|
| 'Atlantic/Azores'
|
||||||
|
| 'Atlantic/Cape_Verde'
|
||||||
|
| 'Europe/London'
|
||||||
|
| 'Europe/Berlin'
|
||||||
|
| 'Africa/Lagos'
|
||||||
|
| 'Europe/Athens'
|
||||||
|
| 'Africa/Cairo'
|
||||||
|
| 'Europe/Moscow'
|
||||||
|
| 'Asia/Riyadh'
|
||||||
|
| 'Asia/Dubai'
|
||||||
|
| 'Asia/Baku'
|
||||||
|
| 'Asia/Karachi'
|
||||||
|
| 'Asia/Tashkent'
|
||||||
|
| 'Asia/Calcutta'
|
||||||
|
| 'Asia/Dhaka'
|
||||||
|
| 'Asia/Almaty'
|
||||||
|
| 'Asia/Jakarta'
|
||||||
|
| 'Asia/Bangkok'
|
||||||
|
| 'Asia/Shanghai'
|
||||||
|
| 'Asia/Singapore'
|
||||||
|
| 'Asia/Tokyo'
|
||||||
|
| 'Asia/Seoul'
|
||||||
|
| 'Australia/Brisbane'
|
||||||
|
| 'Australia/Sydney'
|
||||||
|
| 'Pacific/Guam'
|
||||||
|
| 'Pacific/Noumea'
|
||||||
|
| 'Pacific/Auckland'
|
||||||
|
| 'Pacific/Fiji';
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
auth: {
|
auth: {
|
||||||
users: UserAuthOperations;
|
users: UserAuthOperations;
|
||||||
};
|
};
|
||||||
|
blocks: {};
|
||||||
collections: {
|
collections: {
|
||||||
|
users: User;
|
||||||
posts: Post;
|
posts: Post;
|
||||||
media: Media;
|
media: Media;
|
||||||
'plugin-collection': PluginCollection;
|
'email-templates': EmailTemplate;
|
||||||
users: User;
|
emails: Email;
|
||||||
|
'payload-jobs': PayloadJob;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
'payload-preferences': PayloadPreference;
|
'payload-preferences': PayloadPreference;
|
||||||
'payload-migrations': PayloadMigration;
|
'payload-migrations': PayloadMigration;
|
||||||
};
|
};
|
||||||
collectionsJoins: {};
|
collectionsJoins: {};
|
||||||
collectionsSelect: {
|
collectionsSelect: {
|
||||||
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
posts: PostsSelect<false> | PostsSelect<true>;
|
posts: PostsSelect<false> | PostsSelect<true>;
|
||||||
media: MediaSelect<false> | MediaSelect<true>;
|
media: MediaSelect<false> | MediaSelect<true>;
|
||||||
'plugin-collection': PluginCollectionSelect<false> | PluginCollectionSelect<true>;
|
'email-templates': EmailTemplatesSelect<false> | EmailTemplatesSelect<true>;
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
emails: EmailsSelect<false> | EmailsSelect<true>;
|
||||||
|
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
@@ -39,7 +99,13 @@ export interface Config {
|
|||||||
collection: 'users';
|
collection: 'users';
|
||||||
};
|
};
|
||||||
jobs: {
|
jobs: {
|
||||||
tasks: unknown;
|
tasks: {
|
||||||
|
'process-email-queue': ProcessEmailQueueJob;
|
||||||
|
inline: {
|
||||||
|
input: unknown;
|
||||||
|
output: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
workflows: unknown;
|
workflows: unknown;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -61,13 +127,38 @@ export interface UserAuthOperations {
|
|||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
email: string;
|
||||||
|
resetPasswordToken?: string | null;
|
||||||
|
resetPasswordExpiration?: string | null;
|
||||||
|
salt?: string | null;
|
||||||
|
hash?: string | null;
|
||||||
|
loginAttempts?: number | null;
|
||||||
|
lockUntil?: string | null;
|
||||||
|
sessions?:
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
createdAt?: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
|
password?: string | null;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "posts".
|
* via the `definition` "posts".
|
||||||
*/
|
*/
|
||||||
export interface Post {
|
export interface Post {
|
||||||
id: string;
|
id: string;
|
||||||
addedByPlugin?: string | null;
|
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -91,29 +182,225 @@ export interface Media {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "plugin-collection".
|
* via the `definition` "email-templates".
|
||||||
*/
|
*/
|
||||||
export interface PluginCollection {
|
export interface EmailTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
|
/**
|
||||||
|
* A descriptive name for this email template
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Unique identifier for this template (e.g., "welcome-email", "password-reset")
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
/**
|
||||||
|
* Email subject line. You can use Handlebars variables like {{firstName}} or {{siteName}}.
|
||||||
|
*/
|
||||||
|
subject: string;
|
||||||
|
/**
|
||||||
|
* Email content with rich text formatting. Supports Handlebars variables like {{firstName}} and helpers like {{formatDate createdAt "long"}}. Content is converted to HTML and plain text automatically.
|
||||||
|
*/
|
||||||
|
content: {
|
||||||
|
root: {
|
||||||
|
type: string;
|
||||||
|
children: {
|
||||||
|
type: string;
|
||||||
|
version: number;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}[];
|
||||||
|
direction: ('ltr' | 'rtl') | null;
|
||||||
|
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||||
|
indent: number;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Email delivery and status tracking
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "emails".
|
||||||
|
*/
|
||||||
|
export interface Email {
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Email template used (optional if custom content provided)
|
||||||
|
*/
|
||||||
|
template?: (string | null) | EmailTemplate;
|
||||||
|
/**
|
||||||
|
* Template slug used for this email
|
||||||
|
*/
|
||||||
|
templateSlug?: string | null;
|
||||||
|
/**
|
||||||
|
* Recipient email address(es), comma-separated
|
||||||
|
*/
|
||||||
|
to: string;
|
||||||
|
/**
|
||||||
|
* CC email address(es), comma-separated
|
||||||
|
*/
|
||||||
|
cc?: string | null;
|
||||||
|
/**
|
||||||
|
* BCC email address(es), comma-separated
|
||||||
|
*/
|
||||||
|
bcc?: string | null;
|
||||||
|
/**
|
||||||
|
* Sender email address (optional, uses default if not provided)
|
||||||
|
*/
|
||||||
|
from?: string | null;
|
||||||
|
/**
|
||||||
|
* Reply-to email address
|
||||||
|
*/
|
||||||
|
replyTo?: string | null;
|
||||||
|
/**
|
||||||
|
* Email subject line
|
||||||
|
*/
|
||||||
|
subject: string;
|
||||||
|
/**
|
||||||
|
* Rendered HTML content of the email
|
||||||
|
*/
|
||||||
|
html: string;
|
||||||
|
/**
|
||||||
|
* Plain text version of the email
|
||||||
|
*/
|
||||||
|
text?: string | null;
|
||||||
|
/**
|
||||||
|
* Template variables used to render this email
|
||||||
|
*/
|
||||||
|
variables?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
/**
|
||||||
|
* When this email should be sent (leave empty for immediate)
|
||||||
|
*/
|
||||||
|
scheduledAt?: string | null;
|
||||||
|
/**
|
||||||
|
* When this email was actually sent
|
||||||
|
*/
|
||||||
|
sentAt?: string | null;
|
||||||
|
/**
|
||||||
|
* Current status of this email
|
||||||
|
*/
|
||||||
|
status: 'pending' | 'processing' | 'sent' | 'failed';
|
||||||
|
/**
|
||||||
|
* Number of send attempts made
|
||||||
|
*/
|
||||||
|
attempts?: number | null;
|
||||||
|
/**
|
||||||
|
* When the last send attempt was made
|
||||||
|
*/
|
||||||
|
lastAttemptAt?: string | null;
|
||||||
|
/**
|
||||||
|
* Last error message if send failed
|
||||||
|
*/
|
||||||
|
error?: string | null;
|
||||||
|
/**
|
||||||
|
* Email priority (1=highest, 10=lowest)
|
||||||
|
*/
|
||||||
|
priority?: number | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "users".
|
* via the `definition` "payload-jobs".
|
||||||
*/
|
*/
|
||||||
export interface User {
|
export interface PayloadJob {
|
||||||
id: string;
|
id: string;
|
||||||
|
/**
|
||||||
|
* Input data provided to the job
|
||||||
|
*/
|
||||||
|
input?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
taskStatus?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
totalTried?: number | null;
|
||||||
|
/**
|
||||||
|
* If hasError is true this job will not be retried
|
||||||
|
*/
|
||||||
|
hasError?: boolean | null;
|
||||||
|
/**
|
||||||
|
* If hasError is true, this is the error that caused it
|
||||||
|
*/
|
||||||
|
error?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
/**
|
||||||
|
* Task execution log
|
||||||
|
*/
|
||||||
|
log?:
|
||||||
|
| {
|
||||||
|
executedAt: string;
|
||||||
|
completedAt: string;
|
||||||
|
taskSlug: 'inline' | 'process-email-queue';
|
||||||
|
taskID: string;
|
||||||
|
input?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
output?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
state: 'failed' | 'succeeded';
|
||||||
|
error?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
id?: string | null;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
|
taskSlug?: ('inline' | 'process-email-queue') | null;
|
||||||
|
queue?: string | null;
|
||||||
|
waitUntil?: string | null;
|
||||||
|
processing?: boolean | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
@@ -122,6 +409,10 @@ export interface User {
|
|||||||
export interface PayloadLockedDocument {
|
export interface PayloadLockedDocument {
|
||||||
id: string;
|
id: string;
|
||||||
document?:
|
document?:
|
||||||
|
| ({
|
||||||
|
relationTo: 'users';
|
||||||
|
value: string | User;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'posts';
|
relationTo: 'posts';
|
||||||
value: string | Post;
|
value: string | Post;
|
||||||
@@ -131,12 +422,16 @@ export interface PayloadLockedDocument {
|
|||||||
value: string | Media;
|
value: string | Media;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'plugin-collection';
|
relationTo: 'email-templates';
|
||||||
value: string | PluginCollection;
|
value: string | EmailTemplate;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'users';
|
relationTo: 'emails';
|
||||||
value: string | User;
|
value: string | Email;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'payload-jobs';
|
||||||
|
value: string | PayloadJob;
|
||||||
} | null);
|
} | null);
|
||||||
globalSlug?: string | null;
|
globalSlug?: string | null;
|
||||||
user: {
|
user: {
|
||||||
@@ -180,12 +475,35 @@ export interface PayloadMigration {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users_select".
|
||||||
|
*/
|
||||||
|
export interface UsersSelect<T extends boolean = true> {
|
||||||
|
firstName?: T;
|
||||||
|
lastName?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
email?: T;
|
||||||
|
resetPasswordToken?: T;
|
||||||
|
resetPasswordExpiration?: T;
|
||||||
|
salt?: T;
|
||||||
|
hash?: T;
|
||||||
|
loginAttempts?: T;
|
||||||
|
lockUntil?: T;
|
||||||
|
sessions?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
id?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
expiresAt?: T;
|
||||||
|
};
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "posts_select".
|
* via the `definition` "posts_select".
|
||||||
*/
|
*/
|
||||||
export interface PostsSelect<T extends boolean = true> {
|
export interface PostsSelect<T extends boolean = true> {
|
||||||
addedByPlugin?: T;
|
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
@@ -208,27 +526,72 @@ export interface MediaSelect<T extends boolean = true> {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "plugin-collection_select".
|
* via the `definition` "email-templates_select".
|
||||||
*/
|
*/
|
||||||
export interface PluginCollectionSelect<T extends boolean = true> {
|
export interface EmailTemplatesSelect<T extends boolean = true> {
|
||||||
id?: T;
|
name?: T;
|
||||||
|
slug?: T;
|
||||||
|
subject?: T;
|
||||||
|
content?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "users_select".
|
* via the `definition` "emails_select".
|
||||||
*/
|
*/
|
||||||
export interface UsersSelect<T extends boolean = true> {
|
export interface EmailsSelect<T extends boolean = true> {
|
||||||
|
template?: T;
|
||||||
|
templateSlug?: T;
|
||||||
|
to?: T;
|
||||||
|
cc?: T;
|
||||||
|
bcc?: T;
|
||||||
|
from?: T;
|
||||||
|
replyTo?: T;
|
||||||
|
subject?: T;
|
||||||
|
html?: T;
|
||||||
|
text?: T;
|
||||||
|
variables?: T;
|
||||||
|
scheduledAt?: T;
|
||||||
|
sentAt?: T;
|
||||||
|
status?: T;
|
||||||
|
attempts?: T;
|
||||||
|
lastAttemptAt?: T;
|
||||||
|
error?: T;
|
||||||
|
priority?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "payload-jobs_select".
|
||||||
|
*/
|
||||||
|
export interface PayloadJobsSelect<T extends boolean = true> {
|
||||||
|
input?: T;
|
||||||
|
taskStatus?: T;
|
||||||
|
completedAt?: T;
|
||||||
|
totalTried?: T;
|
||||||
|
hasError?: T;
|
||||||
|
error?: T;
|
||||||
|
log?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
executedAt?: T;
|
||||||
|
completedAt?: T;
|
||||||
|
taskSlug?: T;
|
||||||
|
taskID?: T;
|
||||||
|
input?: T;
|
||||||
|
output?: T;
|
||||||
|
state?: T;
|
||||||
|
error?: T;
|
||||||
|
id?: T;
|
||||||
|
};
|
||||||
|
taskSlug?: T;
|
||||||
|
queue?: T;
|
||||||
|
waitUntil?: T;
|
||||||
|
processing?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
@@ -262,6 +625,14 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "ProcessEmailQueueJob".
|
||||||
|
*/
|
||||||
|
export interface ProcessEmailQueueJob {
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "auth".
|
* via the `definition` "auth".
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||||
|
import {
|
||||||
|
BlocksFeature,
|
||||||
|
FixedToolbarFeature,
|
||||||
|
HeadingFeature,
|
||||||
|
HorizontalRuleFeature,
|
||||||
|
InlineToolbarFeature,
|
||||||
|
lexicalHTML,
|
||||||
|
} from '@payloadcms/richtext-lexical'
|
||||||
import { MongoMemoryReplSet } from 'mongodb-memory-server'
|
import { MongoMemoryReplSet } from 'mongodb-memory-server'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { buildConfig } from 'payload'
|
import { buildConfig } from 'payload'
|
||||||
import { tempProject } from 'temp-project'
|
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
|
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
|
||||||
import { seed } from './seed.js'
|
import { seed, seedUser } from './seed.js'
|
||||||
|
import mailingPlugin from "../src/plugin.js"
|
||||||
|
import { sendEmail } from "../src/utils/helpers.js"
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
@@ -18,15 +27,32 @@ if (!process.env.ROOT_DIR) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buildConfigWithMemoryDB = async () => {
|
const buildConfigWithMemoryDB = async () => {
|
||||||
if (process.env.NODE_ENV === 'test') {
|
// Use in-memory MongoDB for development and testing
|
||||||
|
if (process.env.NODE_ENV === 'test' || process.env.USE_MEMORY_DB === 'true' || !process.env.DATABASE_URI) {
|
||||||
|
console.log('🚀 Starting MongoDB in-memory database...')
|
||||||
|
|
||||||
const memoryDB = await MongoMemoryReplSet.create({
|
const memoryDB = await MongoMemoryReplSet.create({
|
||||||
replSet: {
|
replSet: {
|
||||||
count: 3,
|
count: 1, // Single instance for dev (faster startup)
|
||||||
dbName: 'payloadmemory',
|
dbName: process.env.NODE_ENV === 'test' ? 'payloadmemory' : 'payload-mailing-dev',
|
||||||
|
storageEngine: 'wiredTiger',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true`
|
const uri = `${memoryDB.getUri()}&retryWrites=true`
|
||||||
|
process.env.DATABASE_URI = uri
|
||||||
|
|
||||||
|
console.log('✅ MongoDB in-memory database started')
|
||||||
|
console.log(`📊 Database URI: ${uri.replace(/mongodb:\/\/[^@]*@/, 'mongodb://***@')}`)
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('🛑 Stopping MongoDB in-memory database...')
|
||||||
|
await memoryDB.stop()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log(`🔗 Using external MongoDB: ${process.env.DATABASE_URI?.replace(/mongodb:\/\/[^@]*@/, 'mongodb://***@')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildConfig({
|
return buildConfig({
|
||||||
@@ -36,6 +62,52 @@ const buildConfigWithMemoryDB = async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
collections: [
|
collections: [
|
||||||
|
{
|
||||||
|
slug: 'users',
|
||||||
|
auth: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'firstName',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lastName',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hooks: {
|
||||||
|
afterChange: [
|
||||||
|
async ({ doc, operation, req, previousDoc }) => {
|
||||||
|
// Only send welcome email on user creation, not updates
|
||||||
|
if (operation === 'create' && doc.email) {
|
||||||
|
try {
|
||||||
|
console.log('📧 Queuing welcome email for new user:', doc.email)
|
||||||
|
|
||||||
|
// Queue the welcome email using template slug
|
||||||
|
const emailId = await sendEmail(req.payload, {
|
||||||
|
templateSlug: 'welcome-email',
|
||||||
|
to: doc.email,
|
||||||
|
variables: {
|
||||||
|
firstName: doc.firstName || doc.email?.split('@')?.[0],
|
||||||
|
siteName: 'PayloadCMS Mailing Demo',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
isPremium: false,
|
||||||
|
dashboardUrl: 'http://localhost:3000/admin',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('✅ Welcome email queued successfully. Email ID:', emailId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error queuing welcome email:', error)
|
||||||
|
// Don't throw - we don't want to fail user creation if email fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: 'posts',
|
slug: 'posts',
|
||||||
fields: [],
|
fields: [],
|
||||||
@@ -58,9 +130,93 @@ const buildConfigWithMemoryDB = async () => {
|
|||||||
await seed(payload)
|
await seed(payload)
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
tempProject({
|
mailingPlugin({
|
||||||
collections: {
|
defaultFrom: 'noreply@test.com',
|
||||||
posts: true,
|
initOrder: 'after',
|
||||||
|
transport: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 1025, // MailHog port for dev
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: 'test',
|
||||||
|
pass: 'test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retryAttempts: 3,
|
||||||
|
retryDelay: 60000, // 1 minute for dev
|
||||||
|
queue: 'email-queue',
|
||||||
|
|
||||||
|
// Optional: Custom rich text editor configuration
|
||||||
|
// Comment out to use default lexical editor
|
||||||
|
richTextEditor: lexicalEditor({
|
||||||
|
features: ({ defaultFeatures }) => [
|
||||||
|
...defaultFeatures,
|
||||||
|
// Example: Add custom features for email templates
|
||||||
|
FixedToolbarFeature(),
|
||||||
|
InlineToolbarFeature(),
|
||||||
|
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3'] }),
|
||||||
|
HorizontalRuleFeature(),
|
||||||
|
// You can add more features like:
|
||||||
|
// BlocksFeature({ blocks: [...] }),
|
||||||
|
// LinkFeature({ ... }),
|
||||||
|
// etc.
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
|
||||||
|
emailWrapper: (email) => {
|
||||||
|
// Example: wrap email content in a custom layout
|
||||||
|
const wrappedHtml = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${email.subject}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; }
|
||||||
|
.header { background: #007bff; color: white; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background: #f8f9fa; padding: 15px; text-align: center; font-size: 12px; color: #6c757d; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>My Company</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
${email.html}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
This email was sent from My Company. If you have questions, contact support@mycompany.com
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
const wrappedText = `
|
||||||
|
MY COMPANY
|
||||||
|
==========
|
||||||
|
|
||||||
|
${email.text || email.html?.replace(/<[^>]*>/g, '')}
|
||||||
|
|
||||||
|
---
|
||||||
|
This email was sent from My Company.
|
||||||
|
If you have questions, contact support@mycompany.com
|
||||||
|
`
|
||||||
|
|
||||||
|
return {
|
||||||
|
...email,
|
||||||
|
html: wrappedHtml,
|
||||||
|
text: wrappedText.trim(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Called after mailing plugin is fully initialized
|
||||||
|
onReady: async (payload) => {
|
||||||
|
await seedUser(payload)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
210
dev/seed.ts
210
dev/seed.ts
@@ -3,8 +3,214 @@ import type { Payload } from 'payload'
|
|||||||
import { devUser } from './helpers/credentials.js'
|
import { devUser } from './helpers/credentials.js'
|
||||||
|
|
||||||
export const seed = async (payload: Payload) => {
|
export const seed = async (payload: Payload) => {
|
||||||
|
// Create example email template
|
||||||
|
const { totalDocs: templateCount } = await payload.count({
|
||||||
|
collection: 'email-templates' as const,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (templateCount === 0) {
|
||||||
|
// Simple welcome email template
|
||||||
|
await payload.create({
|
||||||
|
collection: 'email-templates' as const,
|
||||||
|
data: {
|
||||||
|
name: 'Welcome Email',
|
||||||
|
slug: 'welcome-email',
|
||||||
|
subject: 'Welcome to {{siteName}}, {{firstName}}!',
|
||||||
|
content: {
|
||||||
|
root: {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
detail: 0,
|
||||||
|
format: 0,
|
||||||
|
mode: 'normal',
|
||||||
|
style: '',
|
||||||
|
text: 'Welcome {{firstName}}! 🎉',
|
||||||
|
type: 'text',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
tag: 'h1',
|
||||||
|
type: 'heading',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
detail: 0,
|
||||||
|
format: 0,
|
||||||
|
mode: 'normal',
|
||||||
|
style: '',
|
||||||
|
text: "We're thrilled to have you join {{siteName}}! This email demonstrates how easy it is to create beautiful emails using PayloadCMS's rich text editor with Handlebars variables.",
|
||||||
|
type: 'text',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
type: 'paragraph',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
detail: 0,
|
||||||
|
format: 1,
|
||||||
|
mode: 'normal',
|
||||||
|
style: '',
|
||||||
|
text: 'What you can do:',
|
||||||
|
type: 'text',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
type: 'paragraph',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
detail: 0,
|
||||||
|
format: 0,
|
||||||
|
mode: 'normal',
|
||||||
|
style: '',
|
||||||
|
text: 'Create beautiful emails with rich text formatting',
|
||||||
|
type: 'text',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
type: 'listitem',
|
||||||
|
value: 1,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
detail: 0,
|
||||||
|
format: 0,
|
||||||
|
mode: 'normal',
|
||||||
|
style: '',
|
||||||
|
text: 'Use the emailWrapper hook to add custom layouts',
|
||||||
|
type: 'text',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
type: 'listitem',
|
||||||
|
value: 2,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
detail: 0,
|
||||||
|
format: 0,
|
||||||
|
mode: 'normal',
|
||||||
|
style: '',
|
||||||
|
text: 'Queue and schedule emails effortlessly',
|
||||||
|
type: 'text',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
type: 'listitem',
|
||||||
|
value: 3,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
listType: 'bullet',
|
||||||
|
start: 1,
|
||||||
|
tag: 'ul',
|
||||||
|
type: 'list',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
detail: 0,
|
||||||
|
format: 0,
|
||||||
|
mode: 'normal',
|
||||||
|
style: '',
|
||||||
|
text: 'Get started by exploring the admin panel and creating your own email templates. Your account was created on {{formatDate createdAt "long"}}.',
|
||||||
|
type: 'text',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
type: 'paragraph',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
detail: 0,
|
||||||
|
format: 0,
|
||||||
|
mode: 'normal',
|
||||||
|
style: '',
|
||||||
|
text: 'Best regards,',
|
||||||
|
type: 'text',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'linebreak',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
detail: 0,
|
||||||
|
format: 0,
|
||||||
|
mode: 'normal',
|
||||||
|
style: '',
|
||||||
|
text: 'The {{siteName}} Team',
|
||||||
|
type: 'text',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
type: 'paragraph',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
direction: 'ltr',
|
||||||
|
format: '',
|
||||||
|
indent: 0,
|
||||||
|
type: 'root',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('✅ Example email template created successfully')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const seedUser = async (payload: Payload) => {
|
||||||
|
// Create dev user if not exists - called after mailing plugin is initialized
|
||||||
const { totalDocs } = await payload.count({
|
const { totalDocs } = await payload.count({
|
||||||
collection: 'users',
|
collection: 'users' as const,
|
||||||
where: {
|
where: {
|
||||||
email: {
|
email: {
|
||||||
equals: devUser.email,
|
equals: devUser.email,
|
||||||
@@ -14,7 +220,7 @@ export const seed = async (payload: Payload) => {
|
|||||||
|
|
||||||
if (!totalDocs) {
|
if (!totalDocs) {
|
||||||
await payload.create({
|
await payload.create({
|
||||||
collection: 'users',
|
collection: 'users' as const,
|
||||||
data: devUser,
|
data: devUser,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
45
dev/start-dev.js
Executable file
45
dev/start-dev.js
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Development startup script for PayloadCMS Mailing Plugin
|
||||||
|
// This ensures proper environment setup and provides helpful information
|
||||||
|
|
||||||
|
console.log('🚀 PayloadCMS Mailing Plugin - Development Mode')
|
||||||
|
console.log('=' .repeat(50))
|
||||||
|
|
||||||
|
// Set development environment
|
||||||
|
process.env.NODE_ENV = process.env.NODE_ENV || 'development'
|
||||||
|
|
||||||
|
// Enable in-memory MongoDB by default for development
|
||||||
|
if (!process.env.DATABASE_URI) {
|
||||||
|
process.env.USE_MEMORY_DB = 'true'
|
||||||
|
console.log('📦 Using in-memory MongoDB (no installation required)')
|
||||||
|
} else {
|
||||||
|
console.log(`🔗 Using external MongoDB: ${process.env.DATABASE_URI}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('')
|
||||||
|
console.log('🔧 Starting development server...')
|
||||||
|
console.log('📧 Mailing plugin configured with test transport')
|
||||||
|
console.log('🎯 Test interface will be available at: /mailing-test')
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
// Import and start Next.js
|
||||||
|
import('next/dist/cli/next-dev.js')
|
||||||
|
.then(({ nextDev }) => {
|
||||||
|
nextDev([])
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Failed to start development server:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('\n🛑 Shutting down development server...')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\n🛑 Shutting down development server...')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
40
dev/test-plugin.mjs
Normal file
40
dev/test-plugin.mjs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Simple test to verify plugin can be imported and initialized
|
||||||
|
import { mailingPlugin, sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
|
||||||
|
|
||||||
|
console.log('✅ Plugin imports successfully')
|
||||||
|
console.log('✅ mailingPlugin:', typeof mailingPlugin)
|
||||||
|
console.log('✅ sendEmail:', typeof sendEmail)
|
||||||
|
console.log('✅ scheduleEmail:', typeof scheduleEmail)
|
||||||
|
|
||||||
|
// Test plugin configuration
|
||||||
|
try {
|
||||||
|
const testConfig = {
|
||||||
|
collections: [],
|
||||||
|
db: null,
|
||||||
|
secret: 'test'
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginFn = mailingPlugin({
|
||||||
|
defaultFrom: 'test@example.com',
|
||||||
|
transport: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 1025,
|
||||||
|
secure: false,
|
||||||
|
auth: { user: 'test', pass: 'test' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const configWithPlugin = pluginFn(testConfig)
|
||||||
|
console.log('✅ Plugin configuration works')
|
||||||
|
console.log('✅ Collections added:', configWithPlugin.collections?.length > testConfig.collections.length)
|
||||||
|
console.log('✅ Jobs configured:', !!configWithPlugin.jobs)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Plugin configuration error:', error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 PayloadCMS Mailing Plugin is ready for development!')
|
||||||
|
console.log('\nNext steps:')
|
||||||
|
console.log('1. Run: npm run dev (in dev directory)')
|
||||||
|
console.log('2. Open: http://localhost:3000/admin')
|
||||||
|
console.log('3. Test: http://localhost:3000/mailing-test')
|
||||||
@@ -31,5 +31,10 @@
|
|||||||
},
|
},
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"emitDeclarationOnly": false,
|
"emitDeclarationOnly": false,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-mailing",
|
"name": "@xtr-dev/payload-mailing",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"description": "Template-based email system with scheduling and job processing for PayloadCMS",
|
"description": "Template-based email system with scheduling and job processing for PayloadCMS",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -13,12 +13,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run copyfiles && npm run build:types && npm run build:swc",
|
"build": "npm run clean && npm run build:tsc",
|
||||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
"build:tsc": "tsc --project ./tsconfig.build.json",
|
||||||
"build:types": "tsc --outDir dist --rootDir ./src",
|
|
||||||
"clean": "rimraf {dist,*.tsbuildinfo}",
|
"clean": "rimraf {dist,*.tsbuildinfo}",
|
||||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
||||||
"dev": "next dev dev --turbo",
|
"dev": "next dev dev --turbo",
|
||||||
|
"dev:memory": "cd dev && USE_MEMORY_DB=true npm run dev",
|
||||||
|
"dev:start": "cd dev && node start-dev.js",
|
||||||
"dev:generate-importmap": "npm run dev:payload generate:importmap",
|
"dev:generate-importmap": "npm run dev:payload generate:importmap",
|
||||||
"dev:generate-types": "npm run dev:payload generate:types",
|
"dev:generate-types": "npm run dev:payload generate:types",
|
||||||
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
|
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
"scheduling",
|
"scheduling",
|
||||||
"jobs",
|
"jobs",
|
||||||
"mailing",
|
"mailing",
|
||||||
"handlebars"
|
"richtext"
|
||||||
],
|
],
|
||||||
"author": "XTR Development",
|
"author": "XTR Development",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -8950,7 +8950,7 @@ snapshots:
|
|||||||
eslint: 9.35.0
|
eslint: 9.35.0
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.35.0)(typescript@5.9.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0))(eslint@9.35.0)
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.35.0)(typescript@5.9.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0))(eslint@9.35.0)
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0)
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.35.0)(typescript@5.9.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0)
|
||||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0)
|
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0)
|
||||||
eslint-plugin-react: 7.37.5(eslint@9.35.0)
|
eslint-plugin-react: 7.37.5(eslint@9.35.0)
|
||||||
eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0)
|
eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0)
|
||||||
@@ -8984,7 +8984,7 @@ snapshots:
|
|||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
unrs-resolver: 1.11.1
|
unrs-resolver: 1.11.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0)
|
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.35.0)(typescript@5.9.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0)
|
||||||
eslint-plugin-import-x: 4.6.1(eslint@9.35.0)(typescript@5.9.2)
|
eslint-plugin-import-x: 4.6.1(eslint@9.35.0)(typescript@5.9.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -9041,7 +9041,7 @@ snapshots:
|
|||||||
- typescript
|
- typescript
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0):
|
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.35.0)(typescript@5.9.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CollectionConfig } from 'payload/types'
|
import type { CollectionConfig, RichTextField } from 'payload'
|
||||||
|
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||||
|
|
||||||
const EmailTemplates: CollectionConfig = {
|
export const createEmailTemplatesCollection = (editor?: RichTextField['editor']): CollectionConfig => ({
|
||||||
slug: 'email-templates',
|
slug: 'email-templates',
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'name',
|
useAsTitle: 'name',
|
||||||
@@ -22,84 +23,50 @@ const EmailTemplates: CollectionConfig = {
|
|||||||
description: 'A descriptive name for this email template',
|
description: 'A descriptive name for this email template',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'slug',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
admin: {
|
||||||
|
description: 'Unique identifier for this template (e.g., "welcome-email", "password-reset")',
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
beforeChange: [
|
||||||
|
({ value }) => {
|
||||||
|
if (value) {
|
||||||
|
return value.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'subject',
|
name: 'subject',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
required: true,
|
required: true,
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Email subject line (supports Handlebars variables)',
|
description: 'Email subject line. You can use Handlebars variables like {{firstName}} or {{siteName}}.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'htmlTemplate',
|
name: 'content',
|
||||||
type: 'textarea',
|
type: 'richText',
|
||||||
required: true,
|
required: true,
|
||||||
|
editor: editor || lexicalEditor({
|
||||||
|
features: ({ defaultFeatures }) => [
|
||||||
|
...defaultFeatures,
|
||||||
|
],
|
||||||
|
}),
|
||||||
admin: {
|
admin: {
|
||||||
description: 'HTML email template (supports Handlebars syntax)',
|
description: 'Email content with rich text formatting. Supports Handlebars variables like {{firstName}} and helpers like {{formatDate createdAt "long"}}. Content is converted to HTML and plain text automatically.',
|
||||||
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,
|
timestamps: true,
|
||||||
}
|
})
|
||||||
|
|
||||||
|
// Default export for backward compatibility
|
||||||
|
const EmailTemplates = createEmailTemplatesCollection()
|
||||||
export default EmailTemplates
|
export default EmailTemplates
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { CollectionConfig } from 'payload/types'
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
const EmailOutbox: CollectionConfig = {
|
const Emails: CollectionConfig = {
|
||||||
slug: 'email-outbox',
|
slug: 'emails',
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'subject',
|
useAsTitle: 'subject',
|
||||||
defaultColumns: ['subject', 'to', 'status', 'scheduledAt', 'sentAt'],
|
defaultColumns: ['subject', 'to', 'status', 'scheduledAt', 'sentAt'],
|
||||||
group: 'Mailing',
|
group: 'Mailing',
|
||||||
|
description: 'Email delivery and status tracking',
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: () => true,
|
read: () => true,
|
||||||
@@ -17,7 +18,7 @@ const EmailOutbox: CollectionConfig = {
|
|||||||
{
|
{
|
||||||
name: 'template',
|
name: 'template',
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
relationTo: 'email-templates',
|
relationTo: 'email-templates' as const,
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Email template used (optional if custom content provided)',
|
description: 'Email template used (optional if custom content provided)',
|
||||||
},
|
},
|
||||||
@@ -26,22 +27,25 @@ const EmailOutbox: CollectionConfig = {
|
|||||||
name: 'to',
|
name: 'to',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
required: true,
|
required: true,
|
||||||
|
hasMany: true,
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Recipient email address(es), comma-separated',
|
description: 'Recipient email addresses',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'cc',
|
name: 'cc',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
hasMany: true,
|
||||||
admin: {
|
admin: {
|
||||||
description: 'CC email address(es), comma-separated',
|
description: 'CC email addresses',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'bcc',
|
name: 'bcc',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
hasMany: true,
|
||||||
admin: {
|
admin: {
|
||||||
description: 'BCC email address(es), comma-separated',
|
description: 'BCC email addresses',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -161,20 +165,20 @@ const EmailOutbox: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
timestamps: true,
|
timestamps: true,
|
||||||
indexes: [
|
// indexes: [
|
||||||
{
|
// {
|
||||||
fields: {
|
// fields: {
|
||||||
status: 1,
|
// status: 1,
|
||||||
scheduledAt: 1,
|
// scheduledAt: 1,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
fields: {
|
// fields: {
|
||||||
priority: -1,
|
// priority: -1,
|
||||||
createdAt: 1,
|
// createdAt: 1,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EmailOutbox
|
export default Emails
|
||||||
18
src/index.ts
18
src/index.ts
@@ -1,25 +1,23 @@
|
|||||||
// Main plugin export
|
// Main plugin export
|
||||||
export { default as mailingPlugin } from './plugin'
|
export { mailingPlugin, default as mailingPluginDefault } from './plugin.js'
|
||||||
export { mailingPlugin } from './plugin'
|
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export * from './types'
|
export * from './types/index.js'
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
export { MailingService } from './services/MailingService'
|
export { MailingService } from './services/MailingService.js'
|
||||||
|
|
||||||
// Collections
|
// Collections
|
||||||
export { default as EmailTemplates } from './collections/EmailTemplates'
|
export { default as EmailTemplates, createEmailTemplatesCollection } from './collections/EmailTemplates.js'
|
||||||
export { default as EmailOutbox } from './collections/EmailOutbox'
|
export { default as Emails } from './collections/Emails.js'
|
||||||
|
|
||||||
// Jobs
|
// Jobs are integrated into the plugin configuration
|
||||||
export * from './jobs'
|
|
||||||
|
|
||||||
// Utility functions for developers
|
// Utility functions for developers
|
||||||
export {
|
export {
|
||||||
getMailing,
|
getMailing,
|
||||||
sendEmail,
|
sendEmail,
|
||||||
scheduleEmail,
|
scheduleEmail,
|
||||||
processOutbox,
|
processEmails,
|
||||||
retryFailedEmails,
|
retryFailedEmails,
|
||||||
} from './utils/helpers'
|
} from './utils/helpers.js'
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
import { Job } from 'payload/jobs'
|
import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js'
|
||||||
import { processOutboxJob, ProcessOutboxJobData } from './processOutboxJob'
|
import { MailingService } from '../services/MailingService.js'
|
||||||
import { MailingService } from '../services/MailingService'
|
|
||||||
|
|
||||||
export const createMailingJobs = (mailingService: MailingService): Job[] => {
|
export const createMailingJobs = (mailingService: MailingService): any[] => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
slug: 'processOutbox',
|
slug: 'processEmails',
|
||||||
handler: async ({ job, req }) => {
|
handler: async ({ job, req }: { job: any; req: any }) => {
|
||||||
return processOutboxJob(
|
return processEmailsJob(
|
||||||
job as { data: ProcessOutboxJobData },
|
job as { data: ProcessEmailsJobData },
|
||||||
{ req, mailingService }
|
{ req, mailingService }
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
interfaceName: 'ProcessOutboxJob',
|
interfaceName: 'ProcessEmailsJob',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export * from './processOutboxJob'
|
export * from './processEmailsJob.js'
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
import { PayloadRequest } from 'payload/types'
|
import type { PayloadRequest } from 'payload'
|
||||||
import { MailingService } from '../services/MailingService'
|
import { MailingService } from '../services/MailingService.js'
|
||||||
|
|
||||||
export interface ProcessOutboxJobData {
|
export interface ProcessEmailsJobData {
|
||||||
type: 'process-outbox' | 'retry-failed'
|
type: 'process-emails' | 'retry-failed'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const processOutboxJob = async (
|
export const processEmailsJob = async (
|
||||||
job: { data: ProcessOutboxJobData },
|
job: { data: ProcessEmailsJobData },
|
||||||
context: { req: PayloadRequest; mailingService: MailingService }
|
context: { req: PayloadRequest; mailingService: MailingService }
|
||||||
) => {
|
) => {
|
||||||
const { mailingService } = context
|
const { mailingService } = context
|
||||||
const { type } = job.data
|
const { type } = job.data
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (type === 'process-outbox') {
|
if (type === 'process-emails') {
|
||||||
await mailingService.processOutbox()
|
await mailingService.processEmails()
|
||||||
console.log('Outbox processing completed successfully')
|
console.log('Email processing completed successfully')
|
||||||
} else if (type === 'retry-failed') {
|
} else if (type === 'retry-failed') {
|
||||||
await mailingService.retryFailedEmails()
|
await mailingService.retryFailedEmails()
|
||||||
console.log('Failed email retry completed successfully')
|
console.log('Failed email retry completed successfully')
|
||||||
@@ -26,10 +26,10 @@ export const processOutboxJob = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scheduleOutboxJob = async (
|
export const scheduleEmailsJob = async (
|
||||||
payload: any,
|
payload: any,
|
||||||
queueName: string,
|
queueName: string,
|
||||||
jobType: 'process-outbox' | 'retry-failed',
|
jobType: 'process-emails' | 'retry-failed',
|
||||||
delay?: number
|
delay?: number
|
||||||
) => {
|
) => {
|
||||||
if (!payload.jobs) {
|
if (!payload.jobs) {
|
||||||
@@ -40,7 +40,7 @@ export const scheduleOutboxJob = async (
|
|||||||
try {
|
try {
|
||||||
await payload.jobs.queue({
|
await payload.jobs.queue({
|
||||||
queue: queueName,
|
queue: queueName,
|
||||||
task: 'processOutbox',
|
task: 'processEmails',
|
||||||
input: { type: jobType },
|
input: { type: jobType },
|
||||||
waitUntil: delay ? new Date(Date.now() + delay) : undefined,
|
waitUntil: delay ? new Date(Date.now() + delay) : undefined,
|
||||||
})
|
})
|
||||||
141
src/plugin.ts
141
src/plugin.ts
@@ -1,26 +1,57 @@
|
|||||||
import { Config } from 'payload/config'
|
import type { Config } from 'payload'
|
||||||
import { MailingPluginConfig, MailingContext } from './types'
|
import { MailingPluginConfig, MailingContext } from './types/index.js'
|
||||||
import { MailingService } from './services/MailingService'
|
import { MailingService } from './services/MailingService.js'
|
||||||
import { createMailingJobs } from './jobs'
|
import { createEmailTemplatesCollection } from './collections/EmailTemplates.js'
|
||||||
import EmailTemplates from './collections/EmailTemplates'
|
import Emails from './collections/Emails.js'
|
||||||
import EmailOutbox from './collections/EmailOutbox'
|
|
||||||
import { scheduleOutboxJob } from './jobs/processOutboxJob'
|
|
||||||
|
|
||||||
export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
|
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'
|
const queueName = pluginConfig.queue || 'default'
|
||||||
|
|
||||||
// Update collection slugs if custom ones are provided
|
// Handle templates collection configuration
|
||||||
|
const templatesConfig = pluginConfig.collections?.templates
|
||||||
|
const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates'
|
||||||
|
const templatesOverrides = typeof templatesConfig === 'object' ? templatesConfig : {}
|
||||||
|
|
||||||
|
// Create base templates collection with custom editor if provided
|
||||||
|
const baseTemplatesCollection = createEmailTemplatesCollection(pluginConfig.richTextEditor)
|
||||||
|
|
||||||
const templatesCollection = {
|
const templatesCollection = {
|
||||||
...EmailTemplates,
|
...baseTemplatesCollection,
|
||||||
slug: templatesSlug,
|
slug: templatesSlug,
|
||||||
|
...templatesOverrides,
|
||||||
|
// Ensure admin config is properly merged
|
||||||
|
admin: {
|
||||||
|
...baseTemplatesCollection.admin,
|
||||||
|
...templatesOverrides.admin,
|
||||||
|
},
|
||||||
|
// Ensure access config is properly merged
|
||||||
|
access: {
|
||||||
|
...baseTemplatesCollection.access,
|
||||||
|
...templatesOverrides.access,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const outboxCollection = {
|
// Handle emails collection configuration
|
||||||
...EmailOutbox,
|
const emailsConfig = pluginConfig.collections?.emails
|
||||||
slug: outboxSlug,
|
const emailsSlug = typeof emailsConfig === 'string' ? emailsConfig : 'emails'
|
||||||
fields: EmailOutbox.fields.map(field => {
|
const emailsOverrides = typeof emailsConfig === 'object' ? emailsConfig : {}
|
||||||
|
|
||||||
|
const emailsCollection = {
|
||||||
|
...Emails,
|
||||||
|
slug: emailsSlug,
|
||||||
|
...emailsOverrides,
|
||||||
|
// Ensure admin config is properly merged
|
||||||
|
admin: {
|
||||||
|
...Emails.admin,
|
||||||
|
...emailsOverrides.admin,
|
||||||
|
},
|
||||||
|
// Ensure access config is properly merged
|
||||||
|
access: {
|
||||||
|
...Emails.access,
|
||||||
|
...emailsOverrides.access,
|
||||||
|
},
|
||||||
|
// Update relationship fields to point to correct templates collection
|
||||||
|
fields: (emailsOverrides.fields || Emails.fields).map((field: any) => {
|
||||||
if (field.name === 'template' && field.type === 'relationship') {
|
if (field.name === 'template' && field.type === 'relationship') {
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
@@ -36,63 +67,77 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
|
|||||||
collections: [
|
collections: [
|
||||||
...(config.collections || []),
|
...(config.collections || []),
|
||||||
templatesCollection,
|
templatesCollection,
|
||||||
outboxCollection,
|
emailsCollection,
|
||||||
],
|
],
|
||||||
jobs: {
|
jobs: {
|
||||||
...(config.jobs || {}),
|
...(config.jobs || {}),
|
||||||
tasks: [
|
tasks: [
|
||||||
...(config.jobs?.tasks || []),
|
...(config.jobs?.tasks || []),
|
||||||
// Jobs will be added via onInit hook
|
{
|
||||||
|
slug: 'process-email-queue',
|
||||||
|
handler: async ({ job, req }: { job: any; req: any }) => {
|
||||||
|
try {
|
||||||
|
const mailingService = new MailingService((req as any).payload, pluginConfig)
|
||||||
|
|
||||||
|
console.log('🔄 Processing email queue (pending + failed emails)...')
|
||||||
|
|
||||||
|
// Process pending emails first
|
||||||
|
await mailingService.processEmails()
|
||||||
|
|
||||||
|
// Then retry failed emails
|
||||||
|
await mailingService.retryFailedEmails()
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: {
|
||||||
|
success: true,
|
||||||
|
message: 'Email queue processed successfully (pending and failed emails)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error processing email queue:', error)
|
||||||
|
return {
|
||||||
|
output: {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interfaceName: 'ProcessEmailQueueJob',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
onInit: async (payload) => {
|
onInit: async (payload: any) => {
|
||||||
// Call original onInit if it exists
|
if (pluginConfig.initOrder === 'after' && config.onInit) {
|
||||||
if (config.onInit) {
|
|
||||||
await config.onInit(payload)
|
await config.onInit(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize mailing service
|
// Initialize mailing service
|
||||||
const mailingService = new MailingService(payload, pluginConfig)
|
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
|
// Add mailing context to payload for developer access
|
||||||
;(payload as any).mailing = {
|
;(payload as any).mailing = {
|
||||||
|
payload,
|
||||||
service: mailingService,
|
service: mailingService,
|
||||||
config: pluginConfig,
|
config: pluginConfig,
|
||||||
collections: {
|
collections: {
|
||||||
templates: templatesSlug,
|
templates: templatesSlug,
|
||||||
outbox: outboxSlug,
|
emails: emailsSlug,
|
||||||
},
|
},
|
||||||
} as MailingContext
|
} as MailingContext
|
||||||
|
|
||||||
console.log('PayloadCMS Mailing Plugin initialized successfully')
|
console.log('PayloadCMS Mailing Plugin initialized successfully')
|
||||||
|
|
||||||
|
// Call onReady callback if provided
|
||||||
|
if (pluginConfig.onReady) {
|
||||||
|
await pluginConfig.onReady(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginConfig.initOrder !== 'after' && config.onInit) {
|
||||||
|
await config.onInit(payload)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default mailingPlugin
|
export default mailingPlugin
|
||||||
|
|||||||
@@ -6,22 +6,28 @@ import {
|
|||||||
SendEmailOptions,
|
SendEmailOptions,
|
||||||
MailingService as IMailingService,
|
MailingService as IMailingService,
|
||||||
EmailTemplate,
|
EmailTemplate,
|
||||||
OutboxEmail,
|
QueuedEmail,
|
||||||
MailingTransportConfig
|
MailingTransportConfig,
|
||||||
} from '../types'
|
EmailObject
|
||||||
|
} from '../types/index.js'
|
||||||
|
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
|
||||||
|
|
||||||
export class MailingService implements IMailingService {
|
export class MailingService implements IMailingService {
|
||||||
private payload: Payload
|
private payload: Payload
|
||||||
private config: MailingPluginConfig
|
private config: MailingPluginConfig
|
||||||
private transporter: Transporter
|
private transporter!: Transporter
|
||||||
private templatesCollection: string
|
private templatesCollection: string
|
||||||
private outboxCollection: string
|
private emailsCollection: string
|
||||||
|
|
||||||
constructor(payload: Payload, config: MailingPluginConfig) {
|
constructor(payload: Payload, config: MailingPluginConfig) {
|
||||||
this.payload = payload
|
this.payload = payload
|
||||||
this.config = config
|
this.config = config
|
||||||
this.templatesCollection = config.collections?.templates || 'email-templates'
|
|
||||||
this.outboxCollection = config.collections?.outbox || 'email-outbox'
|
const templatesConfig = config.collections?.templates
|
||||||
|
this.templatesCollection = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates'
|
||||||
|
|
||||||
|
const emailsConfig = config.collections?.emails
|
||||||
|
this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails'
|
||||||
|
|
||||||
this.initializeTransporter()
|
this.initializeTransporter()
|
||||||
this.registerHandlebarsHelpers()
|
this.registerHandlebarsHelpers()
|
||||||
@@ -32,7 +38,7 @@ export class MailingService implements IMailingService {
|
|||||||
if ('sendMail' in this.config.transport) {
|
if ('sendMail' in this.config.transport) {
|
||||||
this.transporter = this.config.transport
|
this.transporter = this.config.transport
|
||||||
} else {
|
} else {
|
||||||
this.transporter = nodemailer.createTransporter(this.config.transport as MailingTransportConfig)
|
this.transporter = nodemailer.createTransport(this.config.transport as MailingTransportConfig)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Email transport configuration is required')
|
throw new Error('Email transport configuration is required')
|
||||||
@@ -64,7 +70,7 @@ export class MailingService implements IMailingService {
|
|||||||
}).format(amount)
|
}).format(amount)
|
||||||
})
|
})
|
||||||
|
|
||||||
Handlebars.registerHelper('ifEquals', function(arg1: any, arg2: any, options: any) {
|
Handlebars.registerHelper('ifEquals', function(this: any, arg1: any, arg2: any, options: any) {
|
||||||
return (arg1 === arg2) ? options.fn(this) : options.inverse(this)
|
return (arg1 === arg2) ? options.fn(this) : options.inverse(this)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -75,29 +81,34 @@ export class MailingService implements IMailingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendEmail(options: SendEmailOptions): Promise<string> {
|
async sendEmail(options: SendEmailOptions): Promise<string> {
|
||||||
const outboxId = await this.scheduleEmail({
|
const emailId = await this.scheduleEmail({
|
||||||
...options,
|
...options,
|
||||||
scheduledAt: new Date()
|
scheduledAt: new Date()
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.processOutboxItem(outboxId)
|
await this.processEmailItem(emailId)
|
||||||
|
|
||||||
return outboxId
|
return emailId
|
||||||
}
|
}
|
||||||
|
|
||||||
async scheduleEmail(options: SendEmailOptions): Promise<string> {
|
async scheduleEmail(options: SendEmailOptions): Promise<string> {
|
||||||
let html = options.html || ''
|
let html = options.html || ''
|
||||||
let text = options.text || ''
|
let text = options.text || ''
|
||||||
let subject = options.subject || ''
|
let subject = options.subject || ''
|
||||||
|
let templateId: string | undefined = undefined
|
||||||
|
|
||||||
if (options.templateId) {
|
if (options.templateSlug) {
|
||||||
const template = await this.getTemplate(options.templateId)
|
const template = await this.getTemplateBySlug(options.templateSlug)
|
||||||
|
|
||||||
if (template) {
|
if (template) {
|
||||||
|
templateId = template.id
|
||||||
const variables = options.variables || {}
|
const variables = options.variables || {}
|
||||||
|
const renderedContent = await this.renderEmailTemplate(template, variables)
|
||||||
html = this.renderTemplate(template.htmlTemplate, variables)
|
html = renderedContent.html
|
||||||
text = template.textTemplate ? this.renderTemplate(template.textTemplate, variables) : ''
|
text = renderedContent.text
|
||||||
subject = this.renderTemplate(template.subject, variables)
|
subject = this.renderHandlebarsTemplate(template.subject, variables)
|
||||||
|
} else {
|
||||||
|
throw new Error(`Email template not found: ${options.templateSlug}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +120,11 @@ export class MailingService implements IMailingService {
|
|||||||
throw new Error('Email HTML content is required')
|
throw new Error('Email HTML content is required')
|
||||||
}
|
}
|
||||||
|
|
||||||
const outboxData = {
|
const queueData = {
|
||||||
template: options.templateId || undefined,
|
template: templateId,
|
||||||
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
|
to: Array.isArray(options.to) ? options.to : [options.to],
|
||||||
cc: options.cc ? (Array.isArray(options.cc) ? options.cc.join(', ') : options.cc) : undefined,
|
cc: options.cc ? (Array.isArray(options.cc) ? options.cc : [options.cc]) : undefined,
|
||||||
bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc.join(', ') : options.bcc) : undefined,
|
bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : undefined,
|
||||||
from: options.from || this.config.defaultFrom,
|
from: options.from || this.config.defaultFrom,
|
||||||
replyTo: options.replyTo,
|
replyTo: options.replyTo,
|
||||||
subject: subject || options.subject,
|
subject: subject || options.subject,
|
||||||
@@ -127,18 +138,18 @@ export class MailingService implements IMailingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.payload.create({
|
const result = await this.payload.create({
|
||||||
collection: this.outboxCollection,
|
collection: this.emailsCollection as any,
|
||||||
data: outboxData,
|
data: queueData,
|
||||||
})
|
})
|
||||||
|
|
||||||
return result.id as string
|
return result.id as string
|
||||||
}
|
}
|
||||||
|
|
||||||
async processOutbox(): Promise<void> {
|
async processEmails(): Promise<void> {
|
||||||
const currentTime = new Date().toISOString()
|
const currentTime = new Date().toISOString()
|
||||||
|
|
||||||
const { docs: pendingEmails } = await this.payload.find({
|
const { docs: pendingEmails } = await this.payload.find({
|
||||||
collection: this.outboxCollection,
|
collection: this.emailsCollection as any,
|
||||||
where: {
|
where: {
|
||||||
and: [
|
and: [
|
||||||
{
|
{
|
||||||
@@ -167,7 +178,7 @@ export class MailingService implements IMailingService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for (const email of pendingEmails) {
|
for (const email of pendingEmails) {
|
||||||
await this.processOutboxItem(email.id)
|
await this.processEmailItem(String(email.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +188,7 @@ export class MailingService implements IMailingService {
|
|||||||
const retryTime = new Date(Date.now() - retryDelay).toISOString()
|
const retryTime = new Date(Date.now() - retryDelay).toISOString()
|
||||||
|
|
||||||
const { docs: failedEmails } = await this.payload.find({
|
const { docs: failedEmails } = await this.payload.find({
|
||||||
collection: this.outboxCollection,
|
collection: this.emailsCollection as any,
|
||||||
where: {
|
where: {
|
||||||
and: [
|
and: [
|
||||||
{
|
{
|
||||||
@@ -210,15 +221,15 @@ export class MailingService implements IMailingService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for (const email of failedEmails) {
|
for (const email of failedEmails) {
|
||||||
await this.processOutboxItem(email.id)
|
await this.processEmailItem(String(email.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processOutboxItem(outboxId: string): Promise<void> {
|
private async processEmailItem(emailId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.payload.update({
|
await this.payload.update({
|
||||||
collection: this.outboxCollection,
|
collection: this.emailsCollection as any,
|
||||||
id: outboxId,
|
id: emailId,
|
||||||
data: {
|
data: {
|
||||||
status: 'processing',
|
status: 'processing',
|
||||||
lastAttemptAt: new Date().toISOString(),
|
lastAttemptAt: new Date().toISOString(),
|
||||||
@@ -226,11 +237,11 @@ export class MailingService implements IMailingService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const email = await this.payload.findByID({
|
const email = await this.payload.findByID({
|
||||||
collection: this.outboxCollection,
|
collection: this.emailsCollection as any,
|
||||||
id: outboxId,
|
id: emailId,
|
||||||
}) as OutboxEmail
|
}) as QueuedEmail
|
||||||
|
|
||||||
const mailOptions = {
|
let emailObject: EmailObject = {
|
||||||
from: email.from || this.config.defaultFrom,
|
from: email.from || this.config.defaultFrom,
|
||||||
to: email.to,
|
to: email.to,
|
||||||
cc: email.cc || undefined,
|
cc: email.cc || undefined,
|
||||||
@@ -239,13 +250,30 @@ export class MailingService implements IMailingService {
|
|||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
html: email.html,
|
html: email.html,
|
||||||
text: email.text || undefined,
|
text: email.text || undefined,
|
||||||
|
variables: email.variables,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply emailWrapper hook if configured
|
||||||
|
if (this.config.emailWrapper) {
|
||||||
|
emailObject = await this.config.emailWrapper(emailObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: emailObject.from || this.config.defaultFrom,
|
||||||
|
to: emailObject.to,
|
||||||
|
cc: emailObject.cc || undefined,
|
||||||
|
bcc: emailObject.bcc || undefined,
|
||||||
|
replyTo: emailObject.replyTo || undefined,
|
||||||
|
subject: emailObject.subject,
|
||||||
|
html: emailObject.html,
|
||||||
|
text: emailObject.text || undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.transporter.sendMail(mailOptions)
|
await this.transporter.sendMail(mailOptions)
|
||||||
|
|
||||||
await this.payload.update({
|
await this.payload.update({
|
||||||
collection: this.outboxCollection,
|
collection: this.emailsCollection as any,
|
||||||
id: outboxId,
|
id: emailId,
|
||||||
data: {
|
data: {
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
sentAt: new Date().toISOString(),
|
sentAt: new Date().toISOString(),
|
||||||
@@ -254,12 +282,12 @@ export class MailingService implements IMailingService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const attempts = await this.incrementAttempts(outboxId)
|
const attempts = await this.incrementAttempts(emailId)
|
||||||
const maxAttempts = this.config.retryAttempts || 3
|
const maxAttempts = this.config.retryAttempts || 3
|
||||||
|
|
||||||
await this.payload.update({
|
await this.payload.update({
|
||||||
collection: this.outboxCollection,
|
collection: this.emailsCollection as any,
|
||||||
id: outboxId,
|
id: emailId,
|
||||||
data: {
|
data: {
|
||||||
status: attempts >= maxAttempts ? 'failed' : 'pending',
|
status: attempts >= maxAttempts ? 'failed' : 'pending',
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
@@ -268,22 +296,22 @@ export class MailingService implements IMailingService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (attempts >= maxAttempts) {
|
if (attempts >= maxAttempts) {
|
||||||
console.error(`Email ${outboxId} failed permanently after ${attempts} attempts:`, error)
|
console.error(`Email ${emailId} failed permanently after ${attempts} attempts:`, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async incrementAttempts(outboxId: string): Promise<number> {
|
private async incrementAttempts(emailId: string): Promise<number> {
|
||||||
const email = await this.payload.findByID({
|
const email = await this.payload.findByID({
|
||||||
collection: this.outboxCollection,
|
collection: this.emailsCollection as any,
|
||||||
id: outboxId,
|
id: emailId,
|
||||||
}) as OutboxEmail
|
}) as QueuedEmail
|
||||||
|
|
||||||
const newAttempts = (email.attempts || 0) + 1
|
const newAttempts = (email.attempts || 0) + 1
|
||||||
|
|
||||||
await this.payload.update({
|
await this.payload.update({
|
||||||
collection: this.outboxCollection,
|
collection: this.emailsCollection as any,
|
||||||
id: outboxId,
|
id: emailId,
|
||||||
data: {
|
data: {
|
||||||
attempts: newAttempts,
|
attempts: newAttempts,
|
||||||
},
|
},
|
||||||
@@ -292,26 +320,49 @@ export class MailingService implements IMailingService {
|
|||||||
return newAttempts
|
return newAttempts
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getTemplate(templateId: string): Promise<EmailTemplate | null> {
|
private async getTemplateBySlug(templateSlug: string): Promise<EmailTemplate | null> {
|
||||||
try {
|
try {
|
||||||
const template = await this.payload.findByID({
|
const { docs } = await this.payload.find({
|
||||||
collection: this.templatesCollection,
|
collection: this.templatesCollection as any,
|
||||||
id: templateId,
|
where: {
|
||||||
|
slug: {
|
||||||
|
equals: templateSlug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
})
|
})
|
||||||
return template as EmailTemplate
|
|
||||||
|
return docs.length > 0 ? docs[0] as EmailTemplate : null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Template ${templateId} not found:`, error)
|
console.error(`Template with slug '${templateSlug}' not found:`, error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderTemplate(template: string, variables: Record<string, any>): string {
|
private renderHandlebarsTemplate(template: string, variables: Record<string, any>): string {
|
||||||
try {
|
try {
|
||||||
const compiled = Handlebars.compile(template)
|
const compiled = Handlebars.compile(template)
|
||||||
return compiled(variables)
|
return compiled(variables)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Template rendering error:', error)
|
console.error('Handlebars template rendering error:', error)
|
||||||
return template
|
return template
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async renderEmailTemplate(template: EmailTemplate, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
|
||||||
|
if (!template.content) {
|
||||||
|
return { html: '', text: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize richtext to HTML and text
|
||||||
|
let html = serializeRichTextToHTML(template.content)
|
||||||
|
let text = serializeRichTextToText(template.content)
|
||||||
|
|
||||||
|
// Apply Handlebars variables to the rendered content
|
||||||
|
html = this.renderHandlebarsTemplate(html, variables)
|
||||||
|
text = this.renderHandlebarsTemplate(text, variables)
|
||||||
|
|
||||||
|
return { html, text }
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,35 @@
|
|||||||
import { Payload } from 'payload'
|
import { Payload } from 'payload'
|
||||||
|
import type { CollectionConfig, RichTextField } from 'payload'
|
||||||
import { Transporter } from 'nodemailer'
|
import { Transporter } from 'nodemailer'
|
||||||
|
|
||||||
|
export interface EmailObject {
|
||||||
|
to: string | string[]
|
||||||
|
cc?: string | string[]
|
||||||
|
bcc?: string | string[]
|
||||||
|
from?: string
|
||||||
|
replyTo?: string
|
||||||
|
subject: string
|
||||||
|
html: string
|
||||||
|
text?: string
|
||||||
|
variables?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmailWrapperHook = (email: EmailObject) => EmailObject | Promise<EmailObject>
|
||||||
|
|
||||||
export interface MailingPluginConfig {
|
export interface MailingPluginConfig {
|
||||||
collections?: {
|
collections?: {
|
||||||
templates?: string
|
templates?: string | Partial<CollectionConfig>
|
||||||
outbox?: string
|
emails?: string | Partial<CollectionConfig>
|
||||||
}
|
}
|
||||||
defaultFrom?: string
|
defaultFrom?: string
|
||||||
transport?: Transporter | MailingTransportConfig
|
transport?: Transporter | MailingTransportConfig
|
||||||
queue?: string
|
queue?: string
|
||||||
retryAttempts?: number
|
retryAttempts?: number
|
||||||
retryDelay?: number
|
retryDelay?: number
|
||||||
|
emailWrapper?: EmailWrapperHook
|
||||||
|
richTextEditor?: RichTextField['editor']
|
||||||
|
onReady?: (payload: any) => Promise<void>
|
||||||
|
initOrder?: 'before' | 'after'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MailingTransportConfig {
|
export interface MailingTransportConfig {
|
||||||
@@ -26,27 +45,20 @@ export interface MailingTransportConfig {
|
|||||||
export interface EmailTemplate {
|
export interface EmailTemplate {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
slug: string
|
||||||
subject: string
|
subject: string
|
||||||
htmlTemplate: string
|
content: any // Lexical editor state
|
||||||
textTemplate?: string
|
|
||||||
variables?: TemplateVariable[]
|
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateVariable {
|
|
||||||
name: string
|
|
||||||
type: 'text' | 'number' | 'boolean' | 'date'
|
|
||||||
required: boolean
|
|
||||||
description?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OutboxEmail {
|
export interface QueuedEmail {
|
||||||
id: string
|
id: string
|
||||||
templateId?: string
|
template?: string
|
||||||
to: string | string[]
|
to: string[]
|
||||||
cc?: string | string[]
|
cc?: string[]
|
||||||
bcc?: string | string[]
|
bcc?: string[]
|
||||||
from?: string
|
from?: string
|
||||||
replyTo?: string
|
replyTo?: string
|
||||||
subject: string
|
subject: string
|
||||||
@@ -65,7 +77,7 @@ export interface OutboxEmail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SendEmailOptions {
|
export interface SendEmailOptions {
|
||||||
templateId?: string
|
templateSlug?: string
|
||||||
to: string | string[]
|
to: string | string[]
|
||||||
cc?: string | string[]
|
cc?: string | string[]
|
||||||
bcc?: string | string[]
|
bcc?: string | string[]
|
||||||
@@ -82,7 +94,7 @@ export interface SendEmailOptions {
|
|||||||
export interface MailingService {
|
export interface MailingService {
|
||||||
sendEmail(options: SendEmailOptions): Promise<string>
|
sendEmail(options: SendEmailOptions): Promise<string>
|
||||||
scheduleEmail(options: SendEmailOptions): Promise<string>
|
scheduleEmail(options: SendEmailOptions): Promise<string>
|
||||||
processOutbox(): Promise<void>
|
processEmails(): Promise<void>
|
||||||
retryFailedEmails(): Promise<void>
|
retryFailedEmails(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,4 +102,4 @@ export interface MailingContext {
|
|||||||
payload: Payload
|
payload: Payload
|
||||||
config: MailingPluginConfig
|
config: MailingPluginConfig
|
||||||
service: MailingService
|
service: MailingService
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Payload } from 'payload'
|
import { Payload } from 'payload'
|
||||||
import { SendEmailOptions } from '../types'
|
import { SendEmailOptions } from '../types/index.js'
|
||||||
|
|
||||||
export const getMailing = (payload: Payload) => {
|
export const getMailing = (payload: Payload) => {
|
||||||
const mailing = (payload as any).mailing
|
const mailing = (payload as any).mailing
|
||||||
@@ -19,9 +19,9 @@ export const scheduleEmail = async (payload: Payload, options: SendEmailOptions)
|
|||||||
return mailing.service.scheduleEmail(options)
|
return mailing.service.scheduleEmail(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const processOutbox = async (payload: Payload): Promise<void> => {
|
export const processEmails = async (payload: Payload): Promise<void> => {
|
||||||
const mailing = getMailing(payload)
|
const mailing = getMailing(payload)
|
||||||
return mailing.service.processOutbox()
|
return mailing.service.processEmails()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const retryFailedEmails = async (payload: Payload): Promise<void> => {
|
export const retryFailedEmails = async (payload: Payload): Promise<void> => {
|
||||||
|
|||||||
150
src/utils/richTextSerializer.ts
Normal file
150
src/utils/richTextSerializer.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// Using any type for now since Lexical types have import issues
|
||||||
|
// import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||||
|
type SerializedEditorState = any
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts Lexical richtext content to HTML
|
||||||
|
*/
|
||||||
|
export function serializeRichTextToHTML(richTextData: SerializedEditorState): string {
|
||||||
|
if (!richTextData?.root?.children) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializeNodesToHTML(richTextData.root.children)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts Lexical richtext content to plain text
|
||||||
|
*/
|
||||||
|
export function serializeRichTextToText(richTextData: SerializedEditorState): string {
|
||||||
|
if (!richTextData?.root?.children) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializeNodesToText(richTextData.root.children)
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeNodesToHTML(nodes: any[]): string {
|
||||||
|
return nodes.map(node => serializeNodeToHTML(node)).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeNodeToHTML(node: any): string {
|
||||||
|
if (!node) return ''
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case 'paragraph':
|
||||||
|
const children = node.children ? serializeNodesToHTML(node.children) : ''
|
||||||
|
return `<p>${children}</p>`
|
||||||
|
|
||||||
|
case 'heading':
|
||||||
|
const headingChildren = node.children ? serializeNodesToHTML(node.children) : ''
|
||||||
|
const tag = node.tag || 'h1'
|
||||||
|
return `<${tag}>${headingChildren}</${tag}>`
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
let text = node.text || ''
|
||||||
|
|
||||||
|
// Apply text formatting
|
||||||
|
if (node.format) {
|
||||||
|
if (node.format & 1) text = `<strong>${text}</strong>` // Bold
|
||||||
|
if (node.format & 2) text = `<em>${text}</em>` // Italic
|
||||||
|
if (node.format & 4) text = `<s>${text}</s>` // Strikethrough
|
||||||
|
if (node.format & 8) text = `<u>${text}</u>` // Underline
|
||||||
|
if (node.format & 16) text = `<code>${text}</code>` // Code
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
case 'linebreak':
|
||||||
|
return '<br>'
|
||||||
|
|
||||||
|
case 'list':
|
||||||
|
const listChildren = node.children ? serializeNodesToHTML(node.children) : ''
|
||||||
|
const listTag = node.listType === 'number' ? 'ol' : 'ul'
|
||||||
|
return `<${listTag}>${listChildren}</${listTag}>`
|
||||||
|
|
||||||
|
case 'listitem':
|
||||||
|
const listItemChildren = node.children ? serializeNodesToHTML(node.children) : ''
|
||||||
|
return `<li>${listItemChildren}</li>`
|
||||||
|
|
||||||
|
case 'quote':
|
||||||
|
const quoteChildren = node.children ? serializeNodesToHTML(node.children) : ''
|
||||||
|
return `<blockquote>${quoteChildren}</blockquote>`
|
||||||
|
|
||||||
|
case 'link':
|
||||||
|
const linkChildren = node.children ? serializeNodesToHTML(node.children) : ''
|
||||||
|
const url = node.url || '#'
|
||||||
|
const target = node.newTab ? ' target="_blank" rel="noopener noreferrer"' : ''
|
||||||
|
return `<a href="${url}"${target}>${linkChildren}</a>`
|
||||||
|
|
||||||
|
case 'horizontalrule':
|
||||||
|
return '<hr>'
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Handle unknown nodes by processing children
|
||||||
|
if (node.children) {
|
||||||
|
return serializeNodesToHTML(node.children)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeNodesToText(nodes: any[]): string {
|
||||||
|
return nodes.map(node => serializeNodeToText(node)).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeNodeToText(node: any): string {
|
||||||
|
if (!node) return ''
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case 'paragraph':
|
||||||
|
const children = node.children ? serializeNodesToText(node.children) : ''
|
||||||
|
return `${children}\n\n`
|
||||||
|
|
||||||
|
case 'heading':
|
||||||
|
const headingChildren = node.children ? serializeNodesToText(node.children) : ''
|
||||||
|
return `${headingChildren}\n\n`
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
return node.text || ''
|
||||||
|
|
||||||
|
case 'linebreak':
|
||||||
|
return '\n'
|
||||||
|
|
||||||
|
case 'list':
|
||||||
|
const listChildren = node.children ? serializeNodesToText(node.children) : ''
|
||||||
|
return `${listChildren}\n`
|
||||||
|
|
||||||
|
case 'listitem':
|
||||||
|
const listItemChildren = node.children ? serializeNodesToText(node.children) : ''
|
||||||
|
return `• ${listItemChildren}\n`
|
||||||
|
|
||||||
|
case 'quote':
|
||||||
|
const quoteChildren = node.children ? serializeNodesToText(node.children) : ''
|
||||||
|
return `> ${quoteChildren}\n\n`
|
||||||
|
|
||||||
|
case 'link':
|
||||||
|
const linkChildren = node.children ? serializeNodesToText(node.children) : ''
|
||||||
|
const url = node.url || ''
|
||||||
|
return `${linkChildren} (${url})`
|
||||||
|
|
||||||
|
case 'horizontalrule':
|
||||||
|
return '---\n\n'
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Handle unknown nodes by processing children
|
||||||
|
if (node.children) {
|
||||||
|
return serializeNodesToText(node.children)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies Handlebars variables to richtext-generated HTML/text
|
||||||
|
*/
|
||||||
|
export function applyVariablesToContent(content: string, variables: Record<string, any>): string {
|
||||||
|
// This function can be extended to handle more complex variable substitution
|
||||||
|
// For now, it works with the Handlebars rendering that happens later
|
||||||
|
return content
|
||||||
|
}
|
||||||
56
test-slug-system.js
Normal file
56
test-slug-system.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Test script to verify slug-based template system
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import configPromise from './dev/payload.config.js'
|
||||||
|
import { sendEmail } from './dist/utils/helpers.js'
|
||||||
|
|
||||||
|
async function testSlugSystem() {
|
||||||
|
console.log('🔄 Testing slug-based template system...\n')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await configPromise
|
||||||
|
const payload = await getPayload({ config })
|
||||||
|
|
||||||
|
console.log('📝 Sending email using template slug "welcome-email"')
|
||||||
|
|
||||||
|
const emailId = await sendEmail(payload, {
|
||||||
|
templateSlug: 'welcome-email',
|
||||||
|
to: 'test-slug@example.com',
|
||||||
|
variables: {
|
||||||
|
firstName: 'SlugTest',
|
||||||
|
siteName: 'Slug Demo Site',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
isPremium: true,
|
||||||
|
dashboardUrl: 'http://localhost:3000/admin',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('✅ Email queued successfully with ID:', emailId)
|
||||||
|
|
||||||
|
// Check if email was queued with templateSlug
|
||||||
|
const email = await payload.findByID({
|
||||||
|
collection: 'emails',
|
||||||
|
id: emailId,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('\n📧 Email details:')
|
||||||
|
console.log('- ID:', email.id)
|
||||||
|
console.log('- To:', email.to)
|
||||||
|
console.log('- Subject:', email.subject)
|
||||||
|
console.log('- Template Slug:', email.templateSlug)
|
||||||
|
console.log('- Status:', email.status)
|
||||||
|
console.log('- Subject contains personalized data:', email.subject.includes('SlugTest'))
|
||||||
|
|
||||||
|
if (email.templateSlug === 'welcome-email') {
|
||||||
|
console.log('\n✅ Slug-based template system working correctly!')
|
||||||
|
} else {
|
||||||
|
console.log('\n❌ Template slug not stored correctly')
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error:', error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testSlugSystem()
|
||||||
30
tsconfig.build.json
Normal file
30
tsconfig.build.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": false,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"dev",
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": false,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user