- Reuse duplicate prevention logic in rescheduling to prevent race conditions - Add queueName validation in plugin initialization and helper function - Enhanced scheduleEmailProcessingJob to return boolean and accept delay parameter - Improve error handling: rescheduling failures warn but don't fail current job - Prevent duplicate jobs when multiple handlers complete simultaneously 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
@xtr-dev/payload-mailing
📧 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
✅ Template System: Create reusable email templates with Handlebars syntax
✅ Outbox Scheduling: Schedule emails for future delivery
✅ Job Integration: Automatic processing via PayloadCMS jobs queue
✅ Retry Failed Sends: Automatic retry mechanism for failed emails
✅ Template Variables: Dynamic content with validation
✅ Developer API: Simple methods for sending emails programmatically
Installation
npm install @xtr-dev/payload-mailing
Quick Start
1. Add the plugin to your Payload config
import { buildConfig } from 'payload/config'
import { mailingPlugin } from '@xtr-dev/payload-mailing'
export default buildConfig({
// ... your config
plugins: [
mailingPlugin({
defaultFrom: 'noreply@yoursite.com',
transport: {
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
},
retryAttempts: 3,
retryDelay: 300000, // 5 minutes
queue: 'email-queue', // optional
}),
],
})
2. Send emails in your code
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
// Send immediately using template slug
const emailId = await sendEmail(payload, {
templateSlug: 'welcome-email',
to: 'user@example.com',
variables: {
firstName: 'John',
welcomeUrl: 'https://yoursite.com/welcome'
}
})
// Schedule for later
const scheduledId = await scheduleEmail(payload, {
templateSlug: 'reminder-email',
to: 'user@example.com',
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
variables: {
eventName: 'Product Launch',
eventDate: new Date('2024-01-15')
}
})
Configuration
Plugin Options
interface MailingPluginConfig {
collections?: {
templates?: string // default: 'email-templates'
emails?: string // default: 'emails'
}
defaultFrom?: string
transport?: Transporter | MailingTransportConfig
queue?: string // default: 'default'
retryAttempts?: number // default: 3
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'
}
Transport Configuration
You can provide either a Nodemailer transporter instance or configuration:
// Using configuration object
{
transport: {
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
}
}
// Or using a transporter instance
import nodemailer from 'nodemailer'
{
transport: nodemailer.createTransporter({
// your config
})
}
Creating Email Templates
- Go to your Payload admin panel
- Navigate to Mailing > Email Templates
- Create a new template with:
- Name: Descriptive name for the template
- Slug: Unique identifier for the template (auto-generated)
- Subject: Email subject (supports Handlebars)
- Content: Rich text editor with Handlebars syntax (automatically generates HTML and text versions)
Template Example
Subject: Welcome to {{siteName}}, {{firstName}}!
Content (using rich text editor with Handlebars):
# Welcome {{firstName}}! 🎉
Thanks for joining {{siteName}}. We're excited to have you!
**What you can do:**
• Create beautiful emails with rich text formatting
• Use the emailWrapper hook to add custom layouts
• Queue and schedule emails effortlessly
Your account was created on {{formatDate createdAt "long"}}.
Best regards,
The {{siteName}} Team
Advanced Features
Custom HTML Layouts with Email Wrapper Hook
The emailWrapper hook allows you to apply consistent HTML layouts and styling to all emails sent through the plugin. This is perfect for adding company branding, headers, footers, and responsive styling.
Basic Email Wrapper
mailingPlugin({
// ... other config
emailWrapper: (email) => {
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: #f4f4f4; }
.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; line-height: 1.6; }
.footer { background: #f8f9fa; padding: 15px; text-align: center; color: #6c757d; font-size: 14px; }
/* Responsive styles */
@media only screen and (max-width: 600px) {
.container { margin: 0 10px; }
.content { padding: 20px; }
}
</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.<br>
<a href="#" style="color: #007bff;">Unsubscribe</a> |
<a href="#" style="color: #007bff;">Contact Support</a>
</div>
</div>
</body>
</html>
`
return {
...email,
html: wrappedHtml,
text: `MY COMPANY\n\n${email.text}\n\n© 2024 My Company\nUnsubscribe: [link] | Contact Support: [link]`
}
}
})
Advanced Email Wrapper with Dynamic Content
mailingPlugin({
// ... other config
emailWrapper: (email) => {
// You can access email properties and customize based on content
const isTransactional = email.subject?.includes('Receipt') || email.subject?.includes('Confirmation');
const headerColor = isTransactional ? '#28a745' : '#007bff';
const headerText = isTransactional ? 'Order Confirmation' : 'My Company';
const wrappedHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>${email.subject}</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style>
/* Reset styles */
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
/* Base styles */
body {
margin: 0 !important;
padding: 0 !important;
background-color: #f4f4f4;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.email-header {
background: linear-gradient(135deg, ${headerColor}, ${headerColor}dd);
color: white;
padding: 30px 20px;
text-align: center;
}
.email-content {
padding: 30px;
color: #333333;
line-height: 1.6;
}
.email-footer {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
color: #6c757d;
font-size: 14px;
border-top: 1px solid #e9ecef;
}
.email-footer a {
color: ${headerColor};
text-decoration: none;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0 10px !important;
}
.email-header, .email-content {
padding: 20px !important;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h1 style="margin: 0; font-size: 24px;">${headerText}</h1>
</div>
<div class="email-content">
${email.html}
</div>
<div class="email-footer">
<p style="margin: 0 0 10px;">© ${new Date().getFullYear()} My Company. All rights reserved.</p>
<p style="margin: 0;">
<a href="mailto:support@mycompany.com">Contact Support</a> |
<a href="#">Privacy Policy</a> |
<a href="#">Unsubscribe</a>
</p>
</div>
</div>
</body>
</html>
`
// Also enhance the plain text version
const wrappedText = `
${headerText.toUpperCase()}
${'='.repeat(headerText.length)}
${email.text || email.html?.replace(/<[^>]*>/g, '')}
---
© ${new Date().getFullYear()} My Company. All rights reserved.
Contact Support: support@mycompany.com
Privacy Policy: [link]
Unsubscribe: [link]
`.trim();
return {
...email,
html: wrappedHtml,
text: wrappedText
}
}
})
External CSS and Assets
You can also reference external stylesheets and assets:
mailingPlugin({
emailWrapper: (email) => {
const wrappedHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${email.subject}</title>
<!-- External CSS -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
/* Your custom styles here */
</style>
</head>
<body>
<div style="max-width: 600px; margin: 0 auto;">
<img src="https://mycompany.com/email-header.png" alt="My Company" style="width: 100%; height: auto;">
<div style="padding: 20px;">
${email.html}
</div>
<img src="https://mycompany.com/email-footer.png" alt="Footer" style="width: 100%; height: auto;">
</div>
</body>
</html>
`;
return { ...email, html: wrappedHtml };
}
})
Template-Specific Layouts
You can customize layouts based on email templates:
mailingPlugin({
emailWrapper: (email, context) => {
// Access template information if available
const templateSlug = context?.templateSlug;
let layoutClass = 'default-layout';
let headerColor = '#007bff';
if (templateSlug === 'welcome-email') {
layoutClass = 'welcome-layout';
headerColor = '#28a745';
} else if (templateSlug === 'invoice-email') {
layoutClass = 'invoice-layout';
headerColor = '#dc3545';
}
const wrappedHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${email.subject}</title>
<style>
.${layoutClass} { /* template-specific styles */ }
.header { background-color: ${headerColor}; }
</style>
</head>
<body>
<div class="${layoutClass}">
${email.html}
</div>
</body>
</html>
`;
return { ...email, html: wrappedHtml };
}
})
The emailWrapper hook receives the email object with html, text, and subject properties, and should return the modified email object with your custom layout applied.
Custom Rich Text Editor
Override the rich text editor used for templates:
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:
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
The plugin includes several built-in helpers:
{{formatDate date 'short'}}- Format dates (short, long, or default){{formatCurrency amount 'USD'}}- Format currency{{capitalize string}}- Capitalize first letter{{#ifEquals value1 value2}}...{{/ifEquals}}- Conditional equality
API Methods
sendEmail(payload, options)
Send an email immediately:
const emailId = await sendEmail(payload, {
templateSlug: 'order-confirmation', // optional - use template slug
to: ['customer@example.com'], // string or array of emails
cc: ['manager@example.com'], // optional - array of emails
bcc: ['archive@example.com'], // optional - array of emails
from: 'orders@yoursite.com', // optional, uses default
replyTo: 'support@yoursite.com', // optional
subject: 'Custom subject', // required if no template
html: '<h1>Custom HTML</h1>', // required if no template
text: 'Custom text version', // optional
variables: { // template variables
orderNumber: '12345',
customerName: 'John Doe'
},
priority: 1 // optional, 1-10 (1 = highest)
})
scheduleEmail(payload, options)
Schedule an email for later delivery:
const emailId = await scheduleEmail(payload, {
templateSlug: 'newsletter',
to: ['user1@example.com', 'user2@example.com'],
scheduledAt: new Date('2024-01-15T10:00:00Z'),
variables: {
month: 'January',
highlights: ['Feature A', 'Feature B']
}
})
processEmails(payload)
Manually process pending emails:
import { processEmails } from '@xtr-dev/payload-mailing'
await processEmails(payload)
retryFailedEmails(payload)
Manually retry failed emails:
import { retryFailedEmails } from '@xtr-dev/payload-mailing'
await retryFailedEmails(payload)
Job Processing
The plugin automatically adds a unified email processing job to PayloadCMS:
- Job Name:
process-email-queue - Function: Processes both pending emails and retries failed emails
- Trigger: Manual via admin panel or API call
The job is automatically registered when the plugin initializes. To trigger it manually:
// Queue the job for processing
await payload.jobs.queue({
task: 'process-email-queue',
input: {}
})
Email Status Tracking
All emails are stored in the emails collection with these statuses:
pending- Waiting to be sentprocessing- Currently being sentsent- Successfully deliveredfailed- Failed to send (will retry if attempts < retryAttempts)
Monitoring
Check the Mailing > Emails collection in your admin panel to:
- View email delivery status
- See error messages for failed sends
- Track retry attempts
- Monitor scheduled emails
Environment Variables
# Email configuration
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USER=your-email@gmail.com
EMAIL_PASS=your-app-password
EMAIL_FROM=noreply@yoursite.com
Security and Access Control
Collection Access Restrictions
By default, both email templates and emails collections allow full access (read/create/update/delete: () => true). For production use, you should configure proper access restrictions using collection overrides:
mailingPlugin({
// ... other config
collections: {
templates: {
access: {
read: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:read')
},
create: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:create')
},
update: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:update')
},
delete: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
}
},
emails: {
access: {
read: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:read')
},
create: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:create')
},
update: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:update')
},
delete: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
}
}
}
})
Collection Overrides
You can override any collection configuration using the collections.templates or collections.emails options. This includes:
- Access controls - Restrict who can read/create/update/delete
- Admin UI settings - Customize admin interface appearance
- Field modifications - Add custom fields or modify existing ones
- Hooks - Add custom validation or processing logic
Example with additional custom fields:
mailingPlugin({
// ... other config
collections: {
templates: {
admin: {
group: 'Custom Marketing',
description: 'Custom email templates with enhanced features'
},
fields: [
// Plugin's default fields are preserved
{
name: 'category',
type: 'select',
options: [
{ label: 'Marketing', value: 'marketing' },
{ label: 'Transactional', value: 'transactional' },
{ label: 'System', value: 'system' }
],
admin: {
position: 'sidebar'
}
},
{
name: 'tags',
type: 'text',
hasMany: true,
admin: {
description: 'Tags for organizing templates'
}
}
],
hooks: {
beforeChange: [
({ data, req }) => {
// Custom validation logic
if (data.category === 'system' && req.user?.role !== 'admin') {
throw new Error('Only admins can create system templates')
}
return data
}
]
}
}
}
})
TypeScript Support
The plugin includes full TypeScript definitions. Import types as needed:
import {
MailingPluginConfig,
SendEmailOptions,
EmailTemplate,
QueuedEmail,
EmailObject,
EmailWrapperHook
} from '@xtr-dev/payload-mailing'
Recent Changes
v0.0.x (Latest)
🔄 Breaking Changes:
- Removed email layouts system in favor of
emailWrapperhook for better flexibility - Email fields (
to,cc,bcc) now usehasMany: truefor 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-queuejob handles both pending and failed emails
✨ New Features:
- Rich text editor with automatic HTML/text conversion
- Template slugs for easier template reference
emailWrapperhook 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
MIT
Contributing
Issues and pull requests welcome at GitHub repository