Bas van den Aakster 74f565ab4e 🚀 BREAKING: Simplify API to use Payload collections directly
- Remove complex sendEmail/scheduleEmail methods and SendEmailOptions types
- Add simple renderTemplate() helper for template rendering
- Users now create emails using payload.create() with full type safety
- Leverage Payload's existing collection system instead of duplicating functionality
- Provide comprehensive migration guide and usage examples

BREAKING CHANGES:
- sendEmail() and scheduleEmail() methods removed
- SendEmailOptions type removed
- Use payload.create() with email collection instead
- Use renderTemplate() helper for template rendering

Benefits:
 Full TypeScript support with generated Payload types
 Use any custom fields in your email collection
 Leverage Payload's validation, hooks, and access control
 Simpler, more consistent API
 Less code to maintain

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:23:05 +02:00
2025-09-13 15:04:38 +02:00
2025-09-13 14:57:25 +02:00

@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

  1. Go to your Payload admin panel
  2. Navigate to Mailing > Email Templates
  3. Create a new template with:
    • Name: Descriptive name for the template
    • 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 sent
  • processing - Currently being sent
  • sent - Successfully delivered
  • failed - 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 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

MIT

Contributing

Issues and pull requests welcome at GitHub repository

Description
No description provided
Readme 599 KiB
Languages
TypeScript 96.8%
JavaScript 3.2%