From a12d4c1bee20a70afee70626381ba61ed26f8308 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sun, 14 Sep 2025 17:53:29 +0200 Subject: [PATCH 1/2] BREAKING CHANGE: Remove sendEmailWorkflow, add immediate processing to sendEmailTask - Remove entire workflows directory and sendEmailWorkflow - Factor out email processing logic into reusable utilities (emailProcessor.ts) - Add processImmediately option to sendEmailTask input schema - Update sendEmailTask to process emails immediately when requested - Update processEmailsTask to use shared processing utilities - Remove workflow-related exports and plugin configuration - Simplify documentation to focus on unified task approach - Export new email processing utilities (processEmailById, processAllEmails) - Bump version to 0.4.0 (breaking change - workflows removed) Migration: Use sendEmailTask with processImmediately: true instead of sendEmailWorkflow --- README.md | 36 +--- package.json | 2 +- src/index.ts | 9 +- src/jobs/processEmailsTask.ts | 18 +- src/jobs/sendEmailTask.ts | 49 +++-- src/plugin.ts | 5 - src/utils/emailProcessor.ts | 39 ++++ src/workflows/index.ts | 11 -- src/workflows/sendEmailWorkflow.ts | 291 ----------------------------- 9 files changed, 100 insertions(+), 360 deletions(-) create mode 100644 src/utils/emailProcessor.ts delete mode 100644 src/workflows/index.ts delete mode 100644 src/workflows/sendEmailWorkflow.ts diff --git a/README.md b/README.md index cc42ed4..ca6aa75 100644 --- a/README.md +++ b/README.md @@ -382,16 +382,7 @@ await retryFailedEmails(payload) ## PayloadCMS Integration -The plugin provides both tasks and workflows for email processing: - -### Tasks vs Workflows - -- **Tasks**: Simple job execution, good for background processing -- **Workflows**: More advanced with UI, status tracking, and immediate processing options - -### Task Integration - -The plugin provides a ready-to-use PayloadCMS task for queuing template emails: +The plugin provides PayloadCMS tasks for email processing: ### 1. Add the task to your Payload config @@ -464,25 +455,13 @@ The task can also be triggered from the Payload admin panel with a user-friendly - ✅ **Error Handling**: Comprehensive error reporting - ✅ **Queue Management**: Leverage Payload's job queue system -### Workflow Integration +### Immediate Processing -For advanced features, use the workflow instead: +The send email task now supports immediate processing. Enable the `processImmediately` option to send emails instantly: ```typescript -import { sendEmailWorkflow } from '@xtr-dev/payload-mailing' - -export default buildConfig({ - jobs: { - workflows: [sendEmailWorkflow] - } -}) -``` - -**Key advantage**: Optional `processImmediately` option to send emails instantly instead of queuing. - -```typescript -await payload.workflows.queue({ - workflow: 'send-email', +await payload.jobs.queue({ + task: 'send-email', input: { processImmediately: true, // Send immediately (default: false) templateSlug: 'welcome-email', @@ -492,6 +471,11 @@ await payload.workflows.queue({ }) ``` +**Benefits**: +- No separate workflow needed +- Unified task interface +- Optional immediate processing when needed + ## Job Processing The plugin automatically adds a unified email processing job to PayloadCMS: diff --git a/package.json b/package.json index 647817a..1448d52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-mailing", - "version": "0.3.1", + "version": "0.4.0", "description": "Template-based email system with scheduling and job processing for PayloadCMS", "type": "module", "main": "dist/index.js", diff --git a/src/index.ts b/src/index.ts index 15d5d5c..2ae4610 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,10 +15,6 @@ export { default as Emails } from './collections/Emails.js' export { mailingJobs, sendEmailJob } from './jobs/index.js' export type { SendEmailTaskInput } from './jobs/sendEmailTask.js' -// Workflows (includes the send email workflow) -export { mailingWorkflows, sendEmailWorkflow } from './workflows/index.js' -export type { SendEmailWorkflowInput } from './workflows/sendEmailWorkflow.js' - // Main email sending function export { sendEmail, type SendEmailOptions } from './sendEmail.js' export { default as sendEmailDefault } from './sendEmail.js' @@ -30,4 +26,7 @@ export { processEmails, retryFailedEmails, parseAndValidateEmails, -} from './utils/helpers.js' \ No newline at end of file +} from './utils/helpers.js' + +// Email processing utilities +export { processEmailById, processAllEmails } from './utils/emailProcessor.js' \ No newline at end of file diff --git a/src/jobs/processEmailsTask.ts b/src/jobs/processEmailsTask.ts index 14de41f..1168c32 100644 --- a/src/jobs/processEmailsTask.ts +++ b/src/jobs/processEmailsTask.ts @@ -1,5 +1,5 @@ import type { PayloadRequest, Payload } from 'payload' -import type { MailingService } from '../services/MailingService.js' +import { processAllEmails } from '../utils/emailProcessor.js' /** * Data passed to the process emails task @@ -14,18 +14,16 @@ export interface ProcessEmailsTaskData { */ export const processEmailsTaskHandler = async ( job: { data: ProcessEmailsTaskData }, - context: { req: PayloadRequest; mailingService: MailingService } + context: { req: PayloadRequest } ) => { - const { mailingService } = context + const { req } = context + const payload = (req as any).payload try { console.log('🔄 Processing email queue (pending + failed emails)...') - // Process pending emails first - await mailingService.processEmails() - - // Then retry failed emails - await mailingService.retryFailedEmails() + // Use the shared email processing logic + await processAllEmails(payload) console.log('✅ Email queue processing completed successfully') } catch (error) { @@ -49,10 +47,10 @@ export const processEmailsTask = { throw new Error('Mailing plugin not properly initialized') } - // Use the existing mailing service from context + // Use the task handler await processEmailsTaskHandler( job as { data: ProcessEmailsTaskData }, - { req, mailingService: mailingContext.service } + { req } ) return { diff --git a/src/jobs/sendEmailTask.ts b/src/jobs/sendEmailTask.ts index 9d39151..170bbca 100644 --- a/src/jobs/sendEmailTask.ts +++ b/src/jobs/sendEmailTask.ts @@ -1,5 +1,6 @@ import { sendEmail } from '../sendEmail.js' import { BaseEmailDocument } from '../types/index.js' +import { processEmailById } from '../utils/emailProcessor.js' export interface SendEmailTaskInput { // Template mode fields @@ -20,6 +21,7 @@ export interface SendEmailTaskInput { replyTo?: string scheduledAt?: string | Date // ISO date string or Date object priority?: number + processImmediately?: boolean // If true, process the email immediately instead of waiting for the queue // Allow any additional fields that users might have in their email collection [key: string]: any @@ -44,8 +46,8 @@ function transformTaskInputToSendEmailOptions(taskInput: SendEmailTaskInput) { // Standard email fields that should be copied to data const standardFields = ['to', 'cc', 'bcc', 'from', 'fromName', 'replyTo', 'subject', 'html', 'text', 'scheduledAt', 'priority'] - // Template-specific fields that should not be copied to data - const templateFields = ['templateSlug', 'variables'] + // Fields that should not be copied to data + const excludedFields = ['templateSlug', 'variables', 'processImmediately'] // Copy standard fields to data standardFields.forEach(field => { @@ -54,9 +56,9 @@ function transformTaskInputToSendEmailOptions(taskInput: SendEmailTaskInput) { } }) - // Copy any additional custom fields that aren't template or standard fields + // Copy any additional custom fields that aren't excluded or standard fields Object.keys(taskInput).forEach(key => { - if (!templateFields.includes(key) && !standardFields.includes(key)) { + if (!excludedFields.includes(key) && !standardFields.includes(key)) { sendEmailOptions.data[key] = taskInput[key] } }) @@ -72,6 +74,15 @@ export const sendEmailJob = { slug: 'send-email', label: 'Send Email', inputSchema: [ + { + name: 'processImmediately', + type: 'checkbox' as const, + label: 'Process Immediately', + defaultValue: false, + admin: { + description: 'Process and send the email immediately instead of waiting for the queue processor' + } + }, { name: 'templateSlug', type: 'text' as const, @@ -171,7 +182,8 @@ export const sendEmailJob = { type: 'date' as const, label: 'Schedule For', admin: { - description: 'Optional date/time to schedule email for future delivery' + description: 'Optional date/time to schedule email for future delivery', + condition: (data: any) => !data.processImmediately } }, { @@ -195,6 +207,7 @@ export const sendEmailJob = { handler: async ({ input, payload }: any) => { // Cast input to our expected type const taskInput = input as SendEmailTaskInput + const shouldProcessImmediately = taskInput.processImmediately || false try { // Transform task input into sendEmail options using helper function @@ -203,22 +216,36 @@ export const sendEmailJob = { // Use the sendEmail helper to create the email const email = await sendEmail(payload, sendEmailOptions) + // If processImmediately is true, process the email now + if (shouldProcessImmediately) { + console.log(`⚡ Processing email ${email.id} immediately...`) + await processEmailById(payload, String(email.id)) + console.log(`✅ Email ${email.id} processed and sent immediately`) + + return { + output: { + success: true, + id: email.id, + status: 'sent', + processedImmediately: true + } + } + } + return { output: { success: true, id: email.id, + status: 'queued', + processedImmediately: false } } } catch (error) { if (error instanceof Error) { - // Preserve original error and stack trace - const wrappedError = new Error(`Failed to queue email: ${error.message}`) - wrappedError.stack = error.stack - wrappedError.cause = error - throw wrappedError + throw new Error(`Failed to process email: ${error.message}`, { cause: error }) } else { - throw new Error(`Failed to queue email: ${String(error)}`) + throw new Error(`Failed to process email: ${String(error)}`) } } } diff --git a/src/plugin.ts b/src/plugin.ts index efb056a..8cd05c1 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -4,7 +4,6 @@ import { MailingService } from './services/MailingService.js' import { createEmailTemplatesCollection } from './collections/EmailTemplates.js' import Emails from './collections/Emails.js' import { mailingJobs, scheduleEmailsJob } from './jobs/index.js' -import { mailingWorkflows } from './workflows/index.js' export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => { @@ -87,10 +86,6 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con ...(config.jobs?.tasks || []), ...mailingJobs, ], - workflows: [ - ...(config.jobs?.workflows || []), - ...mailingWorkflows, - ], }, onInit: async (payload: any) => { if (pluginConfig.initOrder === 'after' && config.onInit) { diff --git a/src/utils/emailProcessor.ts b/src/utils/emailProcessor.ts new file mode 100644 index 0000000..b69c1da --- /dev/null +++ b/src/utils/emailProcessor.ts @@ -0,0 +1,39 @@ +import type { Payload } from 'payload' + +/** + * Processes a single email by ID using the mailing service + * @param payload Payload instance + * @param emailId The ID of the email to process + * @returns Promise that resolves when email is processed + */ +export async function processEmailById(payload: Payload, emailId: string): Promise { + // Get mailing context from payload + const mailingContext = (payload as any).mailing + + if (!mailingContext || !mailingContext.service) { + throw new Error('Mailing plugin not properly initialized') + } + + // Process the specific email + await mailingContext.service.processEmailItem(emailId) +} + +/** + * Processes all pending and failed emails using the mailing service + * @param payload Payload instance + * @returns Promise that resolves when all emails are processed + */ +export async function processAllEmails(payload: Payload): Promise { + // Get mailing context from payload + const mailingContext = (payload as any).mailing + + if (!mailingContext || !mailingContext.service) { + throw new Error('Mailing plugin not properly initialized') + } + + // Process pending emails first + await mailingContext.service.processEmails() + + // Then retry failed emails + await mailingContext.service.retryFailedEmails() +} \ No newline at end of file diff --git a/src/workflows/index.ts b/src/workflows/index.ts deleted file mode 100644 index 6936e53..0000000 --- a/src/workflows/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { sendEmailWorkflow } from './sendEmailWorkflow.js' - -/** - * All mailing-related workflows that get registered with Payload - */ -export const mailingWorkflows = [ - sendEmailWorkflow, -] - -// Re-export everything from individual workflow files -export * from './sendEmailWorkflow.js' \ No newline at end of file diff --git a/src/workflows/sendEmailWorkflow.ts b/src/workflows/sendEmailWorkflow.ts deleted file mode 100644 index 3e3c565..0000000 --- a/src/workflows/sendEmailWorkflow.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { sendEmail } from '../sendEmail.js' -import { BaseEmailDocument } from '../types/index.js' - -export interface SendEmailWorkflowInput { - // Template mode fields - templateSlug?: string - variables?: Record - - // Direct email mode fields - subject?: string - html?: string - text?: string - - // Common fields - to: string | string[] - cc?: string | string[] - bcc?: string | string[] - from?: string - fromName?: string - replyTo?: string - scheduledAt?: string | Date // ISO date string or Date object - priority?: number - - // Workflow-specific option - processImmediately?: boolean // If true, process the email immediately instead of waiting for the queue - - // Allow any additional fields that users might have in their email collection - [key: string]: any -} - -/** - * Transforms workflow input into sendEmail options by separating template and data fields - */ -function transformWorkflowInputToSendEmailOptions(workflowInput: SendEmailWorkflowInput) { - const sendEmailOptions: any = { - data: {} - } - - // If using template mode, set template options - if (workflowInput.templateSlug) { - sendEmailOptions.template = { - slug: workflowInput.templateSlug, - variables: workflowInput.variables || {} - } - } - - // Standard email fields that should be copied to data - const standardFields = ['to', 'cc', 'bcc', 'from', 'fromName', 'replyTo', 'subject', 'html', 'text', 'scheduledAt', 'priority'] - - // Fields that should not be copied to data - const excludedFields = ['templateSlug', 'variables', 'processImmediately'] - - // Copy standard fields to data - standardFields.forEach(field => { - if (workflowInput[field] !== undefined) { - sendEmailOptions.data[field] = workflowInput[field] - } - }) - - // Copy any additional custom fields - Object.keys(workflowInput).forEach(key => { - if (!excludedFields.includes(key) && !standardFields.includes(key)) { - sendEmailOptions.data[key] = workflowInput[key] - } - }) - - return sendEmailOptions -} - -/** - * Workflow for sending emails with optional immediate processing - * Can be used through Payload's workflow system to send emails programmatically - */ -export const sendEmailWorkflow = { - slug: 'send-email', - label: 'Send Email', - inputSchema: [ - { - name: 'processImmediately', - type: 'checkbox' as const, - label: 'Process Immediately', - defaultValue: false, - admin: { - description: 'Process and send the email immediately instead of waiting for the queue processor' - } - }, - { - name: 'templateSlug', - type: 'text' as const, - label: 'Template Slug', - admin: { - description: 'Use a template (leave empty for direct email)', - condition: (data: any) => !data.subject && !data.html - } - }, - { - name: 'variables', - type: 'json' as const, - label: 'Template Variables', - admin: { - description: 'JSON object with variables for template rendering', - condition: (data: any) => Boolean(data.templateSlug) - } - }, - { - name: 'subject', - type: 'text' as const, - label: 'Subject', - admin: { - description: 'Email subject (required if not using template)', - condition: (data: any) => !data.templateSlug - } - }, - { - name: 'html', - type: 'textarea' as const, - label: 'HTML Content', - admin: { - description: 'HTML email content (required if not using template)', - condition: (data: any) => !data.templateSlug - } - }, - { - name: 'text', - type: 'textarea' as const, - label: 'Text Content', - admin: { - description: 'Plain text email content (optional)', - condition: (data: any) => !data.templateSlug - } - }, - { - name: 'to', - type: 'text' as const, - required: true, - label: 'To (Email Recipients)', - admin: { - description: 'Comma-separated list of email addresses' - } - }, - { - name: 'cc', - type: 'text' as const, - label: 'CC (Carbon Copy)', - admin: { - description: 'Optional comma-separated list of CC email addresses' - } - }, - { - name: 'bcc', - type: 'text' as const, - label: 'BCC (Blind Carbon Copy)', - admin: { - description: 'Optional comma-separated list of BCC email addresses' - } - }, - { - name: 'from', - type: 'text' as const, - label: 'From Email', - admin: { - description: 'Optional sender email address (uses default if not provided)' - } - }, - { - name: 'fromName', - type: 'text' as const, - label: 'From Name', - admin: { - description: 'Optional sender display name (e.g., "John Doe")' - } - }, - { - name: 'replyTo', - type: 'text' as const, - label: 'Reply To', - admin: { - description: 'Optional reply-to email address' - } - }, - { - name: 'scheduledAt', - type: 'date' as const, - label: 'Schedule For', - admin: { - description: 'Optional date/time to schedule email for future delivery', - condition: (data: any) => !data.processImmediately - } - }, - { - name: 'priority', - type: 'number' as const, - label: 'Priority', - min: 1, - max: 10, - defaultValue: 5, - admin: { - description: 'Email priority (1 = highest, 10 = lowest)' - } - } - ], - handler: async ({ job, req }: any) => { - const { input, id, taskStatus } = job - const { payload } = req - - // Cast input to our expected type - const workflowInput = input as SendEmailWorkflowInput - const shouldProcessImmediately = workflowInput.processImmediately || false - - try { - console.log(`📧 Workflow ${id}: Creating email...`) - - // Transform workflow input into sendEmail options - const sendEmailOptions = transformWorkflowInputToSendEmailOptions(workflowInput) - - // Create the email in the database - const email = await sendEmail(payload, sendEmailOptions) - - console.log(`✅ Workflow ${id}: Email created with ID: ${email.id}`) - - // Update task status with email ID - if (taskStatus) { - await taskStatus.update({ - data: { - emailId: email.id, - status: 'created' - } - }) - } - - // If processImmediately is true, process the email now - if (shouldProcessImmediately) { - console.log(`⚡ Workflow ${id}: Processing email immediately...`) - - // Get the mailing service from context - const mailingContext = payload.mailing - if (!mailingContext || !mailingContext.service) { - throw new Error('Mailing plugin not properly initialized') - } - - // Process just this specific email - await mailingContext.service.processEmailItem(String(email.id)) - - console.log(`✅ Workflow ${id}: Email processed and sent immediately`) - - // Update task status - if (taskStatus) { - await taskStatus.update({ - data: { - emailId: email.id, - status: 'sent', - processedImmediately: true - } - }) - } - } else { - // Update task status for queued email - if (taskStatus) { - await taskStatus.update({ - data: { - emailId: email.id, - status: 'queued', - processedImmediately: false - } - }) - } - } - - } catch (error) { - console.error(`❌ Workflow ${id}: Failed to process email:`, error) - - // Update task status with error - if (taskStatus) { - await taskStatus.update({ - data: { - status: 'failed', - error: error instanceof Error ? error.message : String(error) - } - }) - } - - if (error instanceof Error) { - throw new Error(`Failed to process email: ${error.message}`, { cause: error }) - } else { - throw new Error(`Failed to process email: ${String(error)}`) - } - } - } -} - -export default sendEmailWorkflow \ No newline at end of file From ccd8ef35c35bad5de6fd7e0e9950148f3f81f9a9 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sun, 14 Sep 2025 18:00:23 +0200 Subject: [PATCH 2/2] Fix error handling and improve error messages - Fix inconsistent error handling in sendEmailTask by re-throwing original Error instances - Preserve stack traces and error context instead of creating new Error wrappers - Improve generic error messages in emailProcessor utilities with specific details - Add actionable guidance for common configuration issues - Help developers understand what went wrong and how to fix it - Bump version to 0.4.1 --- package.json | 2 +- src/jobs/sendEmailTask.ts | 4 +++- src/utils/emailProcessor.ts | 30 ++++++++++++++++++++++++++---- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 1448d52..85bef84 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-mailing", - "version": "0.4.0", + "version": "0.4.1", "description": "Template-based email system with scheduling and job processing for PayloadCMS", "type": "module", "main": "dist/index.js", diff --git a/src/jobs/sendEmailTask.ts b/src/jobs/sendEmailTask.ts index 170bbca..12755d3 100644 --- a/src/jobs/sendEmailTask.ts +++ b/src/jobs/sendEmailTask.ts @@ -242,9 +242,11 @@ export const sendEmailJob = { } } catch (error) { + // Re-throw Error instances to preserve stack trace and error context if (error instanceof Error) { - throw new Error(`Failed to process email: ${error.message}`, { cause: error }) + throw error } else { + // Only wrap non-Error values throw new Error(`Failed to process email: ${String(error)}`) } } diff --git a/src/utils/emailProcessor.ts b/src/utils/emailProcessor.ts index b69c1da..ff7daf7 100644 --- a/src/utils/emailProcessor.ts +++ b/src/utils/emailProcessor.ts @@ -10,8 +10,19 @@ export async function processEmailById(payload: Payload, emailId: string): Promi // Get mailing context from payload const mailingContext = (payload as any).mailing - if (!mailingContext || !mailingContext.service) { - throw new Error('Mailing plugin not properly initialized') + if (!mailingContext) { + throw new Error( + 'Mailing plugin not found on payload instance. ' + + 'Ensure the mailingPlugin is properly configured in your Payload config plugins array.' + ) + } + + if (!mailingContext.service) { + throw new Error( + 'Mailing service not available. ' + + 'The plugin may not have completed initialization. ' + + 'Check that email configuration is properly set up in your Payload config.' + ) } // Process the specific email @@ -27,8 +38,19 @@ export async function processAllEmails(payload: Payload): Promise { // Get mailing context from payload const mailingContext = (payload as any).mailing - if (!mailingContext || !mailingContext.service) { - throw new Error('Mailing plugin not properly initialized') + if (!mailingContext) { + throw new Error( + 'Mailing plugin not found on payload instance. ' + + 'Ensure the mailingPlugin is properly configured in your Payload config plugins array.' + ) + } + + if (!mailingContext.service) { + throw new Error( + 'Mailing service not available. ' + + 'The plugin may not have completed initialization. ' + + 'Check that email configuration is properly set up in your Payload config.' + ) } // Process pending emails first