Compare commits

...

6 Commits

Author SHA1 Message Date
Bas
bba223410d Merge pull request #43 from xtr-dev/dev
Remove verbose initialization logs
2025-09-14 18:36:19 +02:00
0d295603ef Update development documentation for silent plugin initialization
- Remove reference to removed 'PayloadCMS Mailing Plugin initialized successfully' log
- Add note explaining that plugin initializes silently on success
- Clarify that absence of errors indicates successful initialization
- Keep documentation aligned with actual plugin behavior
- Bump version to 0.4.3
2025-09-14 18:33:34 +02:00
bd1842d45c Remove verbose initialization logs
- Remove 'PayloadCMS Mailing Plugin initialized successfully' log
- Remove 'Scheduled initial email processing job in queue' log
- Keep error logging for failed job scheduling
- Reduce console noise during plugin initialization
- Bump version to 0.4.2
2025-09-14 18:18:20 +02:00
Bas
a40d87c63c Merge pull request #42 from xtr-dev/dev
BREAKING CHANGE: Remove sendEmailWorkflow, add immediate processing t…
2025-09-14 18:07:19 +02:00
ccd8ef35c3 Fix error handling and improve error messages
- Fix inconsistent error handling in sendEmailTask by re-throwing original Error instances
- Preserve stack traces and error context instead of creating new Error wrappers
- Improve generic error messages in emailProcessor utilities with specific details
- Add actionable guidance for common configuration issues
- Help developers understand what went wrong and how to fix it
- Bump version to 0.4.1
2025-09-14 18:00:23 +02:00
a12d4c1bee BREAKING CHANGE: Remove sendEmailWorkflow, add immediate processing to sendEmailTask
- Remove entire workflows directory and sendEmailWorkflow
- Factor out email processing logic into reusable utilities (emailProcessor.ts)
- Add processImmediately option to sendEmailTask input schema
- Update sendEmailTask to process emails immediately when requested
- Update processEmailsTask to use shared processing utilities
- Remove workflow-related exports and plugin configuration
- Simplify documentation to focus on unified task approach
- Export new email processing utilities (processEmailById, processAllEmails)
- Bump version to 0.4.0 (breaking change - workflows removed)

Migration: Use sendEmailTask with processImmediately: true instead of sendEmailWorkflow
2025-09-14 17:53:29 +02:00
10 changed files with 126 additions and 364 deletions

View File

@@ -126,9 +126,10 @@ When you start the dev server, look for these messages:
🎯 Test interface will be available at: /mailing-test 🎯 Test interface will be available at: /mailing-test
✅ Example email templates created successfully ✅ Example email templates created successfully
PayloadCMS Mailing Plugin initialized successfully
``` ```
**Note**: The plugin initializes silently on success (no "initialized successfully" message). If you see no errors, the plugin loaded correctly.
## Troubleshooting ## Troubleshooting
### Server won't start ### Server won't start

View File

