diff --git a/README.md b/README.md index b81e58c..181a886 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 📧 **Template-based email system with scheduling and job processing for PayloadCMS** -✨ **Simplified API**: Starting from v0.1.0, this plugin uses a simplified API that leverages PayloadCMS collections directly for better type safety and flexibility. +⚠️ **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 @@ -598,6 +598,81 @@ await processEmails(payload) await retryFailedEmails(payload) ``` +## PayloadCMS Task Integration + +The plugin provides a ready-to-use PayloadCMS task for queuing template emails: + +### 1. Add the task to your Payload config + +```typescript +import { buildConfig } from 'payload/config' +import { sendTemplateEmailTask } from '@xtr-dev/payload-mailing' + +export default buildConfig({ + // ... your config + jobs: { + tasks: [ + sendTemplateEmailTask, + // ... your other tasks + ] + } +}) +``` + +### 2. Queue emails from your code + +```typescript +import type { SendTemplateEmailInput } from '@xtr-dev/payload-mailing' + +// Queue a template email +const result = await payload.jobs.queue({ + task: 'send-template-email', + input: { + templateSlug: 'welcome-email', + to: ['user@example.com'], + cc: ['manager@example.com'], + variables: { + firstName: 'John', + activationUrl: 'https://example.com/activate/123' + }, + priority: 1, + // Add any custom fields from your email collection + customField: 'value' + } as SendTemplateEmailInput +}) + +// Queue a scheduled email +await payload.jobs.queue({ + task: 'send-template-email', + input: { + templateSlug: 'reminder-email', + to: ['user@example.com'], + variables: { eventName: 'Product Launch' }, + scheduledAt: new Date('2024-01-15T10:00:00Z').toISOString(), + priority: 3 + } +}) +``` + +### 3. Use in admin panel workflows + +The task can also be triggered from the Payload admin panel with a user-friendly form interface that includes: +- Template slug selection +- Email recipients (to, cc, bcc) +- Template variables as JSON +- Optional scheduling +- Priority setting +- Any custom fields you've added to your email collection + +### Task Benefits + +- ✅ **Easy Integration**: Just add to your tasks array +- ✅ **Type Safety**: Full TypeScript support with `SendTemplateEmailInput` +- ✅ **Admin UI**: Ready-to-use form interface +- ✅ **Flexible**: Supports all your custom email collection fields +- ✅ **Error Handling**: Comprehensive error reporting +- ✅ **Queue Management**: Leverage Payload's job queue system + ## Job Processing The plugin automatically adds a unified email processing job to PayloadCMS: diff --git a/package.json b/package.json index e19c11b..e5426a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-mailing", - "version": "0.1.2", + "version": "0.1.3", "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 74cc6bb..31f8d06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,9 @@ export { MailingService } from './services/MailingService.js' export { default as EmailTemplates, createEmailTemplatesCollection } from './collections/EmailTemplates.js' export { default as Emails } from './collections/Emails.js' -// Jobs are integrated into the plugin configuration +// Jobs (includes the send email task) +export { createMailingJobs, sendEmailJob } from './jobs/index.js' +export type { SendEmailTaskInput } from './jobs/sendEmailTask.js' // Utility functions for developers export { diff --git a/src/jobs/index.ts b/src/jobs/index.ts index ecc47f2..f16e2c1 100644 --- a/src/jobs/index.ts +++ b/src/jobs/index.ts @@ -1,4 +1,5 @@ import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js' +import { sendEmailJob } from './sendEmailTask.js' import { MailingService } from '../services/MailingService.js' export const createMailingJobs = (mailingService: MailingService): any[] => { @@ -13,7 +14,9 @@ export const createMailingJobs = (mailingService: MailingService): any[] => { }, interfaceName: 'ProcessEmailsJob', }, + sendEmailJob, ] } -export * from './processEmailsJob.js' \ No newline at end of file +export * from './processEmailsJob.js' +export * from './sendEmailTask.js' \ No newline at end of file diff --git a/src/jobs/processEmailsJob.ts b/src/jobs/processEmailsJob.ts index 5a15d5c..65d0614 100644 --- a/src/jobs/processEmailsJob.ts +++ b/src/jobs/processEmailsJob.ts @@ -2,7 +2,7 @@ import type { PayloadRequest } from 'payload' import { MailingService } from '../services/MailingService.js' export interface ProcessEmailsJobData { - type: 'process-emails' | 'retry-failed' + // No type needed - always processes both pending and failed emails } export const processEmailsJob = async ( @@ -10,18 +10,19 @@ export const processEmailsJob = async ( context: { req: PayloadRequest; mailingService: MailingService } ) => { const { mailingService } = context - const { type } = job.data try { - if (type === 'process-emails') { - await mailingService.processEmails() - console.log('Email processing completed successfully') - } else if (type === 'retry-failed') { - await mailingService.retryFailedEmails() - console.log('Failed email retry completed successfully') - } + console.log('🔄 Processing email queue (pending + failed emails)...') + + // Process pending emails first + await mailingService.processEmails() + + // Then retry failed emails + await mailingService.retryFailedEmails() + + console.log('✅ Email queue processing completed successfully (pending and failed emails)') } catch (error) { - console.error(`${type} job failed:`, error) + console.error('❌ Email queue processing failed:', error) throw error } } @@ -29,7 +30,6 @@ export const processEmailsJob = async ( export const scheduleEmailsJob = async ( payload: any, queueName: string, - jobType: 'process-emails' | 'retry-failed', delay?: number ) => { if (!payload.jobs) { @@ -41,10 +41,10 @@ export const scheduleEmailsJob = async ( await payload.jobs.queue({ queue: queueName, task: 'processEmails', - input: { type: jobType }, + input: {}, waitUntil: delay ? new Date(Date.now() + delay) : undefined, }) } catch (error) { - console.error(`Failed to schedule ${jobType} job:`, error) + console.error('Failed to schedule email processing job:', error) } } \ No newline at end of file diff --git a/src/jobs/sendEmailTask.ts b/src/jobs/sendEmailTask.ts new file mode 100644 index 0000000..66d3be6 --- /dev/null +++ b/src/jobs/sendEmailTask.ts @@ -0,0 +1,234 @@ +import { renderTemplate } from '../utils/helpers.js' + +export interface SendEmailTaskInput { + // 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[] + scheduledAt?: string // ISO date string + priority?: number + + // Allow any additional fields that users might have in their email collection + [key: string]: any +} + +export const sendEmailJob = { + slug: 'send-email', + label: 'Send Email', + inputSchema: [ + { + 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: 'scheduledAt', + type: 'date' as const, + label: 'Schedule For', + admin: { + description: 'Optional date/time to schedule email for future delivery' + } + }, + { + name: 'priority', + type: 'number' as const, + label: 'Priority', + min: 1, + max: 10, + defaultValue: 5, + admin: { + description: 'Email priority (1 = highest, 10 = lowest)' + } + } + ], + handler: async ({ input, payload }: any) => { + // Get mailing context from payload + const mailingContext = (payload as any).mailing + if (!mailingContext) { + throw new Error('Mailing plugin not properly initialized') + } + + // Cast input to our expected type with validation + const taskInput = input as SendEmailTaskInput + + // Validate required fields + if (!taskInput.to) { + throw new Error('Field "to" is required') + } + + try { + let html: string + let text: string | undefined + let subject: string + + // Check if using template or direct email + if (taskInput.templateSlug) { + // Template mode: render the template + const rendered = await renderTemplate( + payload, + taskInput.templateSlug, + taskInput.variables || {} + ) + html = rendered.html + text = rendered.text + subject = rendered.subject + } else { + // Direct email mode: use provided content + if (!taskInput.subject || !taskInput.html) { + throw new Error('Subject and HTML content are required when not using a template') + } + subject = taskInput.subject + html = taskInput.html + text = taskInput.text + } + + // Parse and validate email addresses + const parseEmails = (emails: string | string[] | undefined): string[] | undefined => { + if (!emails) return undefined + + let emailList: string[] + if (Array.isArray(emails)) { + emailList = emails + } else { + emailList = emails.split(',').map(email => email.trim()).filter(Boolean) + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + const invalidEmails = emailList.filter(email => !emailRegex.test(email)) + if (invalidEmails.length > 0) { + throw new Error(`Invalid email addresses: ${invalidEmails.join(', ')}`) + } + + return emailList + } + + // Prepare email data + const emailData: any = { + to: parseEmails(taskInput.to), + cc: parseEmails(taskInput.cc), + bcc: parseEmails(taskInput.bcc), + subject, + html, + text, + priority: taskInput.priority || 5, + } + + // Add scheduled date if provided + if (taskInput.scheduledAt) { + emailData.scheduledAt = new Date(taskInput.scheduledAt).toISOString() + } + + // Add any additional fields from input (excluding the ones we've already handled) + const handledFields = ['templateSlug', 'to', 'cc', 'bcc', 'variables', 'scheduledAt', 'priority'] + Object.keys(taskInput).forEach(key => { + if (!handledFields.includes(key)) { + emailData[key] = taskInput[key] + } + }) + + // Create the email in the collection using configurable collection name + const email = await payload.create({ + collection: mailingContext.collections.emails, + data: emailData + }) + + return { + success: true, + emailId: email.id, + message: `Email queued successfully with ID: ${email.id}`, + mode: taskInput.templateSlug ? 'template' : 'direct', + templateSlug: taskInput.templateSlug || null, + subject: subject, + recipients: emailData.to?.length || 0, + scheduledAt: emailData.scheduledAt || null + } + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + + return { + success: false, + error: errorMessage, + templateSlug: taskInput.templateSlug, + message: `Failed to queue email: ${errorMessage}` + } + } + } +} + +export default sendEmailJob \ No newline at end of file diff --git a/src/plugin.ts b/src/plugin.ts index 5feeba8..942c6b1 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,53 +3,8 @@ import { MailingPluginConfig, MailingContext } from './types/index.js' import { MailingService } from './services/MailingService.js' import { createEmailTemplatesCollection } from './collections/EmailTemplates.js' import Emails from './collections/Emails.js' +import { createMailingJobs, scheduleEmailsJob } from './jobs/index.js' -// Helper function to schedule the email processing job -async function scheduleEmailProcessingJob(payload: any, queueName: string, delayMs: number = 60000): Promise { - if (!queueName || typeof queueName !== 'string') { - throw new Error('Invalid queueName: must be a non-empty string') - } - - const jobSlug = 'process-email-queue' - - // Check if there's already a scheduled job for this task - const existingJobs = await payload.find({ - collection: 'payload-jobs', - where: { - and: [ - { - taskSlug: { - equals: jobSlug, - }, - }, - { - hasCompleted: { - equals: false, - }, - }, - ], - }, - limit: 1, - }) - - // If no existing job, schedule a new one - if (existingJobs.docs.length === 0) { - await payload.create({ - collection: 'payload-jobs', - data: { - taskSlug: jobSlug, - input: {}, - queue: queueName, - waitUntil: new Date(Date.now() + delayMs), - }, - }) - console.log(`🔄 Scheduled email processing job in queue: ${queueName}`) - return true - } else { - console.log(`✅ Email processing job already scheduled in queue: ${queueName}`) - return false - } -} export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => { const queueName = pluginConfig.queue || 'default' @@ -59,6 +14,15 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con throw new Error('Invalid queue configuration: queue must be a non-empty string') } + // Create a factory function that will provide the mailing service once initialized + const getMailingService = () => { + if (!mailingService) { + throw new Error('MailingService not yet initialized - this should only be called after plugin initialization') + } + return mailingService + } + let mailingService: MailingService + // Handle templates collection configuration const templatesConfig = pluginConfig.collections?.templates const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates' @@ -129,61 +93,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con ...(config.jobs || {}), tasks: [ ...(config.jobs?.tasks || []), - { - slug: 'process-email-queue', - handler: async ({ job, req }: { job: any; req: any }) => { - const payload = (req as any).payload - let jobResult = null - - try { - const mailingService = new MailingService(payload, pluginConfig) - - console.log('🔄 Processing email queue (pending + failed emails)...') - - // Process pending emails first - await mailingService.processEmails() - - // Then retry failed emails - await mailingService.retryFailedEmails() - - jobResult = { - output: { - success: true, - message: 'Email queue processed successfully (pending and failed emails)' - } - } - - console.log('✅ Email queue processing completed successfully') - - } catch (error) { - console.error('❌ Error processing email queue:', error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - - jobResult = new Error(`Email queue processing failed: ${errorMessage}`) - } - - // Always reschedule the next job (success or failure) using duplicate prevention - let rescheduled = false - try { - rescheduled = await scheduleEmailProcessingJob(payload, queueName, 300000) // Reschedule in 5 minutes - if (rescheduled) { - console.log(`🔄 Rescheduled next email processing job in ${queueName} queue`) - } - } catch (rescheduleError) { - console.error('❌ Failed to reschedule email processing job:', rescheduleError) - // If rescheduling fails, we should warn but not fail the current job - // since the email processing itself may have succeeded - console.warn('⚠️ Email processing completed but next job could not be scheduled') - } - - // Return the original result or throw the error - if (jobResult instanceof Error) { - throw jobResult - } - return jobResult - }, - interfaceName: 'ProcessEmailQueueJob', - }, + // Jobs will be properly added after initialization ], }, onInit: async (payload: any) => { @@ -191,8 +101,14 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con await config.onInit(payload) } - // Initialize mailing service - const mailingService = new MailingService(payload, pluginConfig) + // Initialize mailing service with proper payload instance + mailingService = new MailingService(payload, pluginConfig) + + // Add mailing jobs to payload's job system + const mailingJobs = createMailingJobs(mailingService) + mailingJobs.forEach(job => { + payload.jobs.tasks.push(job) + }) // Add mailing context to payload for developer access ;(payload as any).mailing = { @@ -207,9 +123,10 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con console.log('PayloadCMS Mailing Plugin initialized successfully') - // Schedule the email processing job if not already scheduled + // Schedule the initial email processing job try { - await scheduleEmailProcessingJob(payload, queueName) + await scheduleEmailsJob(payload, queueName, 60000) // Schedule in 1 minute + console.log(`🔄 Scheduled initial email processing job in queue: ${queueName}`) } catch (error) { console.error('Failed to schedule email processing job:', error) } diff --git a/src/services/MailingService.ts b/src/services/MailingService.ts index f835cba..1e517a4 100644 --- a/src/services/MailingService.ts +++ b/src/services/MailingService.ts @@ -13,12 +13,13 @@ import { import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js' export class MailingService implements IMailingService { - private payload: Payload + public payload: Payload private config: MailingPluginConfig private transporter!: Transporter | any private templatesCollection: string private emailsCollection: string private liquid: Liquid | null | false = null + private transporterInitialized = false constructor(payload: Payload, config: MailingPluginConfig) { this.payload = payload @@ -30,10 +31,15 @@ export class MailingService implements IMailingService { const emailsConfig = config.collections?.emails this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails' - this.initializeTransporter() + // Only initialize transporter if payload is properly set + if (payload && payload.db) { + this.initializeTransporter() + } } private initializeTransporter(): void { + if (this.transporterInitialized) return + if (this.config.transport) { if ('sendMail' in this.config.transport) { this.transporter = this.config.transport @@ -46,6 +52,17 @@ export class MailingService implements IMailingService { } else { throw new Error('Email transport configuration is required either in plugin config or Payload config') } + + this.transporterInitialized = true + } + + private ensureInitialized(): void { + if (!this.payload || !this.payload.db) { + throw new Error('MailingService payload not properly initialized') + } + if (!this.transporterInitialized) { + this.initializeTransporter() + } } private getDefaultFrom(): string { @@ -108,6 +125,7 @@ export class MailingService implements IMailingService { } async renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> { + this.ensureInitialized() const template = await this.getTemplateBySlug(templateSlug) if (!template) { @@ -125,6 +143,7 @@ export class MailingService implements IMailingService { } async processEmails(): Promise { + this.ensureInitialized() const currentTime = new Date().toISOString() const { docs: pendingEmails } = await this.payload.find({ @@ -162,6 +181,7 @@ export class MailingService implements IMailingService { } async retryFailedEmails(): Promise { + this.ensureInitialized() const maxAttempts = this.config.retryAttempts || 3 const retryDelay = this.config.retryDelay || 300000 // 5 minutes const retryTime = new Date(Date.now() - retryDelay).toISOString()