Compare commits

..

7 Commits

Author SHA1 Message Date
Bas
80d32674a9 Merge pull request #15 from xtr-dev/dev
Add automatic job scheduling and rescheduling
2025-09-13 17:00:33 +02:00
243f7c96cf Bump package version to 0.0.8 in package.json. 2025-09-13 16:57:50 +02:00
159a99a1ec Fix race conditions and add validation for job scheduling
- Reuse duplicate prevention logic in rescheduling to prevent race conditions
- Add queueName validation in plugin initialization and helper function
- Enhanced scheduleEmailProcessingJob to return boolean and accept delay parameter
- Improve error handling: rescheduling failures warn but don't fail current job
- Prevent duplicate jobs when multiple handlers complete simultaneously

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:56:51 +02:00
860dd7e921 Add automatic job scheduling and rescheduling
- Add scheduleEmailProcessingJob helper to check and schedule jobs on init
- Only schedule if no existing uncompleted job exists
- Update job handler to always reschedule itself after completion (5min interval)
- Job reschedules regardless of success/failure for continuous processing
- Initial job starts 1 minute after init, subsequent jobs every 5 minutes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:51:59 +02:00
Bas
c7af628beb Merge pull request #14 from xtr-dev/dev
Add defaultFromName config option and bump to v0.0.7
2025-09-13 16:29:26 +02:00
fa54c5622c Improve email display name handling with proper escaping
- Add quote escaping in display names to prevent malformed email headers
- Handle empty string defaultFromName by checking trim()
- Prevent formatting when fromName is only whitespace
- Example: John "The Boss" Doe becomes "John \"The Boss\" Doe" <email>

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:26:53 +02:00
719b60b9ef Add defaultFromName config option and bump to v0.0.7
- Add defaultFromName to MailingPluginConfig interface
- Update MailingService to format from field with name when available
- Add getDefaultFrom() helper method for consistent formatting
- Format as "Name" <email@domain.com> when both name and email are provided
- Bump version to 0.0.7

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:23:39 +02:00
4 changed files with 107 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-mailing", "name": "@xtr-dev/payload-mailing",
"version": "0.0.6", "version": "0.0.8",
"description": "Template-based email system with scheduling and job processing for PayloadCMS", "description": "Template-based email system with scheduling and job processing for PayloadCMS",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -4,9 +4,61 @@ import { MailingService } from './services/MailingService.js'
import { createEmailTemplatesCollection } from './collections/EmailTemplates.js' import { createEmailTemplatesCollection } from './collections/EmailTemplates.js'
import Emails from './collections/Emails.js' import Emails from './collections/Emails.js'
// Helper function to schedule the email processing job
async function scheduleEmailProcessingJob(payload: any, queueName: string, delayMs: number = 60000): Promise<boolean> {
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 => { export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
const queueName = pluginConfig.queue || 'default' const queueName = pluginConfig.queue || 'default'
// Validate queueName
if (!queueName || typeof queueName !== 'string') {
throw new Error('Invalid queue configuration: queue must be a non-empty string')
}
// Handle templates collection configuration // Handle templates collection configuration
const templatesConfig = pluginConfig.collections?.templates const templatesConfig = pluginConfig.collections?.templates
const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates' const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates'
@@ -80,8 +132,11 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
{ {
slug: 'process-email-queue', slug: 'process-email-queue',
handler: async ({ job, req }: { job: any; req: any }) => { handler: async ({ job, req }: { job: any; req: any }) => {
const payload = (req as any).payload
let jobResult = null
try { try {
const mailingService = new MailingService((req as any).payload, pluginConfig) const mailingService = new MailingService(payload, pluginConfig)
console.log('🔄 Processing email queue (pending + failed emails)...') console.log('🔄 Processing email queue (pending + failed emails)...')
@@ -91,19 +146,41 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
// Then retry failed emails // Then retry failed emails
await mailingService.retryFailedEmails() await mailingService.retryFailedEmails()
return { jobResult = {
output: { output: {
success: true, success: true,
message: 'Email queue processed successfully (pending and failed emails)' message: 'Email queue processed successfully (pending and failed emails)'
} }
} }
console.log('✅ Email queue processing completed successfully')
} catch (error) { } catch (error) {
console.error('❌ Error processing email queue:', error) console.error('❌ Error processing email queue:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error' const errorMessage = error instanceof Error ? error.message : 'Unknown error'
// Properly fail the job by throwing the error jobResult = new Error(`Email queue processing failed: ${errorMessage}`)
throw 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', interfaceName: 'ProcessEmailQueueJob',
}, },
@@ -130,6 +207,13 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
console.log('PayloadCMS Mailing Plugin initialized successfully') console.log('PayloadCMS Mailing Plugin initialized successfully')
// Schedule the email processing job if not already scheduled
try {
await scheduleEmailProcessingJob(payload, queueName)
} catch (error) {
console.error('Failed to schedule email processing job:', error)
}
// Call onReady callback if provided // Call onReady callback if provided
if (pluginConfig.onReady) { if (pluginConfig.onReady) {
await pluginConfig.onReady(payload) await pluginConfig.onReady(payload)

View File

@@ -48,6 +48,20 @@ export class MailingService implements IMailingService {
} }
} }
private getDefaultFrom(): string {
const fromEmail = this.config.defaultFrom
const fromName = this.config.defaultFromName
// Check if fromName exists, is not empty after trimming, and fromEmail exists
if (fromName && fromName.trim() && fromEmail) {
// Escape quotes in the display name to prevent malformed headers
const escapedName = fromName.replace(/"/g, '\\"')
return `"${escapedName}" <${fromEmail}>`
}
return fromEmail || ''
}
private registerHandlebarsHelpers(): void { private registerHandlebarsHelpers(): void {
Handlebars.registerHelper('formatDate', (date: Date, format?: string) => { Handlebars.registerHelper('formatDate', (date: Date, format?: string) => {
if (!date) return '' if (!date) return ''
@@ -128,7 +142,7 @@ export class MailingService implements IMailingService {
to: Array.isArray(options.to) ? options.to : [options.to], to: Array.isArray(options.to) ? options.to : [options.to],
cc: options.cc ? (Array.isArray(options.cc) ? options.cc : [options.cc]) : undefined, cc: options.cc ? (Array.isArray(options.cc) ? options.cc : [options.cc]) : undefined,
bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : undefined, bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : undefined,
from: options.from || this.config.defaultFrom, from: options.from || this.getDefaultFrom(),
replyTo: options.replyTo, replyTo: options.replyTo,
subject: subject || options.subject, subject: subject || options.subject,
html, html,
@@ -245,7 +259,7 @@ export class MailingService implements IMailingService {
}) as QueuedEmail }) as QueuedEmail
let emailObject: EmailObject = { let emailObject: EmailObject = {
from: email.from || this.config.defaultFrom, from: email.from || this.getDefaultFrom(),
to: email.to, to: email.to,
cc: email.cc || undefined, cc: email.cc || undefined,
bcc: email.bcc || undefined, bcc: email.bcc || undefined,
@@ -262,7 +276,7 @@ export class MailingService implements IMailingService {
} }
const mailOptions = { const mailOptions = {
from: emailObject.from || this.config.defaultFrom, from: emailObject.from,
to: emailObject.to, to: emailObject.to,
cc: emailObject.cc || undefined, cc: emailObject.cc || undefined,
bcc: emailObject.bcc || undefined, bcc: emailObject.bcc || undefined,

View File

@@ -22,6 +22,7 @@ export interface MailingPluginConfig {
emails?: string | Partial<CollectionConfig> emails?: string | Partial<CollectionConfig>
} }
defaultFrom?: string defaultFrom?: string
defaultFromName?: string
transport?: Transporter | MailingTransportConfig transport?: Transporter | MailingTransportConfig
queue?: string queue?: string
retryAttempts?: number retryAttempts?: number