Compare commits

..

24 Commits

Author SHA1 Message Date
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
Bas
2c0f202518 Merge pull request #58 from xtr-dev/dev
Dev
2025-09-20 20:18:56 +02:00
3f177cfeb5 Bump version to 0.4.11 2025-09-20 20:11:00 +02:00
e364dd2c58 Fix job ID extraction in immediate processing
- Job relationship returns job objects, not just IDs
- Extract ID property from job object before passing to processJobById()
- This fixes the '[object Object]' issue in logs and ensures job execution works

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 20:10:35 +02:00
Bas
aa5a03b5b0 Merge pull request #57 from xtr-dev/dev
Fix TypeScript build error in jobScheduler.ts
2025-09-20 19:04:46 +02:00
8ee3ff5a7d Fix TypeScript build error in jobScheduler.ts
- Use static values for task and queue in logging instead of accessing job properties
- Properties 'task' and 'queue' don't exist on BaseJob type

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 19:02:38 +02:00
Bas
2220d83288 Merge pull request #56 from xtr-dev/dev
Dev
2025-09-20 19:01:01 +02:00
2f46dde532 Add configurable logger with PAYLOAD_MAILING_LOG_LEVEL support
- Created centralized logger utility using Payload's built-in logger system
- Added PAYLOAD_MAILING_LOG_LEVEL environment variable for log level configuration
- Replaced all console.log/error/warn calls with structured logger
- Added debug logging for immediate processing flow to help troubleshoot issues
- Improved logging context with specific prefixes (IMMEDIATE, PROCESSOR, JOB_SCHEDULER, etc.)
- Bumped version to 0.4.10

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 18:57:18 +02:00
02a9334bf4 Add npm version badge to README for improved visibility 2025-09-19 09:59:28 +02:00
15 changed files with 375 additions and 302 deletions

View File

