Compare commits

..

12 Commits

Author SHA1 Message Date
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
Bas
de1ae636de Merge pull request #55 from xtr-dev/dev
Dev
2025-09-14 23:36:58 +02:00
ae38653466 Expand and clarify README documentation:
- Provide detailed examples of email template structure and rendering.
- Add guidance on job scheduling and direct email sending use cases.
- Enhance troubleshooting section with common issues and solutions.
- Introduce bulk operations, email monitoring, and query examples.
- Update plugin configuration requirements and clarify environment variables.

Improves overall usability and onboarding for developers.
2025-09-14 23:33:14 +02:00
fe8c4d194e Bump version to 0.4.9 and add comprehensive plugin configuration details to README. 2025-09-14 23:30:14 +02:00
0198821ff3 Update README for improved clarity and reduced redundancy. 2025-09-14 23:22:46 +02:00
8 changed files with 564 additions and 601 deletions

1048
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-mailing", "name": "@xtr-dev/payload-mailing",
"version": "0.4.8", "version": "0.4.11",
"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,5 +1,6 @@
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'
const Emails: CollectionConfig = { const Emails: CollectionConfig = {
slug: 'emails', slug: 'emails',
@@ -220,7 +221,8 @@ const Emails: CollectionConfig = {
} }
} catch (error) { } catch (error) {
// Log error but don't throw - we don't want to fail the email operation // 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,5 +1,6 @@
import type { PayloadRequest, Payload } from 'payload' import type { PayloadRequest, Payload } from 'payload'
import { processAllEmails } from '../utils/emailProcessor.js' import { processAllEmails } from '../utils/emailProcessor.js'
import { createContextLogger } from '../utils/logger.js'
/** /**
* Data passed to the process emails task * Data passed to the process emails task
@@ -67,7 +68,8 @@ export const scheduleEmailsJob = async (
delay?: number delay?: number
) => { ) => {
if (!payload.jobs) { if (!payload.jobs) {
console.warn('PayloadCMS jobs not configured - emails will not be processed automatically') const logger = createContextLogger(payload, 'SCHEDULER')
logger.warn('PayloadCMS jobs not configured - emails will not be processed automatically')
return return
} }
@@ -79,6 +81,7 @@ export const scheduleEmailsJob = async (
waitUntil: delay ? new Date(Date.now() + delay) : undefined, waitUntil: delay ? new Date(Date.now() + delay) : undefined,
} as any) } as any)
} catch (error) { } catch (error) {
console.error('Failed to schedule email processing job:', error) const logger = createContextLogger(payload, 'SCHEDULER')
logger.error('Failed to schedule email processing job:', error)
} }
} }

View File

@@ -2,6 +2,7 @@ import { Payload } from 'payload'
import { getMailing, renderTemplate, parseAndValidateEmails, sanitizeFromName } from './utils/helpers.js' import { getMailing, renderTemplate, 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'
// Options for sending emails // Options for sending emails
export interface SendEmailOptions<T extends BaseEmailDocument = BaseEmailDocument> { export interface SendEmailOptions<T extends BaseEmailDocument = BaseEmailDocument> {
@@ -137,6 +138,9 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
// If processImmediately is true, get the job from the relationship and process it now // 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')
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')
} }
@@ -149,6 +153,8 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
const startTime = Date.now() const startTime = Date.now()
let jobId: string | undefined let jobId: string | undefined
logger.debug(`Polling for job creation (max ${maxAttempts} attempts, ${maxTotalTime}ms timeout)`)
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Check total timeout before continuing // Check total timeout before continuing
if (Date.now() - startTime > maxTotalTime) { if (Date.now() - startTime > maxTotalTime) {
@@ -172,17 +178,19 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
id: email.id, 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) { if (emailWithJobs.jobs && emailWithJobs.jobs.length > 0) {
// Job found! Get the first job ID (should only be one for a new email) // Job found! Get the first job ID (should only be one for a new email)
jobId = Array.isArray(emailWithJobs.jobs) const firstJob = Array.isArray(emailWithJobs.jobs) ? emailWithJobs.jobs[0] : emailWithJobs.jobs
? String(emailWithJobs.jobs[0]) jobId = typeof firstJob === 'string' ? firstJob : String(firstJob.id || firstJob)
: String(emailWithJobs.jobs) logger.info(`Found job ID: ${jobId}`)
break break
} }
// Log on later attempts to help with debugging (reduced threshold) // Log on later attempts to help with debugging (reduced threshold)
if (attempt >= 2) { if (attempt >= 1) {
console.log(`Waiting for job creation for email ${email.id}, attempt ${attempt + 1}/${maxAttempts}`) logger.debug(`Waiting for job creation for email ${email.id}, attempt ${attempt + 1}/${maxAttempts}`)
} }
} }
@@ -204,9 +212,12 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
) )
} }
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`)
} catch (error) { } catch (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

@@ -1,4 +1,5 @@
import type { Payload } from 'payload' import type { Payload } from 'payload'
import { createContextLogger } from './logger.js'
/** /**
* Processes a single email by ID using the mailing service * Processes a single email by ID using the mailing service
@@ -36,20 +37,28 @@ 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)
await payload.jobs.run({ const result = await payload.jobs.run({
where: { where: {
id: { id: {
equals: jobId equals: jobId
} }
} }
}) })
logger.info(`Job ${jobId} execution completed`, { result })
} catch (error) { } catch (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,4 +1,5 @@
import type { Payload } from 'payload' import type { Payload } from 'payload'
import { createContextLogger } from './logger.js'
/** /**
* Finds existing processing jobs for an email * Finds existing processing jobs for an email
@@ -47,11 +48,16 @@ export async function ensureEmailJob(
const mailingContext = (payload as any).mailing const mailingContext = (payload as any).mailing
const queueName = options?.queueName || mailingContext?.config?.queue || 'default' const queueName = options?.queueName || mailingContext?.config?.queue || 'default'
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,
@@ -62,21 +68,32 @@ export async function ensureEmailJob(
waitUntil: options?.scheduledAt ? new Date(options.scheduledAt) : undefined 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}`)
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)
console.log(`Found existing jobs for email ${normalizedEmailId}: ${existingJobs.docs.map(j => j.id).join(', ')}`) logger.info(`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
@@ -92,6 +109,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}`)
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}`
@@ -99,6 +117,7 @@ export async function ensureEmailJob(
} }
// Non-constraint related error // Non-constraint related error
logger.error(`Non-constraint 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}`)
} }
} }
@@ -134,7 +153,8 @@ export async function updateEmailJobRelationship(
}) })
} catch (error) { } catch (error) {
const normalizedEmailId = String(emailId) 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 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),
}
}