Compare commits

...

20 Commits

Author SHA1 Message Date
Bas
53ab62ed10 Merge pull request #68 from xtr-dev/dev
Fix filterOptions ObjectId casting error and bump version to 0.4.20
2025-10-07 22:04:22 +02:00
de57dd4102 Fix filterOptions ObjectId casting error and bump version to 0.4.20
Fixed incorrect usage of resolveID in filterOptions where { id } was passed instead of id directly. This caused ObjectId casting errors when the id parameter was a populated object.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 22:01:23 +02:00
Bas
4633ead274 Merge pull request #67 from xtr-dev/dev
Fix ObjectId casting error when jobs relationship is populated
2025-10-07 21:38:42 +02:00
d69f7c1f98 Fix ObjectId casting error when jobs relationship is populated
When the email's jobs relationship is populated with full job objects instead of just IDs,
calling String(job) on an object results in "[object Object]", which causes a Mongoose
ObjectId casting error. This fix properly extracts the ID from job objects or uses the
value directly if it's already an ID.

Fixes job scheduler error: "Cast to ObjectId failed for value '[object Object]'"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 21:35:55 +02:00
Bas
57984e8633 Merge pull request #65 from xtr-dev/dev
Fix template relationship population in sendEmail and bump version to…
2025-10-06 23:58:25 +02:00
d15fa454a0 Refactor template lookup to eliminate duplication and improve type safety
Changes:
- Added MailingService.renderTemplateDocument() method to render from template document
- Created renderTemplateWithId() helper that combines lookup and rendering in one operation
- Updated sendEmail() to use renderTemplateWithId() instead of separate lookup and render
- Added runtime validation to ensure template collection exists before querying
- Eliminated duplicate template lookup (previously looked up twice per email send)

Benefits:
- Improved performance by reducing database queries from 2 to 1 per template-based email
- Better error messages when template collection is misconfigured
- Runtime validation complements TypeScript type assertions for safer code
- Cleaner separation of concerns in sendEmail() function

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 23:55:31 +02:00
f431786907 Fix template relationship population in sendEmail and bump version to 0.4.18
The sendEmail function now properly populates the template relationship field when using template-based emails. This ensures:
- Template relationship is set on the email document
- templateSlug field is auto-populated via beforeChange hook
- beforeSend hook has access to the full template relationship
- Proper record of which template was used for each email

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 23:48:23 +02:00
Bas
63a5c5f982 Merge pull request #64 from xtr-dev/dev
Add templateSlug field auto-populated from template relationship and …
2025-10-06 23:38:11 +02:00
107f67e22b Add templateSlug field auto-populated from template relationship and bump version to 0.4.17
Added templateSlug text field to Emails collection that is automatically populated via beforeChange hook when template relationship is set, making template slug accessible in beforeSend hook.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 23:37:17 +02:00
Bas
e95296feff Merge pull request #63 from xtr-dev/dev
Fix template population in beforeSend hook and bump version to 0.4.16
2025-10-06 23:23:14 +02:00
7b853cbd4a Fix template population in beforeSend hook and bump version to 0.4.16
Added depth parameter to findByID call in processEmailItem to ensure template relationship is populated when passed to beforeSend hook.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 23:20:56 +02:00
Bas
8406bca718 Merge pull request #62 from xtr-dev/dev
Dev
2025-10-06 23:16:24 +02:00
59ce8c031a Refactor immediate processing to use configurable job polling
Extract complex polling mechanism from sendEmail.ts into dedicated utility function (jobPolling.ts) and make polling parameters configurable via plugin options. This improves code maintainability and allows users to customize polling behavior through the jobPolling config option.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 23:13:54 +02:00
08ba814da0 Add PayloadID type and relation helpers, fix filterOptions casting issue
- Add PayloadID type for string | number IDs
- Add PayloadRelation<T> type for populated/unpopulated relations
- Add isPopulated() type guard to check if relation is populated
- Add resolveID() helper to extract ID from relation (object or ID)
- Add resolveIDs() helper for arrays of relations
- Fix filterOptions in Emails.ts to safely resolve ID before filtering
- This prevents MongoDB ObjectId casting errors when id is an object
- Bump version to 0.4.15

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 23:05:16 +02:00
f303eda652 Clean up sendEmail.ts and bump version to 0.4.14
- Remove duplicate nested if statement at line 188
- Remove redundant comments throughout the file
- Simplify code structure for better readability
- Bump patch version from 0.4.13 to 0.4.14

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 22:59:55 +02:00
Bas
8e1128f1e8 Merge pull request #61 from xtr-dev/dev
Remove deprecated `processEmailsTask` and associated helpers
2025-09-27 11:49:44 +02:00
c62a364d9c Remove deprecated processEmailsTask and associated helpers
- Deleted batch email processing logic in favor of individual email jobs
- Updated `mailingJobs` to only register `processEmailJob`
- Simplified LiquidJS initialization check in `MailingService`
- Bumped version to 0.4.13
2025-09-27 11:48:38 +02:00
Bas
50ce181893 Merge pull request #59 from xtr-dev/dev
Dev
2025-09-20 20:29:07 +02:00
8b2af8164a Remove verbose debug logs from immediate processing
- Reduced log noise while keeping essential error logging
- Only show job polling logs after 2 attempts (to catch real issues)
- Keep the main job scheduling confirmation log
- Immediate processing success is now at debug level

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 20:24:34 +02:00
3d7ddb8c97 Bump version to 0.4.12 2025-09-20 20:23:07 +02:00
13 changed files with 312 additions and 330 deletions

