From a6564e2a2918e2cfdf4582d2eaea4066d408f6f6 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sun, 14 Sep 2025 17:20:21 +0200 Subject: [PATCH] Add sendEmail workflow with immediate processing option - Create sendEmailWorkflow as a Payload workflow alternative to task - Add processImmediately option (disabled by default) to send emails immediately - Expose processEmailItem method in MailingService for individual email processing - Add comprehensive input schema with conditional fields - Update plugin to register both tasks and workflows - Add detailed documentation comparing tasks vs workflows - Includes status tracking and error handling - Bump version to 0.3.0 (new feature) --- README.md | 73 ++++++- package.json | 2 +- src/index.ts | 4 + src/plugin.ts | 5 + src/services/MailingService.ts | 2 +- src/types/index.ts | 1 + src/workflows/index.ts | 11 ++ src/workflows/sendEmailWorkflow.ts | 295 +++++++++++++++++++++++++++++ 8 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 src/workflows/index.ts create mode 100644 src/workflows/sendEmailWorkflow.ts diff --git a/README.md b/README.md index b8fd70a..826a84e 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,16 @@ await processEmails(payload) await retryFailedEmails(payload) ``` -## PayloadCMS Task Integration +## 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: @@ -455,6 +464,68 @@ 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 + +The plugin also provides a workflow for sending emails with advanced features: + +```typescript +import { sendEmailWorkflow } from '@xtr-dev/payload-mailing' + +export default buildConfig({ + // ... your config + jobs: { + workflows: [ + sendEmailWorkflow, + // ... your other workflows + ] + } +}) +``` + +### Workflow Features + +- **Immediate Processing**: Option to send emails immediately instead of queuing +- **Admin UI**: Rich form interface with conditional fields +- **Status Tracking**: Track workflow execution progress +- **Template or Direct**: Support for both template-based and direct HTML emails + +### Using the Workflow + +```typescript +// Queue a workflow with immediate processing +await payload.workflows.queue({ + workflow: 'send-email', + input: { + processImmediately: true, // Send immediately + templateSlug: 'welcome-email', + to: ['user@example.com'], + variables: { name: 'John Doe' } + } +}) + +// Queue normally (processed by background job) +await payload.workflows.queue({ + workflow: 'send-email', + input: { + processImmediately: false, // Default: false + subject: 'Direct Email', + html: '

Hello World!

', + to: ['user@example.com'] + } +}) +``` + +### Workflow vs Task Comparison + +| Feature | Task | Workflow | +|---------|------|----------| +| Admin UI | Basic form | Rich form with conditions | +| Immediate Processing | ❌ | ✅ (optional) | +| Status Tracking | Basic | Detailed with progress | +| Template Support | ✅ | ✅ | +| Direct HTML Support | ✅ | ✅ | +| Background Processing | ✅ | ✅ | + ## Job Processing The plugin automatically adds a unified email processing job to PayloadCMS: diff --git a/package.json b/package.json index cbc42a3..95580a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-mailing", - "version": "0.2.1", + "version": "0.3.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 bb6cd5b..15d5d5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,10 @@ 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' diff --git a/src/plugin.ts b/src/plugin.ts index 8cd05c1..efb056a 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -4,6 +4,7 @@ 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 => { @@ -86,6 +87,10 @@ 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/services/MailingService.ts b/src/services/MailingService.ts index d1860fb..c71d185 100644 --- a/src/services/MailingService.ts +++ b/src/services/MailingService.ts @@ -225,7 +225,7 @@ export class MailingService implements IMailingService { } } - private async processEmailItem(emailId: string): Promise { + async processEmailItem(emailId: string): Promise { try { await this.payload.update({ collection: this.emailsCollection as any, diff --git a/src/types/index.ts b/src/types/index.ts index b545996..4c4a86c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -111,6 +111,7 @@ export interface TemplateVariables { export interface MailingService { processEmails(): Promise + processEmailItem(emailId: string): Promise retryFailedEmails(): Promise renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> } diff --git a/src/workflows/index.ts b/src/workflows/index.ts new file mode 100644 index 0000000..6936e53 --- /dev/null +++ b/src/workflows/index.ts @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..98697f0 --- /dev/null +++ b/src/workflows/sendEmailWorkflow.ts @@ -0,0 +1,295 @@ +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) { + // Preserve original error and stack trace + const wrappedError = new Error(`Failed to process email: ${error.message}`) + wrappedError.stack = error.stack + wrappedError.cause = error + throw wrappedError + } else { + throw new Error(`Failed to process email: ${String(error)}`) + } + } + } +} + +export default sendEmailWorkflow \ No newline at end of file