UNPKG

@xtr-dev/payload-mailing

Version:

Template-based email system with scheduling and job processing for PayloadCMS

235 lines (234 loc) 7.31 kB
import { ensureEmailJob, updateEmailJobRelationship } from '../utils/jobScheduler.js'; import { createContextLogger } from '../utils/logger.js'; const Emails = { slug: 'emails', admin: { useAsTitle: 'subject', defaultColumns: ['subject', 'to', 'status', 'jobs', 'scheduledAt', 'sentAt'], group: 'Mailing', description: 'Email delivery and status tracking', }, fields: [ { name: 'template', type: 'relationship', relationTo: 'email-templates', admin: { description: 'Email template used (optional if custom content provided)', }, }, { name: 'to', type: 'text', required: true, hasMany: true, admin: { description: 'Recipient email addresses', }, }, { name: 'cc', type: 'text', hasMany: true, admin: { description: 'CC email addresses', }, }, { name: 'bcc', type: 'text', hasMany: true, admin: { description: 'BCC email addresses', }, }, { name: 'from', type: 'text', admin: { description: 'Sender email address (optional, uses default if not provided)', }, }, { name: 'fromName', type: 'text', admin: { description: 'Sender display name (optional, e.g., "John Doe" for "John Doe <john@example.com>")', }, }, { name: 'replyTo', type: 'text', admin: { description: 'Reply-to email address', }, }, { name: 'subject', type: 'text', required: true, admin: { description: 'Email subject line', }, }, { name: 'html', type: 'textarea', required: true, admin: { description: 'Rendered HTML content of the email', rows: 8, }, }, { name: 'text', type: 'textarea', admin: { description: 'Plain text version of the email', rows: 6, }, }, { name: 'variables', type: 'json', admin: { description: 'Template variables used to render this email', }, }, { name: 'scheduledAt', type: 'date', admin: { description: 'When this email should be sent (leave empty for immediate)', date: { pickerAppearance: 'dayAndTime', }, }, }, { name: 'sentAt', type: 'date', admin: { description: 'When this email was actually sent', date: { pickerAppearance: 'dayAndTime', }, }, }, { name: 'status', type: 'select', required: true, options: [ { label: 'Pending', value: 'pending' }, { label: 'Processing', value: 'processing' }, { label: 'Sent', value: 'sent' }, { label: 'Failed', value: 'failed' }, ], defaultValue: 'pending', admin: { description: 'Current status of this email', }, }, { name: 'attempts', type: 'number', defaultValue: 0, admin: { description: 'Number of send attempts made', }, }, { name: 'lastAttemptAt', type: 'date', admin: { description: 'When the last send attempt was made', date: { pickerAppearance: 'dayAndTime', }, }, }, { name: 'error', type: 'textarea', admin: { description: 'Last error message if send failed', rows: 3, }, }, { name: 'priority', type: 'number', defaultValue: 5, admin: { description: 'Email priority (1=highest, 10=lowest)', }, }, { name: 'jobs', type: 'relationship', relationTo: 'payload-jobs', hasMany: true, admin: { description: 'Processing jobs associated with this email', allowCreate: false, readOnly: true, }, filterOptions: ({ id }) => { return { 'input.emailId': { equals: id, }, }; }, }, ], hooks: { // Simple approach: Only use afterChange hook for job management // This avoids complex interaction between hooks and ensures document ID is always available afterChange: [ async ({ doc, previousDoc, req, operation }) => { // Skip if: // 1. Email is not pending status // 2. Jobs are not configured // 3. Email already has jobs (unless status just changed to pending) const shouldSkip = doc.status !== 'pending' || !req.payload.jobs || (doc.jobs?.length > 0 && previousDoc?.status === 'pending'); if (shouldSkip) { return; } try { // Ensure a job exists for this email // This function handles: // - Checking for existing jobs (duplicate prevention) // - Creating new job if needed // - Returning all job IDs const result = await ensureEmailJob(req.payload, doc.id, { scheduledAt: doc.scheduledAt, }); // Update the email's job relationship if we have jobs // This handles both new jobs and existing jobs that weren't in the relationship if (result.jobIds.length > 0) { await updateEmailJobRelationship(req.payload, doc.id, result.jobIds, 'emails'); } } catch (error) { // Log error but don't throw - we don't want to fail the email operation const logger = createContextLogger(req.payload, 'EMAILS_HOOK'); logger.error(`Failed to ensure job for email ${doc.id}:`, error); } } ] }, timestamps: true, indexes: [ { fields: ['status', 'scheduledAt'], }, { fields: ['priority', 'createdAt'], }, ], }; export default Emails;