View File

@@ -123,123 +123,6 @@ export default buildConfig({
retryDelay: 60000, // 1 minute for dev retryDelay: 60000, // 1 minute for dev
queue: 'default', queue: 'default',
// Example: Collection overrides for customization
// Uncomment and modify as needed for your use case
/*
collections: {
templates: {
// Custom access controls - restrict who can manage templates
access: {
read: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:read')
},
create: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:create')
},
update: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:update')
},
delete: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
},
// Custom admin UI settings
admin: {
group: 'Marketing',
description: 'Email templates with enhanced security and categorization'
},
// Add custom fields to templates
fields: [
// Default plugin fields are automatically included
{
name: 'category',
type: 'select',
options: [
{ label: 'Marketing', value: 'marketing' },
{ label: 'Transactional', value: 'transactional' },
{ label: 'System Notifications', value: 'system' }
],
defaultValue: 'transactional',
admin: {
position: 'sidebar',
description: 'Template category for organization'
}
},
{
name: 'tags',
type: 'text',
hasMany: true,
admin: {
position: 'sidebar',
description: 'Tags for easy template filtering'
}
},
{
name: 'isActive',
type: 'checkbox',
defaultValue: true,
admin: {
position: 'sidebar',
description: 'Only active templates can be used'
}
}
],
// Custom validation hooks
hooks: {
beforeChange: [
({ data, req }) => {
// Example: Only admins can create system templates
if (data.category === 'system' && req.user?.role !== 'admin') {
throw new Error('Only administrators can create system notification templates')
}
// Example: Auto-generate slug if not provided
if (!data.slug && data.name) {
data.slug = data.name.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
return data
}
]
}
},
emails: {
// Restrict access to emails collection
access: {
read: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:read')
},
create: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:create')
},
update: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:update')
},
delete: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
},
// Custom admin configuration for emails
admin: {
group: 'Marketing',
description: 'Email delivery tracking and management',
defaultColumns: ['subject', 'to', 'status', 'priority', 'scheduledAt'],
}
}
},
*/
// Optional: Custom rich text editor configuration // Optional: Custom rich text editor configuration
// Comment out to use default lexical editor // Comment out to use default lexical editor
richTextEditor: lexicalEditor({ richTextEditor: lexicalEditor({
@@ -256,12 +139,6 @@ export default buildConfig({
// etc. // etc.
], ],
}), }),
// Called after mailing plugin is fully initialized
onReady: async (payload) => {
await seedUser(payload)
},
}), }),
], ],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key', secret: process.env.PAYLOAD_SECRET || 'test-secret_key',

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-mailing", "name": "@xtr-dev/payload-mailing",
"version": "0.4.11", "version": "0.4.20",
"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