@@ -1,5 +1,7 @@
# @xtr-dev/payload-mailing
[![npm version](https://img.shields.io/npm/v/@xtr-dev/payload-mailing.svg)](https://www.npmjs.com/package/@xtr-dev/payload-mailing)
A template-based email system with scheduling and job processing for PayloadCMS 3.x.
⚠️ **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.

View File

@@ -123,123 +123,6 @@ export default buildConfig({
retryDelay: 60000, // 1 minute for dev
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
// Comment out to use default lexical editor
richTextEditor: lexicalEditor({
@@ -256,12 +139,6 @@ export default buildConfig({
// etc.
],
}),
// Called after mailing plugin is fully initialized
onReady: async (payload) => {
await seedUser(payload)
},
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',

View File

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

View File

@@ -1,5 +1,7 @@
import type { CollectionConfig } from 'payload'
import { findExistingJobs, ensureEmailJob, updateEmailJobRelationship } from '../utils/jobScheduler.js'
import { createContextLogger } from '../utils/logger.js'
import { resolveID } from '../utils/helpers.js'
const Emails: CollectionConfig = {
slug: 'emails',
@@ -9,6 +11,26 @@ const Emails: CollectionConfig = {
group: 'Mailing',
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: [
{
name: 'template',
@@ -18,6 +40,14 @@ const Emails: CollectionConfig = {
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',
type: 'text',
@@ -176,15 +206,37 @@ const Emails: CollectionConfig = {
readOnly: true,
},
filterOptions: ({ id }) => {
const emailId = resolveID({ id })
return {
'input.emailId': {
equals: id,
equals: emailId ? String(emailId) : '',
},
}
},
},
],
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
// This avoids complex interaction between hooks and ensures document ID is always available
afterChange: [
@@ -220,7 +272,8 @@ const Emails: CollectionConfig = {
}
} catch (error) {
// Log error but don't throw - we don't want to fail the email operation
console.error(`Failed to ensure job for email ${doc.id}:`, error)
const logger = createContextLogger(req.payload, 'EMAILS_HOOK')
logger.error(`Failed to ensure job for email ${doc.id}:`, error)
}
}
]

View File

@@ -1,16 +1,11 @@
import { processEmailsJob } from './processEmailsTask.js'
import { processEmailJob } from './processEmailJob.js'
/**
* 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 = [
processEmailsJob, // Kept for backward compatibility and batch processing if needed
processEmailJob, // New individual email processing job
processEmailJob,
]
// Re-export everything from individual job files
export * from './processEmailsTask.js'
export * from './processEmailJob.js'

View File

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

View File

@@ -1,84 +0,0 @@
import type { PayloadRequest, Payload } from 'payload'
import { processAllEmails } from '../utils/emailProcessor.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) {
console.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) {
console.error('Failed to schedule email processing job:', error)
}
}

View File

@@ -1,7 +1,9 @@
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 { processJobById } from './utils/emailProcessor.js'
import { createContextLogger } from './utils/logger.js'
import { pollForJobId } from './utils/jobPolling.js'
// Options for sending emails
export interface SendEmailOptions<T extends BaseEmailDocument = BaseEmailDocument> {
@@ -47,17 +49,17 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail>
// If using a template, render it first
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,
options.template.slug,
options.template.variables || {}
)
// Template values take precedence over data values
emailData = {
...emailData,
template: templateId,
subject,
html,
text,
@@ -69,20 +71,16 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
throw new Error('Field "to" is required for sending emails')
}
// Validate required fields based on whether template was used
if (options.template) {
// When using template, subject and html should have been set by renderTemplate
if (!emailData.subject || !emailData.html) {
throw new Error(`Template rendering failed: template "${options.template.slug}" did not provide required subject and html content`)
}
} else {
// When not using template, user must provide subject and html directly
if (!emailData.subject || !emailData.html) {
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) {
emailData.to = parseAndValidateEmails(emailData.to as string | string[])
}
@@ -94,19 +92,15 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
}
if (emailData.replyTo) {
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
}
if (emailData.from) {
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
}
// Sanitize fromName to prevent header injection
emailData.fromName = sanitizeFromName(emailData.fromName as string)
// Normalize Date objects to ISO strings for consistent database storage
if (emailData.scheduledAt instanceof Date) {
emailData.scheduledAt = emailData.scheduledAt.toISOString()
}
@@ -123,90 +117,36 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
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({
collection: collectionSlug,
data: emailData
})
// Validate that the created email has the expected structure
if (!email || typeof email !== 'object' || !email.id) {
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) {
const logger = createContextLogger(payload, 'IMMEDIATE')
if (!payload.jobs) {
throw new Error('PayloadCMS jobs not configured - cannot process email immediately')
}
// Poll for the job with optimized backoff and timeout protection
// This handles the async nature of hooks and ensures we wait for job creation
const maxAttempts = 5 // Reduced from 10 to minimize delay
const initialDelay = 25 // Reduced from 50ms for faster response
const maxTotalTime = 3000 // 3 second total timeout
const startTime = Date.now()
let jobId: string | undefined
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,
})
if (emailWithJobs.jobs && emailWithJobs.jobs.length > 0) {
// Job found! Get the first job ID (should only be one for a new email)
jobId = Array.isArray(emailWithJobs.jobs)
? String(emailWithJobs.jobs[0])
: String(emailWithJobs.jobs)
break
}
// Log on later attempts to help with debugging (reduced threshold)
if (attempt >= 2) {
console.log(`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}').`
)
}
// Poll for the job ID using configurable polling mechanism
const { jobId } = await pollForJobId({
payload,
collectionSlug,
emailId: email.id,
config: mailingConfig.jobPolling,
logger,
})
try {
await processJobById(payload, jobId)
logger.debug(`Successfully processed email ${email.id} immediately`)
} catch (error) {
logger.error(`Failed to process email ${email.id} immediately:`, 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}`)
}
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 subject = await this.renderTemplateString(template.subject || '', variables)
@@ -233,6 +244,7 @@ export class MailingService implements IMailingService {
const email = await this.payload.findByID({
collection: this.emailsCollection as any,
id: emailId,
depth: 1,
}) as BaseEmailDocument
// Combine from and fromName for nodemailer using proper sanitization
@@ -366,7 +378,7 @@ export class MailingService implements IMailingService {
if (engine === 'liquidjs') {
try {
await this.ensureLiquidJSInitialized()
if (this.liquid && typeof this.liquid !== 'boolean') {
if (this.liquid) {
return await this.liquid.parseAndRender(template, variables)
}
} catch (error) {

View File

@@ -1,6 +1,12 @@
import { Payload } 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
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 {
id: string | number
template?: any
templateSlug?: string | null
to: string[]
cc?: string[] | null
bcc?: string[] | null
@@ -62,6 +69,13 @@ export interface 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 {
collections?: {
templates?: string | Partial<CollectionConfig>
@@ -77,6 +91,7 @@ export interface MailingPluginConfig {
richTextEditor?: RichTextField['editor']
beforeSend?: BeforeSendHook
initOrder?: 'before' | 'after'
jobPolling?: JobPollingConfig
}
export interface QueuedEmail {

View File

@@ -1,4 +1,5 @@
import type { Payload } from 'payload'
import { createContextLogger } from './logger.js'
/**
* Processes a single email by ID using the mailing service
@@ -42,7 +43,7 @@ export async function processJobById(payload: Payload, jobId: string): Promise<v
try {
// Run a specific job by its ID (using where clause to find the job)
await payload.jobs.run({
const result = await payload.jobs.run({
where: {
id: {
equals: jobId
@@ -50,6 +51,8 @@ export async function processJobById(payload: Payload, jobId: string): Promise<v
}
})
} catch (error) {
const logger = createContextLogger(payload, 'PROCESSOR')
logger.error(`Job ${jobId} execution failed:`, error)
throw new Error(`Failed to process job ${jobId}: ${String(error)}`)
}
}

View File

@@ -1,5 +1,5 @@
import { Payload } from 'payload'
import { TemplateVariables } from '../types/index.js'
import { TemplateVariables, PayloadID, PayloadRelation } from '../types/index.js'
/**
* Parse and validate email addresses
@@ -74,6 +74,49 @@ export const sanitizeFromName = (fromName: string | null | undefined): string |
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) => {
const mailing = (payload as any).mailing
if (!mailing) {
@@ -87,6 +130,53 @@ export const renderTemplate = async (payload: Payload, templateSlug: string, var
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> => {
const mailing = getMailing(payload)
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

@@ -1,4 +1,5 @@
import type { Payload } from 'payload'
import { createContextLogger } from './logger.js'
/**
* Finds existing processing jobs for an email
@@ -47,6 +48,8 @@ export async function ensureEmailJob(
const mailingContext = (payload as any).mailing
const queueName = options?.queueName || mailingContext?.config?.queue || 'default'
const logger = createContextLogger(payload, 'JOB_SCHEDULER')
// First, optimistically try to create the job
// If it fails due to uniqueness constraint, then check for existing jobs
// This approach minimizes the race condition window
@@ -62,21 +65,23 @@ export async function ensureEmailJob(
waitUntil: options?.scheduledAt ? new Date(options.scheduledAt) : undefined
})
console.log(`Auto-scheduled processing job ${job.id} for email ${normalizedEmailId}`)
logger.info(`Auto-scheduled processing job ${job.id} for email ${normalizedEmailId}`)
return {
jobIds: [job.id],
created: true
}
} catch (createError) {
// Job creation failed - likely due to duplicate constraint or system issue
// Check if duplicate jobs exist (handles race condition where another process created job)
const existingJobs = await findExistingJobs(payload, normalizedEmailId)
if (existingJobs.totalDocs > 0) {
// Found existing jobs - return them (race condition handled successfully)
console.log(`Found 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 {
jobIds: existingJobs.docs.map(job => job.id),
created: false
@@ -92,6 +97,7 @@ export async function ensureEmailJob(
if (isLikelyUniqueConstraint) {
// This should not happen if our check above worked, but provide a clear error
logger.warn(`Unique constraint violation but no existing jobs found for email ${normalizedEmailId}`)
throw new Error(
`Database uniqueness constraint violation for email ${normalizedEmailId}, but no existing jobs found. ` +
`This indicates a potential data consistency issue. Original error: ${errorMessage}`
@@ -99,6 +105,7 @@ export async function ensureEmailJob(
}
// Non-constraint related error
logger.error(`Job creation error for email ${normalizedEmailId}: ${errorMessage}`)
throw new Error(`Failed to create job for email ${normalizedEmailId}: ${errorMessage}`)
}
}
@@ -134,7 +141,8 @@ export async function updateEmailJobRelationship(
})
} catch (error) {
const normalizedEmailId = String(emailId)
console.error(`Failed to update email ${normalizedEmailId} with job relationship:`, error)
const logger = createContextLogger(payload, 'JOB_SCHEDULER')
logger.error(`Failed to update email ${normalizedEmailId} with job relationship:`, error)
throw error
}
}

48
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { Payload } from 'payload'
let pluginLogger: any = null
/**
* Get or create the plugin logger instance
* Uses PAYLOAD_MAILING_LOG_LEVEL environment variable to configure log level
* Defaults to 'info' if not set
*/
export function getPluginLogger(payload: Payload) {
if (!pluginLogger && payload.logger) {
const logLevel = process.env.PAYLOAD_MAILING_LOG_LEVEL || 'info'
pluginLogger = payload.logger.child({
level: logLevel,
plugin: '@xtr-dev/payload-mailing'
})
// Log the configured log level on first initialization
pluginLogger.info(`Logger initialized with level: ${logLevel}`)
}
// Fallback to console if logger not available (shouldn't happen in normal operation)
if (!pluginLogger) {
return {
debug: (...args: any[]) => console.log('[MAILING DEBUG]', ...args),
info: (...args: any[]) => console.log('[MAILING INFO]', ...args),
warn: (...args: any[]) => console.warn('[MAILING WARN]', ...args),
error: (...args: any[]) => console.error('[MAILING ERROR]', ...args),
}
}
return pluginLogger
}
/**
* Create a context-specific logger for a particular operation
*/
export function createContextLogger(payload: Payload, context: string) {
const logger = getPluginLogger(payload)
return {
debug: (message: string, ...args: any[]) => logger.debug(`[${context}] ${message}`, ...args),
info: (message: string, ...args: any[]) => logger.info(`[${context}] ${message}`, ...args),
warn: (message: string, ...args: any[]) => logger.warn(`[${context}] ${message}`, ...args),
error: (message: string, ...args: any[]) => logger.error(`[${context}] ${message}`, ...args),
}
}