@@ -382,16 +382,7 @@ await retryFailedEmails(payload)
## PayloadCMS Integration ## PayloadCMS Integration
The plugin provides both tasks and workflows for email processing: The plugin provides PayloadCMS tasks for email processing:
### Tasks vs Workflows
- **Tasks**: Simple job execution, good for background processing
- **Workflows**: More advanced with UI, status tracking, and immediate processing options
### Task Integration
The plugin provides a ready-to-use PayloadCMS task for queuing template emails:
### 1. Add the task to your Payload config ### 1. Add the task to your Payload config
@@ -464,25 +455,13 @@ The task can also be triggered from the Payload admin panel with a user-friendly
-**Error Handling**: Comprehensive error reporting -**Error Handling**: Comprehensive error reporting
-**Queue Management**: Leverage Payload's job queue system -**Queue Management**: Leverage Payload's job queue system
### Workflow Integration ### Immediate Processing
For advanced features, use the workflow instead: The send email task now supports immediate processing. Enable the `processImmediately` option to send emails instantly:
```typescript ```typescript
import { sendEmailWorkflow } from '@xtr-dev/payload-mailing' await payload.jobs.queue({
task: 'send-email',
export default buildConfig({
jobs: {
workflows: [sendEmailWorkflow]
}
})
```
**Key advantage**: Optional `processImmediately` option to send emails instantly instead of queuing.
```typescript
await payload.workflows.queue({
workflow: 'send-email',
input: { input: {
processImmediately: true, // Send immediately (default: false) processImmediately: true, // Send immediately (default: false)
templateSlug: 'welcome-email', templateSlug: 'welcome-email',
@@ -492,6 +471,11 @@ await payload.workflows.queue({
}) })
``` ```
**Benefits**:
- No separate workflow needed
- Unified task interface
- Optional immediate processing when needed
## Job Processing ## Job Processing
The plugin automatically adds a unified email processing job to PayloadCMS: The plugin automatically adds a unified email processing job to PayloadCMS:

View File

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

@@ -15,10 +15,6 @@ export { default as Emails } from './collections/Emails.js'
export { mailingJobs, sendEmailJob } from './jobs/index.js' export { mailingJobs, sendEmailJob } from './jobs/index.js'
export type { SendEmailTaskInput } from './jobs/sendEmailTask.js' export type { SendEmailTaskInput } from './jobs/sendEmailTask.js'
// Workflows (includes the send email workflow)
export { mailingWorkflows, sendEmailWorkflow } from './workflows/index.js'
export type { SendEmailWorkflowInput } from './workflows/sendEmailWorkflow.js'
// Main email sending function // Main email sending function
export { sendEmail, type SendEmailOptions } from './sendEmail.js' export { sendEmail, type SendEmailOptions } from './sendEmail.js'
export { default as sendEmailDefault } from './sendEmail.js' export { default as sendEmailDefault } from './sendEmail.js'
@@ -31,3 +27,6 @@ export {
retryFailedEmails, retryFailedEmails,
parseAndValidateEmails, parseAndValidateEmails,
} from './utils/helpers.js' } from './utils/helpers.js'
// Email processing utilities
export { processEmailById, processAllEmails } from './utils/emailProcessor.js'

View File

@@ -1,5 +1,5 @@
import type { PayloadRequest, Payload } from 'payload' import type { PayloadRequest, Payload } from 'payload'
import type { MailingService } from '../services/MailingService.js' import { processAllEmails } from '../utils/emailProcessor.js'
/** /**
* Data passed to the process emails task * Data passed to the process emails task
@@ -14,18 +14,16 @@ export interface ProcessEmailsTaskData {
*/ */
export const processEmailsTaskHandler = async ( export const processEmailsTaskHandler = async (
job: { data: ProcessEmailsTaskData }, job: { data: ProcessEmailsTaskData },
context: { req: PayloadRequest; mailingService: MailingService } context: { req: PayloadRequest }
) => { ) => {
const { mailingService } = context const { req } = context
const payload = (req as any).payload
try { try {
console.log('🔄 Processing email queue (pending + failed emails)...') console.log('🔄 Processing email queue (pending + failed emails)...')
// Process pending emails first // Use the shared email processing logic
await mailingService.processEmails() await processAllEmails(payload)
// Then retry failed emails
await mailingService.retryFailedEmails()
console.log('✅ Email queue processing completed successfully') console.log('✅ Email queue processing completed successfully')
} catch (error) { } catch (error) {
@@ -49,10 +47,10 @@ export const processEmailsTask = {
throw new Error('Mailing plugin not properly initialized') throw new Error('Mailing plugin not properly initialized')
} }
// Use the existing mailing service from context // Use the task handler
await processEmailsTaskHandler( await processEmailsTaskHandler(
job as { data: ProcessEmailsTaskData }, job as { data: ProcessEmailsTaskData },
{ req, mailingService: mailingContext.service } { req }
) )
return { return {

View File

@@ -1,5 +1,6 @@
import { sendEmail } from '../sendEmail.js' import { sendEmail } from '../sendEmail.js'
import { BaseEmailDocument } from '../types/index.js' import { BaseEmailDocument } from '../types/index.js'
import { processEmailById } from '../utils/emailProcessor.js'
export interface SendEmailTaskInput { export interface SendEmailTaskInput {
// Template mode fields // Template mode fields
@@ -20,6 +21,7 @@ export interface SendEmailTaskInput {
replyTo?: string replyTo?: string
scheduledAt?: string | Date // ISO date string or Date object scheduledAt?: string | Date // ISO date string or Date object
priority?: number priority?: number
processImmediately?: boolean // If true, process the email immediately instead of waiting for the queue
// Allow any additional fields that users might have in their email collection // Allow any additional fields that users might have in their email collection
[key: string]: any [key: string]: any
@@ -44,8 +46,8 @@ function transformTaskInputToSendEmailOptions(taskInput: SendEmailTaskInput) {
// Standard email fields that should be copied to data // Standard email fields that should be copied to data
const standardFields = ['to', 'cc', 'bcc', 'from', 'fromName', 'replyTo', 'subject', 'html', 'text', 'scheduledAt', 'priority'] const standardFields = ['to', 'cc', 'bcc', 'from', 'fromName', 'replyTo', 'subject', 'html', 'text', 'scheduledAt', 'priority']
// Template-specific fields that should not be copied to data // Fields that should not be copied to data
const templateFields = ['templateSlug', 'variables'] const excludedFields = ['templateSlug', 'variables', 'processImmediately']
// Copy standard fields to data // Copy standard fields to data
standardFields.forEach(field => { standardFields.forEach(field => {
@@ -54,9 +56,9 @@ function transformTaskInputToSendEmailOptions(taskInput: SendEmailTaskInput) {
} }
}) })
// Copy any additional custom fields that aren't template or standard fields // Copy any additional custom fields that aren't excluded or standard fields
Object.keys(taskInput).forEach(key => { Object.keys(taskInput).forEach(key => {
if (!templateFields.includes(key) && !standardFields.includes(key)) { if (!excludedFields.includes(key) && !standardFields.includes(key)) {
sendEmailOptions.data[key] = taskInput[key] sendEmailOptions.data[key] = taskInput[key]
} }
}) })
@@ -72,6 +74,15 @@ export const sendEmailJob = {
slug: 'send-email', slug: 'send-email',
label: 'Send Email', label: 'Send Email',
inputSchema: [ inputSchema: [
{
name: 'processImmediately',
type: 'checkbox' as const,
label: 'Process Immediately',
defaultValue: false,
admin: {
description: 'Process and send the email immediately instead of waiting for the queue processor'
}
},
{ {
name: 'templateSlug', name: 'templateSlug',
type: 'text' as const, type: 'text' as const,
@@ -171,7 +182,8 @@ export const sendEmailJob = {
type: 'date' as const, type: 'date' as const,
label: 'Schedule For', label: 'Schedule For',
admin: { admin: {
description: 'Optional date/time to schedule email for future delivery' description: 'Optional date/time to schedule email for future delivery',
condition: (data: any) => !data.processImmediately
} }
}, },
{ {
@@ -195,6 +207,7 @@ export const sendEmailJob = {
handler: async ({ input, payload }: any) => { handler: async ({ input, payload }: any) => {
// Cast input to our expected type // Cast input to our expected type
const taskInput = input as SendEmailTaskInput const taskInput = input as SendEmailTaskInput
const shouldProcessImmediately = taskInput.processImmediately || false
try { try {
// Transform task input into sendEmail options using helper function // Transform task input into sendEmail options using helper function
@@ -203,22 +216,38 @@ export const sendEmailJob = {
// Use the sendEmail helper to create the email // Use the sendEmail helper to create the email
const email = await sendEmail<BaseEmailDocument>(payload, sendEmailOptions) const email = await sendEmail<BaseEmailDocument>(payload, sendEmailOptions)
// If processImmediately is true, process the email now
if (shouldProcessImmediately) {
console.log(`⚡ Processing email ${email.id} immediately...`)
await processEmailById(payload, String(email.id))
console.log(`✅ Email ${email.id} processed and sent immediately`)
return { return {
output: { output: {
success: true, success: true,
id: email.id, id: email.id,
status: 'sent',
processedImmediately: true
}
}
}
return {
output: {
success: true,
id: email.id,
status: 'queued',
processedImmediately: false
} }
} }
} catch (error) { } catch (error) {
// Re-throw Error instances to preserve stack trace and error context
if (error instanceof Error) { if (error instanceof Error) {
// Preserve original error and stack trace throw error
const wrappedError = new Error(`Failed to queue email: ${error.message}`)
wrappedError.stack = error.stack
wrappedError.cause = error
throw wrappedError
} else { } else {
throw new Error(`Failed to queue email: ${String(error)}`) // Only wrap non-Error values
throw new Error(`Failed to process email: ${String(error)}`)
} }
} }
} }

View File

@@ -4,7 +4,6 @@ import { MailingService } from './services/MailingService.js'
import { createEmailTemplatesCollection } from './collections/EmailTemplates.js' import { createEmailTemplatesCollection } from './collections/EmailTemplates.js'
import Emails from './collections/Emails.js' import Emails from './collections/Emails.js'
import { mailingJobs, scheduleEmailsJob } from './jobs/index.js' import { mailingJobs, scheduleEmailsJob } from './jobs/index.js'
import { mailingWorkflows } from './workflows/index.js'
export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => { export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
@@ -87,10 +86,6 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
...(config.jobs?.tasks || []), ...(config.jobs?.tasks || []),
...mailingJobs, ...mailingJobs,
], ],
workflows: [
...(config.jobs?.workflows || []),
...mailingWorkflows,
],
}, },
onInit: async (payload: any) => { onInit: async (payload: any) => {
if (pluginConfig.initOrder === 'after' && config.onInit) { if (pluginConfig.initOrder === 'after' && config.onInit) {
@@ -111,12 +106,9 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
}, },
} as MailingContext } as MailingContext
console.log('PayloadCMS Mailing Plugin initialized successfully')
// Schedule the initial email processing job // Schedule the initial email processing job
try { try {
await scheduleEmailsJob(payload, queueName, 60000) // Schedule in 1 minute await scheduleEmailsJob(payload, queueName, 60000) // Schedule in 1 minute
console.log(`🔄 Scheduled initial email processing job in queue: ${queueName}`)
} catch (error) { } catch (error) {
console.error('Failed to schedule email processing job:', error) console.error('Failed to schedule email processing job:', error)
} }

View File

@@ -0,0 +1,61 @@
import type { Payload } from 'payload'
/**
* Processes a single email by ID using the mailing service
* @param payload Payload instance
* @param emailId The ID of the email to process
* @returns Promise that resolves when email is processed
*/
export async function processEmailById(payload: Payload, emailId: string): Promise<void> {
// Get mailing context from payload
const mailingContext = (payload as any).mailing
if (!mailingContext) {
throw new Error(
'Mailing plugin not found on payload instance. ' +
'Ensure the mailingPlugin is properly configured in your Payload config plugins array.'
)
}
if (!mailingContext.service) {
throw new Error(
'Mailing service not available. ' +
'The plugin may not have completed initialization. ' +
'Check that email configuration is properly set up in your Payload config.'
)
}
// Process the specific email
await mailingContext.service.processEmailItem(emailId)
}
/**
* Processes all pending and failed emails using the mailing service
* @param payload Payload instance
* @returns Promise that resolves when all emails are processed
*/
export async function processAllEmails(payload: Payload): Promise<void> {
// Get mailing context from payload
const mailingContext = (payload as any).mailing
if (!mailingContext) {
throw new Error(
'Mailing plugin not found on payload instance. ' +
'Ensure the mailingPlugin is properly configured in your Payload config plugins array.'
)
}
if (!mailingContext.service) {
throw new Error(
'Mailing service not available. ' +
'The plugin may not have completed initialization. ' +
'Check that email configuration is properly set up in your Payload config.'
)
}
// Process pending emails first
await mailingContext.service.processEmails()
// Then retry failed emails
await mailingContext.service.retryFailedEmails()
}

View File

@@ -1,11 +0,0 @@
import { sendEmailWorkflow } from './sendEmailWorkflow.js'
/**
* All mailing-related workflows that get registered with Payload
*/
export const mailingWorkflows = [
sendEmailWorkflow,
]
// Re-export everything from individual workflow files
export * from './sendEmailWorkflow.js'

View File

@@ -1,291 +0,0 @@
import { sendEmail } from '../sendEmail.js'
import { BaseEmailDocument } from '../types/index.js'
export interface SendEmailWorkflowInput {
// Template mode fields
templateSlug?: string
variables?: Record<string, any>
// Direct email mode fields
subject?: string
html?: string
text?: string
// Common fields
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
from?: string
fromName?: string
replyTo?: string
scheduledAt?: string | Date // ISO date string or Date object
priority?: number
// Workflow-specific option
processImmediately?: boolean // If true, process the email immediately instead of waiting for the queue
// Allow any additional fields that users might have in their email collection
[key: string]: any
}
/**
* Transforms workflow input into sendEmail options by separating template and data fields
*/
function transformWorkflowInputToSendEmailOptions(workflowInput: SendEmailWorkflowInput) {
const sendEmailOptions: any = {
data: {}
}
// If using template mode, set template options
if (workflowInput.templateSlug) {
sendEmailOptions.template = {
slug: workflowInput.templateSlug,
variables: workflowInput.variables || {}
}
}
// Standard email fields that should be copied to data
const standardFields = ['to', 'cc', 'bcc', 'from', 'fromName', 'replyTo', 'subject', 'html', 'text', 'scheduledAt', 'priority']
// Fields that should not be copied to data
const excludedFields = ['templateSlug', 'variables', 'processImmediately']
// Copy standard fields to data
standardFields.forEach(field => {
if (workflowInput[field] !== undefined) {
sendEmailOptions.data[field] = workflowInput[field]
}
})
// Copy any additional custom fields
Object.keys(workflowInput).forEach(key => {
if (!excludedFields.includes(key) && !standardFields.includes(key)) {
sendEmailOptions.data[key] = workflowInput[key]
}
})
return sendEmailOptions
}
/**
* Workflow for sending emails with optional immediate processing
* Can be used through Payload's workflow system to send emails programmatically
*/
export const sendEmailWorkflow = {
slug: 'send-email',
label: 'Send Email',
inputSchema: [
{
name: 'processImmediately',
type: 'checkbox' as const,
label: 'Process Immediately',
defaultValue: false,
admin: {
description: 'Process and send the email immediately instead of waiting for the queue processor'
}
},
{
name: 'templateSlug',
type: 'text' as const,
label: 'Template Slug',
admin: {
description: 'Use a template (leave empty for direct email)',
condition: (data: any) => !data.subject && !data.html
}
},
{
name: 'variables',
type: 'json' as const,
label: 'Template Variables',
admin: {
description: 'JSON object with variables for template rendering',
condition: (data: any) => Boolean(data.templateSlug)
}
},
{
name: 'subject',
type: 'text' as const,
label: 'Subject',
admin: {
description: 'Email subject (required if not using template)',
condition: (data: any) => !data.templateSlug
}
},
{
name: 'html',
type: 'textarea' as const,
label: 'HTML Content',
admin: {
description: 'HTML email content (required if not using template)',
condition: (data: any) => !data.templateSlug
}
},
{
name: 'text',
type: 'textarea' as const,
label: 'Text Content',
admin: {
description: 'Plain text email content (optional)',
condition: (data: any) => !data.templateSlug
}
},
{
name: 'to',
type: 'text' as const,
required: true,
label: 'To (Email Recipients)',
admin: {
description: 'Comma-separated list of email addresses'
}
},
{
name: 'cc',
type: 'text' as const,
label: 'CC (Carbon Copy)',
admin: {
description: 'Optional comma-separated list of CC email addresses'
}
},
{
name: 'bcc',
type: 'text' as const,
label: 'BCC (Blind Carbon Copy)',
admin: {
description: 'Optional comma-separated list of BCC email addresses'
}
},
{
name: 'from',
type: 'text' as const,
label: 'From Email',
admin: {
description: 'Optional sender email address (uses default if not provided)'
}
},
{
name: 'fromName',
type: 'text' as const,
label: 'From Name',
admin: {
description: 'Optional sender display name (e.g., "John Doe")'
}
},
{
name: 'replyTo',
type: 'text' as const,
label: 'Reply To',
admin: {
description: 'Optional reply-to email address'
}
},
{
name: 'scheduledAt',
type: 'date' as const,
label: 'Schedule For',
admin: {
description: 'Optional date/time to schedule email for future delivery',
condition: (data: any) => !data.processImmediately
}
},
{
name: 'priority',
type: 'number' as const,
label: 'Priority',
min: 1,
max: 10,
defaultValue: 5,
admin: {
description: 'Email priority (1 = highest, 10 = lowest)'
}
}
],
handler: async ({ job, req }: any) => {
const { input, id, taskStatus } = job
const { payload } = req
// Cast input to our expected type
const workflowInput = input as SendEmailWorkflowInput
const shouldProcessImmediately = workflowInput.processImmediately || false
try {
console.log(`📧 Workflow ${id}: Creating email...`)
// Transform workflow input into sendEmail options
const sendEmailOptions = transformWorkflowInputToSendEmailOptions(workflowInput)
// Create the email in the database
const email = await sendEmail<BaseEmailDocument>(payload, sendEmailOptions)
console.log(`✅ Workflow ${id}: Email created with ID: ${email.id}`)
// Update task status with email ID
if (taskStatus) {
await taskStatus.update({
data: {
emailId: email.id,
status: 'created'
}
})
}
// If processImmediately is true, process the email now
if (shouldProcessImmediately) {
console.log(`⚡ Workflow ${id}: Processing email immediately...`)
// Get the mailing service from context
const mailingContext = payload.mailing
if (!mailingContext || !mailingContext.service) {
throw new Error('Mailing plugin not properly initialized')
}
// Process just this specific email
await mailingContext.service.processEmailItem(String(email.id))
console.log(`✅ Workflow ${id}: Email processed and sent immediately`)
// Update task status
if (taskStatus) {
await taskStatus.update({
data: {
emailId: email.id,
status: 'sent',
processedImmediately: true
}
})
}
} else {
// Update task status for queued email
if (taskStatus) {
await taskStatus.update({
data: {
emailId: email.id,
status: 'queued',
processedImmediately: false
}
})
}
}
} catch (error) {
console.error(`❌ Workflow ${id}: Failed to process email:`, error)
// Update task status with error
if (taskStatus) {
await taskStatus.update({
data: {
status: 'failed',
error: error instanceof Error ? error.message : String(error)
}
})
}
if (error instanceof Error) {
throw new Error(`Failed to process email: ${error.message}`, { cause: error })
} else {
throw new Error(`Failed to process email: ${String(error)}`)
}
}
}
}
export default sendEmailWorkflow