@@ -1,6 +1,7 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from 'payload'
import { findExistingJobs, ensureEmailJob, updateEmailJobRelationship } from '../utils/jobScheduler.js' import { findExistingJobs, ensureEmailJob, updateEmailJobRelationship } from '../utils/jobScheduler.js'
import { createContextLogger } from '../utils/logger.js' import { createContextLogger } from '../utils/logger.js'
import { resolveID } from '../utils/helpers.js'
const Emails: CollectionConfig = { const Emails: CollectionConfig = {
slug: 'emails', slug: 'emails',
@@ -10,6 +11,26 @@ const Emails: CollectionConfig = {
group: 'Mailing', group: 'Mailing',
description: 'Email delivery and status tracking', description: 'Email delivery and status tracking',
}, },
defaultPopulate: {
templateSlug: true,
to: true,
cc: true,
bcc: true,
from: true,
replyTo: true,
jobs: true,
status: true,
attempts: true,
lastAttemptAt: true,
error: true,
priority: true,
scheduledAt: true,
sentAt: true,
variables: true,
html: true,
text: true,
createdAt: true,
},
fields: [ fields: [
{ {
name: 'template', name: 'template',
@@ -19,6 +40,14 @@ const Emails: CollectionConfig = {
description: 'Email template used (optional if custom content provided)', description: 'Email template used (optional if custom content provided)',
}, },
}, },
{
name: 'templateSlug',
type: 'text',
admin: {
description: 'Slug of the email template (auto-populated from template relationship)',
readOnly: true,
},
},
{ {
name: 'to', name: 'to',
type: 'text', type: 'text',
@@ -177,15 +206,37 @@ const Emails: CollectionConfig = {
readOnly: true, readOnly: true,
}, },
filterOptions: ({ id }) => { filterOptions: ({ id }) => {
const emailId = resolveID(id)
return { return {
'input.emailId': { 'input.emailId': {
equals: id, equals: emailId ? String(emailId) : '',
}, },
} }
}, },
}, },
], ],
hooks: { hooks: {
beforeChange: [
async ({ data, req }) => {
// Auto-populate templateSlug from template relationship
if (data.template) {
try {
const template = await req.payload.findByID({
collection: 'email-templates',
id: typeof data.template === 'string' ? data.template : data.template.id,
})
data.templateSlug = template.slug
} catch (error) {
// If template lookup fails, clear the slug
data.templateSlug = undefined
}
} else {
// Clear templateSlug if template is removed
data.templateSlug = undefined
}
return data
}
],
// Simple approach: Only use afterChange hook for job management // Simple approach: Only use afterChange hook for job management
// This avoids complex interaction between hooks and ensures document ID is always available // This avoids complex interaction between hooks and ensures document ID is always available
afterChange: [ afterChange: [

View File

@@ -1,16 +1,11 @@
import { processEmailsJob } from './processEmailsTask.js'
import { processEmailJob } from './processEmailJob.js' import { processEmailJob } from './processEmailJob.js'
/** /**
* All mailing-related jobs that get registered with Payload * All mailing-related jobs that get registered with Payload
*
* Note: The sendEmailJob has been removed as each email now gets its own individual processEmailJob
*/ */
export const mailingJobs = [ export const mailingJobs = [
processEmailsJob, // Kept for backward compatibility and batch processing if needed processEmailJob,
processEmailJob, // New individual email processing job
] ]
// Re-export everything from individual job files // Re-export everything from individual job files
export * from './processEmailsTask.js'
export * from './processEmailJob.js' export * from './processEmailJob.js'

View File

@@ -13,7 +13,6 @@ export interface ProcessEmailJobInput {
/** /**
* Job definition for processing a single email * Job definition for processing a single email
* This replaces the batch processing approach with individual email jobs
*/ */
export const processEmailJob = { export const processEmailJob = {
slug: 'process-email', slug: 'process-email',

View File

@@ -1,87 +0,0 @@
import type { PayloadRequest, Payload } from 'payload'
import { processAllEmails } from '../utils/emailProcessor.js'
import { createContextLogger } from '../utils/logger.js'
/**
* Data passed to the process emails task
*/
export interface ProcessEmailsTaskData {
// Currently no data needed - always processes both pending and failed emails
}
/**
* Handler function for processing emails
* Used internally by the task definition
*/
export const processEmailsTaskHandler = async (
job: { data: ProcessEmailsTaskData },
context: { req: PayloadRequest }
) => {
const { req } = context
const payload = (req as any).payload
// Use the shared email processing logic
await processAllEmails(payload)
}
/**
* Task definition for processing emails
* This is what gets registered with Payload's job system
*/
export const processEmailsTask = {
slug: 'process-emails',
handler: async ({ job, req }: { job: any; req: any }) => {
// Get mailing context from payload
const payload = (req as any).payload
const mailingContext = payload.mailing
if (!mailingContext) {
throw new Error('Mailing plugin not properly initialized')
}
// Use the task handler
await processEmailsTaskHandler(
job as { data: ProcessEmailsTaskData },
{ req }
)
return {
output: {
success: true,
message: 'Email queue processing completed successfully'
}
}
},
interfaceName: 'ProcessEmailsTask',
}
// For backward compatibility, export as processEmailsJob
export const processEmailsJob = processEmailsTask
/**
* Helper function to schedule an email processing job
* Used by the plugin during initialization and can be used by developers
*/
export const scheduleEmailsJob = async (
payload: Payload,
queueName: string,
delay?: number
) => {
if (!payload.jobs) {
const logger = createContextLogger(payload, 'SCHEDULER')
logger.warn('PayloadCMS jobs not configured - emails will not be processed automatically')
return
}
try {
await payload.jobs.queue({
queue: queueName,
task: 'process-emails',
input: {},
waitUntil: delay ? new Date(Date.now() + delay) : undefined,
} as any)
} catch (error) {
const logger = createContextLogger(payload, 'SCHEDULER')
logger.error('Failed to schedule email processing job:', error)
}
}

View File

@@ -1,8 +1,9 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import { getMailing, renderTemplate, parseAndValidateEmails, sanitizeFromName } from './utils/helpers.js' import { getMailing, renderTemplateWithId, parseAndValidateEmails, sanitizeFromName } from './utils/helpers.js'
import { BaseEmailDocument } from './types/index.js' import { BaseEmailDocument } from './types/index.js'
import { processJobById } from './utils/emailProcessor.js' import { processJobById } from './utils/emailProcessor.js'
import { createContextLogger } from './utils/logger.js' import { createContextLogger } from './utils/logger.js'
import { pollForJobId } from './utils/jobPolling.js'
// Options for sending emails // Options for sending emails
export interface SendEmailOptions<T extends BaseEmailDocument = BaseEmailDocument> { export interface SendEmailOptions<T extends BaseEmailDocument = BaseEmailDocument> {
@@ -48,17 +49,17 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail> let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail>
// If using a template, render it first
if (options.template) { if (options.template) {
const { html, text, subject } = await renderTemplate( // Look up and render the template in a single operation to avoid duplicate lookups
const { html, text, subject, templateId } = await renderTemplateWithId(
payload, payload,
options.template.slug, options.template.slug,
options.template.variables || {} options.template.variables || {}
) )
// Template values take precedence over data values
emailData = { emailData = {
...emailData, ...emailData,
template: templateId,
subject, subject,
html, html,
text, text,
@@ -70,20 +71,16 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
throw new Error('Field "to" is required for sending emails') throw new Error('Field "to" is required for sending emails')
} }
// Validate required fields based on whether template was used
if (options.template) { if (options.template) {
// When using template, subject and html should have been set by renderTemplate
if (!emailData.subject || !emailData.html) { if (!emailData.subject || !emailData.html) {
throw new Error(`Template rendering failed: template "${options.template.slug}" did not provide required subject and html content`) throw new Error(`Template rendering failed: template "${options.template.slug}" did not provide required subject and html content`)
} }
} else { } else {
// When not using template, user must provide subject and html directly
if (!emailData.subject || !emailData.html) { if (!emailData.subject || !emailData.html) {
throw new Error('Fields "subject" and "html" are required when sending direct emails without a template') throw new Error('Fields "subject" and "html" are required when sending direct emails without a template')
} }
} }
// Process email addresses using shared validation (handle null values)
if (emailData.to) { if (emailData.to) {
emailData.to = parseAndValidateEmails(emailData.to as string | string[]) emailData.to = parseAndValidateEmails(emailData.to as string | string[])
} }
@@ -95,19 +92,15 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
} }
if (emailData.replyTo) { if (emailData.replyTo) {
const validated = parseAndValidateEmails(emailData.replyTo as string | string[]) const validated = parseAndValidateEmails(emailData.replyTo as string | string[])
// replyTo should be a single email, so take the first one if array
emailData.replyTo = validated && validated.length > 0 ? validated[0] : undefined emailData.replyTo = validated && validated.length > 0 ? validated[0] : undefined
} }
if (emailData.from) { if (emailData.from) {
const validated = parseAndValidateEmails(emailData.from as string | string[]) const validated = parseAndValidateEmails(emailData.from as string | string[])
// from should be a single email, so take the first one if array
emailData.from = validated && validated.length > 0 ? validated[0] : undefined emailData.from = validated && validated.length > 0 ? validated[0] : undefined
} }
// Sanitize fromName to prevent header injection
emailData.fromName = sanitizeFromName(emailData.fromName as string) emailData.fromName = sanitizeFromName(emailData.fromName as string)
// Normalize Date objects to ISO strings for consistent database storage
if (emailData.scheduledAt instanceof Date) { if (emailData.scheduledAt instanceof Date) {
emailData.scheduledAt = emailData.scheduledAt.toISOString() emailData.scheduledAt = emailData.scheduledAt.toISOString()
} }
@@ -124,98 +117,34 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
emailData.updatedAt = emailData.updatedAt.toISOString() emailData.updatedAt = emailData.updatedAt.toISOString()
} }
// Create the email in the collection with proper typing
// The hooks will automatically create and populate the job relationship
const email = await payload.create({ const email = await payload.create({
collection: collectionSlug, collection: collectionSlug,
data: emailData data: emailData
}) })
// Validate that the created email has the expected structure
if (!email || typeof email !== 'object' || !email.id) { if (!email || typeof email !== 'object' || !email.id) {
throw new Error('Failed to create email: invalid response from database') throw new Error('Failed to create email: invalid response from database')
} }
// If processImmediately is true, get the job from the relationship and process it now
if (options.processImmediately) { if (options.processImmediately) {
const logger = createContextLogger(payload, 'IMMEDIATE') const logger = createContextLogger(payload, 'IMMEDIATE')
logger.debug(`Starting immediate processing for email ${email.id}`)
if (!payload.jobs) { if (!payload.jobs) {
throw new Error('PayloadCMS jobs not configured - cannot process email immediately') throw new Error('PayloadCMS jobs not configured - cannot process email immediately')
} }
// Poll for the job with optimized backoff and timeout protection // Poll for the job ID using configurable polling mechanism
// This handles the async nature of hooks and ensures we wait for job creation const { jobId } = await pollForJobId({
const maxAttempts = 5 // Reduced from 10 to minimize delay payload,
const initialDelay = 25 // Reduced from 50ms for faster response collectionSlug,
const maxTotalTime = 3000 // 3 second total timeout emailId: email.id,
const startTime = Date.now() config: mailingConfig.jobPolling,
let jobId: string | undefined logger,
logger.debug(`Polling for job creation (max ${maxAttempts} attempts, ${maxTotalTime}ms timeout)`)
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Check total timeout before continuing
if (Date.now() - startTime > maxTotalTime) {
throw new Error(
`Job polling timed out after ${maxTotalTime}ms for email ${email.id}. ` +
`The auto-scheduling may have failed or is taking longer than expected.`
)
}
// Calculate delay with exponential backoff (25ms, 50ms, 100ms, 200ms, 400ms)
// Cap at 400ms per attempt for better responsiveness
const delay = Math.min(initialDelay * Math.pow(2, attempt), 400)
if (attempt > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}
// Refetch the email to check for jobs
const emailWithJobs = await payload.findByID({
collection: collectionSlug,
id: email.id,
}) })
logger.debug(`Attempt ${attempt + 1}/${maxAttempts}: Found ${emailWithJobs.jobs?.length || 0} jobs for email ${email.id}`)
if (emailWithJobs.jobs && emailWithJobs.jobs.length > 0) {
// Job found! Get the first job ID (should only be one for a new email)
const firstJob = Array.isArray(emailWithJobs.jobs) ? emailWithJobs.jobs[0] : emailWithJobs.jobs
jobId = typeof firstJob === 'string' ? firstJob : String(firstJob.id || firstJob)
logger.info(`Found job ID: ${jobId}`)
break
}
// Log on later attempts to help with debugging (reduced threshold)
if (attempt >= 1) {
logger.debug(`Waiting for job creation for email ${email.id}, attempt ${attempt + 1}/${maxAttempts}`)
}
}
if (!jobId) {
// Distinguish between different failure scenarios for better error handling
const timeoutMsg = Date.now() - startTime >= maxTotalTime
const errorType = timeoutMsg ? 'POLLING_TIMEOUT' : 'JOB_NOT_FOUND'
const baseMessage = timeoutMsg
? `Job polling timed out after ${maxTotalTime}ms for email ${email.id}`
: `No processing job found for email ${email.id} after ${maxAttempts} attempts (${Date.now() - startTime}ms)`
throw new Error(
`${errorType}: ${baseMessage}. ` +
`This indicates the email was created but job auto-scheduling failed. ` +
`The email exists in the database but immediate processing cannot proceed. ` +
`You may need to: 1) Check job queue configuration, 2) Verify database hooks are working, ` +
`3) Process the email later using processEmailById('${email.id}').`
)
}
logger.info(`Starting job execution for job ${jobId}`)
try { try {
await processJobById(payload, jobId) await processJobById(payload, jobId)
logger.info(`Successfully processed email ${email.id} immediately`) logger.debug(`Successfully processed email ${email.id} immediately`)
} catch (error) { } catch (error) {
logger.error(`Failed to process email ${email.id} immediately:`, error) logger.error(`Failed to process email ${email.id} immediately:`, error)
throw new Error(`Failed to process email ${email.id} immediately: ${String(error)}`) throw new Error(`Failed to process email ${email.id} immediately: ${String(error)}`)

View File

@@ -127,6 +127,17 @@ export class MailingService implements IMailingService {
throw new Error(`Email template not found: ${templateSlug}`) throw new Error(`Email template not found: ${templateSlug}`)
} }
return this.renderTemplateDocument(template, variables)
}
/**
* Render a template document (for when you already have the template loaded)
* This avoids duplicate template lookups
* @internal
*/
async renderTemplateDocument(template: BaseEmailTemplateDocument, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> {
this.ensureInitialized()
const emailContent = await this.renderEmailTemplate(template, variables) const emailContent = await this.renderEmailTemplate(template, variables)
const subject = await this.renderTemplateString(template.subject || '', variables) const subject = await this.renderTemplateString(template.subject || '', variables)
@@ -233,6 +244,7 @@ export class MailingService implements IMailingService {
const email = await this.payload.findByID({ const email = await this.payload.findByID({
collection: this.emailsCollection as any, collection: this.emailsCollection as any,
id: emailId, id: emailId,
depth: 1,
}) as BaseEmailDocument }) as BaseEmailDocument
// Combine from and fromName for nodemailer using proper sanitization // Combine from and fromName for nodemailer using proper sanitization
@@ -366,7 +378,7 @@ export class MailingService implements IMailingService {
if (engine === 'liquidjs') { if (engine === 'liquidjs') {
try { try {
await this.ensureLiquidJSInitialized() await this.ensureLiquidJSInitialized()
if (this.liquid && typeof this.liquid !== 'boolean') { if (this.liquid) {
return await this.liquid.parseAndRender(template, variables) return await this.liquid.parseAndRender(template, variables)
} }
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,12 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import type { CollectionConfig, RichTextField } from 'payload' import type { CollectionConfig, RichTextField } from 'payload'
// Payload ID type (string or number)
export type PayloadID = string | number
// Payload relation type - can be populated (object with id) or unpopulated (just the ID)
export type PayloadRelation<T extends { id: PayloadID }> = T | PayloadID
// JSON value type that matches Payload's JSON field type // JSON value type that matches Payload's JSON field type
export type JSONValue = string | number | boolean | { [k: string]: unknown } | unknown[] | null | undefined export type JSONValue = string | number | boolean | { [k: string]: unknown } | unknown[] | null | undefined
@@ -8,6 +14,7 @@ export type JSONValue = string | number | boolean | { [k: string]: unknown } | u
export interface BaseEmailDocument { export interface BaseEmailDocument {
id: string | number id: string | number
template?: any template?: any
templateSlug?: string | null
to: string[] to: string[]
cc?: string[] | null cc?: string[] | null
bcc?: string[] | null bcc?: string[] | null
@@ -62,6 +69,13 @@ export interface BeforeSendMailOptions {
export type BeforeSendHook = (options: BeforeSendMailOptions, email: BaseEmailDocument) => BeforeSendMailOptions | Promise<BeforeSendMailOptions> export type BeforeSendHook = (options: BeforeSendMailOptions, email: BaseEmailDocument) => BeforeSendMailOptions | Promise<BeforeSendMailOptions>
export interface JobPollingConfig {
maxAttempts?: number // Maximum number of polling attempts (default: 5)
initialDelay?: number // Initial delay in milliseconds (default: 25)
maxTotalTime?: number // Maximum total polling time in milliseconds (default: 3000)
maxBackoffDelay?: number // Maximum delay between attempts in milliseconds (default: 400)
}
export interface MailingPluginConfig { export interface MailingPluginConfig {
collections?: { collections?: {
templates?: string | Partial<CollectionConfig> templates?: string | Partial<CollectionConfig>
@@ -77,6 +91,7 @@ export interface MailingPluginConfig {
richTextEditor?: RichTextField['editor'] richTextEditor?: RichTextField['editor']
beforeSend?: BeforeSendHook beforeSend?: BeforeSendHook
initOrder?: 'before' | 'after' initOrder?: 'before' | 'after'
jobPolling?: JobPollingConfig
} }
export interface QueuedEmail { export interface QueuedEmail {

View File

@@ -37,16 +37,11 @@ export async function processEmailById(payload: Payload, emailId: string): Promi
* @returns Promise that resolves when job is processed * @returns Promise that resolves when job is processed
*/ */
export async function processJobById(payload: Payload, jobId: string): Promise<void> { export async function processJobById(payload: Payload, jobId: string): Promise<void> {
const logger = createContextLogger(payload, 'PROCESSOR')
logger.debug(`Starting processJobById for job ${jobId}`)
if (!payload.jobs) { if (!payload.jobs) {
throw new Error('PayloadCMS jobs not configured - cannot process job immediately') throw new Error('PayloadCMS jobs not configured - cannot process job immediately')
} }
try { try {
logger.debug(`Running job ${jobId} with payload.jobs.run()`)
// Run a specific job by its ID (using where clause to find the job) // Run a specific job by its ID (using where clause to find the job)
const result = await payload.jobs.run({ const result = await payload.jobs.run({
where: { where: {
@@ -55,9 +50,8 @@ export async function processJobById(payload: Payload, jobId: string): Promise<v
} }
} }
}) })
logger.info(`Job ${jobId} execution completed`, { result })
} catch (error) { } catch (error) {
const logger = createContextLogger(payload, 'PROCESSOR')
logger.error(`Job ${jobId} execution failed:`, error) logger.error(`Job ${jobId} execution failed:`, error)
throw new Error(`Failed to process job ${jobId}: ${String(error)}`) throw new Error(`Failed to process job ${jobId}: ${String(error)}`)
} }

View File

@@ -1,5 +1,5 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import { TemplateVariables } from '../types/index.js' import { TemplateVariables, PayloadID, PayloadRelation } from '../types/index.js'
/** /**
* Parse and validate email addresses * Parse and validate email addresses
@@ -74,6 +74,49 @@ export const sanitizeFromName = (fromName: string | null | undefined): string |
return sanitized.length > 0 ? sanitized : undefined return sanitized.length > 0 ? sanitized : undefined
} }
/**
* Type guard to check if a Payload relation is populated (object) or unpopulated (ID)
*/
export const isPopulated = <T extends { id: PayloadID }>(
value: PayloadRelation<T> | null | undefined
): value is T => {
return value !== null && value !== undefined && typeof value === 'object' && 'id' in value
}
/**
* Resolves a Payload relation to just the ID
* Handles both populated (object with id) and unpopulated (string/number) values
*/
export const resolveID = <T extends { id: PayloadID }>(
value: PayloadRelation<T> | null | undefined
): PayloadID | undefined => {
if (value === null || value === undefined) return undefined
if (typeof value === 'string' || typeof value === 'number') {
return value
}
if (typeof value === 'object' && 'id' in value) {
return value.id
}
return undefined
}
/**
* Resolves an array of Payload relations to an array of IDs
* Handles mixed arrays of populated and unpopulated values
*/
export const resolveIDs = <T extends { id: PayloadID }>(
values: (PayloadRelation<T> | null | undefined)[] | null | undefined
): PayloadID[] => {
if (!values || !Array.isArray(values)) return []
return values
.map(value => resolveID(value))
.filter((id): id is PayloadID => id !== undefined)
}
export const getMailing = (payload: Payload) => { export const getMailing = (payload: Payload) => {
const mailing = (payload as any).mailing const mailing = (payload as any).mailing
if (!mailing) { if (!mailing) {
@@ -87,6 +130,53 @@ export const renderTemplate = async (payload: Payload, templateSlug: string, var
return mailing.service.renderTemplate(templateSlug, variables) return mailing.service.renderTemplate(templateSlug, variables)
} }
/**
* Render a template and return both rendered content and template ID
* This is used by sendEmail to avoid duplicate template lookups
* @internal
*/
export const renderTemplateWithId = async (
payload: Payload,
templateSlug: string,
variables: TemplateVariables
): Promise<{ html: string; text: string; subject: string; templateId: PayloadID }> => {
const mailing = getMailing(payload)
const templatesCollection = mailing.config.collections?.templates || 'email-templates'
// Runtime validation: Ensure the collection exists in Payload
if (!payload.collections[templatesCollection]) {
throw new Error(
`Templates collection '${templatesCollection}' not found. ` +
`Available collections: ${Object.keys(payload.collections).join(', ')}`
)
}
// Look up the template document once
const { docs: templateDocs } = await payload.find({
collection: templatesCollection as any,
where: {
slug: {
equals: templateSlug,
},
},
limit: 1,
})
if (!templateDocs || templateDocs.length === 0) {
throw new Error(`Template not found: ${templateSlug}`)
}
const templateDoc = templateDocs[0]
// Render using the document directly to avoid duplicate lookup
const rendered = await mailing.service.renderTemplateDocument(templateDoc, variables)
return {
...rendered,
templateId: templateDoc.id,
}
}
export const processEmails = async (payload: Payload): Promise<void> => { export const processEmails = async (payload: Payload): Promise<void> => {
const mailing = getMailing(payload) const mailing = getMailing(payload)
return mailing.service.processEmails() return mailing.service.processEmails()

115
src/utils/jobPolling.ts Normal file
View File

@@ -0,0 +1,115 @@
import { Payload } from 'payload'
import { JobPollingConfig } from '../types/index.js'
export interface PollForJobIdOptions {
payload: Payload
collectionSlug: string
emailId: string | number
config?: JobPollingConfig
logger?: {
debug: (message: string, ...args: any[]) => void
info: (message: string, ...args: any[]) => void
warn: (message: string, ...args: any[]) => void
error: (message: string, ...args: any[]) => void
}
}
export interface PollForJobIdResult {
jobId: string
attempts: number
elapsedTime: number
}
// Default job polling configuration values
const DEFAULT_JOB_POLLING_CONFIG: Required<JobPollingConfig> = {
maxAttempts: 5,
initialDelay: 25,
maxTotalTime: 3000,
maxBackoffDelay: 400,
}
/**
* Polls for a job ID associated with an email document using exponential backoff.
* This utility handles the complexity of waiting for auto-scheduled jobs to be created.
*
* The polling mechanism uses exponential backoff with configurable parameters:
* - Starts with an initial delay and doubles on each retry
* - Caps individual delays at maxBackoffDelay
* - Enforces a maximum total polling time
*
* @param options - Polling options including payload, collection, email ID, and config
* @returns Promise resolving to job ID and timing information
* @throws Error if job is not found within the configured limits
*/
export const pollForJobId = async (options: PollForJobIdOptions): Promise<PollForJobIdResult> => {
const { payload, collectionSlug, emailId, logger } = options
// Merge user config with defaults
const config: Required<JobPollingConfig> = {
...DEFAULT_JOB_POLLING_CONFIG,
...options.config,
}
const { maxAttempts, initialDelay, maxTotalTime, maxBackoffDelay } = config
const startTime = Date.now()
let jobId: string | undefined
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const elapsedTime = Date.now() - startTime
// Check if we've exceeded the maximum total polling time
if (elapsedTime > maxTotalTime) {
throw new Error(
`Job polling timed out after ${maxTotalTime}ms for email ${emailId}. ` +
`The auto-scheduling may have failed or is taking longer than expected.`
)
}
// Calculate exponential backoff delay, capped at maxBackoffDelay
const delay = Math.min(initialDelay * Math.pow(2, attempt), maxBackoffDelay)
// Wait before checking (skip on first attempt)
if (attempt > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}
// Fetch the email document to check for associated jobs
const emailWithJobs = await payload.findByID({
collection: collectionSlug,
id: emailId,
})
// Check if jobs array exists and has entries
if (emailWithJobs.jobs && emailWithJobs.jobs.length > 0) {
const firstJob = Array.isArray(emailWithJobs.jobs) ? emailWithJobs.jobs[0] : emailWithJobs.jobs
jobId = typeof firstJob === 'string' ? firstJob : String(firstJob.id || firstJob)
return {
jobId,
attempts: attempt + 1,
elapsedTime: Date.now() - startTime,
}
}
// Log progress for attempts after the second try
if (attempt >= 2 && logger) {
logger.debug(`Waiting for job creation for email ${emailId}, attempt ${attempt + 1}/${maxAttempts}`)
}
}
// If we reach here, job was not found
const elapsedTime = Date.now() - startTime
const timeoutMsg = elapsedTime >= maxTotalTime
const errorType = timeoutMsg ? 'POLLING_TIMEOUT' : 'JOB_NOT_FOUND'
const baseMessage = timeoutMsg
? `Job polling timed out after ${maxTotalTime}ms for email ${emailId}`
: `No processing job found for email ${emailId} after ${maxAttempts} attempts (${elapsedTime}ms)`
throw new Error(
`${errorType}: ${baseMessage}. ` +
`This indicates the email was created but job auto-scheduling failed. ` +
`The email exists in the database but immediate processing cannot proceed. ` +
`You may need to: 1) Check job queue configuration, 2) Verify database hooks are working, ` +
`3) Process the email later using processEmailById('${emailId}').`
)
}

View File

@@ -49,15 +49,12 @@ export async function ensureEmailJob(
const queueName = options?.queueName || mailingContext?.config?.queue || 'default' const queueName = options?.queueName || mailingContext?.config?.queue || 'default'
const logger = createContextLogger(payload, 'JOB_SCHEDULER') const logger = createContextLogger(payload, 'JOB_SCHEDULER')
logger.debug(`Ensuring job for email ${normalizedEmailId}`)
logger.debug(`Queue: ${queueName}, scheduledAt: ${options?.scheduledAt || 'immediate'}`)
// First, optimistically try to create the job // First, optimistically try to create the job
// If it fails due to uniqueness constraint, then check for existing jobs // If it fails due to uniqueness constraint, then check for existing jobs
// This approach minimizes the race condition window // This approach minimizes the race condition window
try { try {
logger.debug(`Attempting to create new job for email ${normalizedEmailId}`)
// Attempt to create job - rely on database constraints for duplicate prevention // Attempt to create job - rely on database constraints for duplicate prevention
const job = await payload.jobs.queue({ const job = await payload.jobs.queue({
queue: queueName, queue: queueName,
@@ -69,31 +66,22 @@ export async function ensureEmailJob(
}) })
logger.info(`Auto-scheduled processing job ${job.id} for email ${normalizedEmailId}`) logger.info(`Auto-scheduled processing job ${job.id} for email ${normalizedEmailId}`)
logger.debug(`Job details`, {
jobId: job.id,
emailId: normalizedEmailId,
scheduledAt: options?.scheduledAt || 'immediate',
task: 'process-email',
queue: queueName
})
return { return {
jobIds: [job.id], jobIds: [job.id],
created: true created: true
} }
} catch (createError) { } catch (createError) {
logger.warn(`Job creation failed for email ${normalizedEmailId}: ${String(createError)}`)
// Job creation failed - likely due to duplicate constraint or system issue // Job creation failed - likely due to duplicate constraint or system issue
// Check if duplicate jobs exist (handles race condition where another process created job) // Check if duplicate jobs exist (handles race condition where another process created job)
const existingJobs = await findExistingJobs(payload, normalizedEmailId) const existingJobs = await findExistingJobs(payload, normalizedEmailId)
logger.debug(`Found ${existingJobs.totalDocs} existing jobs after creation failure`)
if (existingJobs.totalDocs > 0) { if (existingJobs.totalDocs > 0) {
// Found existing jobs - return them (race condition handled successfully) // Found existing jobs - return them (race condition handled successfully)
logger.info(`Using existing jobs for email ${normalizedEmailId}: ${existingJobs.docs.map(j => j.id).join(', ')}`) logger.debug(`Using existing jobs for email ${normalizedEmailId}: ${existingJobs.docs.map(j => j.id).join(', ')}`)
return { return {
jobIds: existingJobs.docs.map(job => job.id), jobIds: existingJobs.docs.map(job => job.id),
created: false created: false
@@ -109,7 +97,7 @@ export async function ensureEmailJob(
if (isLikelyUniqueConstraint) { if (isLikelyUniqueConstraint) {
// This should not happen if our check above worked, but provide a clear error // This should not happen if our check above worked, but provide a clear error
logger.error(`Unique constraint violation but no existing jobs found for email ${normalizedEmailId}`) logger.warn(`Unique constraint violation but no existing jobs found for email ${normalizedEmailId}`)
throw new Error( throw new Error(
`Database uniqueness constraint violation for email ${normalizedEmailId}, but no existing jobs found. ` + `Database uniqueness constraint violation for email ${normalizedEmailId}, but no existing jobs found. ` +
`This indicates a potential data consistency issue. Original error: ${errorMessage}` `This indicates a potential data consistency issue. Original error: ${errorMessage}`
@@ -117,7 +105,7 @@ export async function ensureEmailJob(
} }
// Non-constraint related error // Non-constraint related error
logger.error(`Non-constraint job creation error for email ${normalizedEmailId}: ${errorMessage}`) logger.error(`Job creation error for email ${normalizedEmailId}: ${errorMessage}`)
throw new Error(`Failed to create job for email ${normalizedEmailId}: ${errorMessage}`) throw new Error(`Failed to create job for email ${normalizedEmailId}: ${errorMessage}`)
} }
} }
@@ -141,7 +129,11 @@ export async function updateEmailJobRelationship(
id: normalizedEmailId, id: normalizedEmailId,
}) })
const currentJobs = (currentEmail.jobs || []).map((job: any) => String(job)) // Extract IDs from job objects or use the value directly if it's already an ID
// Jobs can be populated (objects with id field) or just IDs (strings/numbers)
const currentJobs = (currentEmail.jobs || []).map((job: any) =>
typeof job === 'object' && job !== null && job.id ? String(job.id) : String(job)
)
const allJobs = [...new Set([...currentJobs, ...normalizedJobIds])] // Deduplicate with normalized strings const allJobs = [...new Set([...currentJobs, ...normalizedJobIds])] // Deduplicate with normalized strings
await payload.update({ await payload.update({