Compare commits

..

13 Commits

Author SHA1 Message Date
Bas
fde8eb538d Merge pull request #41 from xtr-dev/dev
Dev
2025-09-14 17:47:19 +02:00
845b379da3 Fix error handling and improve naming consistency
- Use native error chaining in workflow (Error constructor with cause option)
- Fix job scheduling to use 'task' instead of 'workflow' property
- Rename processEmailsJob.ts to processEmailsTask.ts for consistency
- Update all imports and references while maintaining backward compatibility
- Add processEmailsTask export with processEmailsJob alias
- Bump version to 0.3.1
2025-09-14 17:34:53 +02:00
dd205dba41 Make workflow documentation more concise
- Simplified workflow section to focus on key advantage
- Removed verbose comparison table and features list
- Kept essential usage example with processImmediately option
- More readable and focused on the main differentiator
2025-09-14 17:21:57 +02:00
a6564e2a29 Add sendEmail workflow with immediate processing option
- Create sendEmailWorkflow as a Payload workflow alternative to task
- Add processImmediately option (disabled by default) to send emails immediately
- Expose processEmailItem method in MailingService for individual email processing
- Add comprehensive input schema with conditional fields
- Update plugin to register both tasks and workflows
- Add detailed documentation comparing tasks vs workflows
- Includes status tracking and error handling
- Bump version to 0.3.0 (new feature)
2025-09-14 17:20:21 +02:00
8f200da449 Refactor and clean up job organization
- Properly encapsulate processEmailsJob in its own file with handler and definition
- Clean up index.ts to remove duplicate code and just export job definitions
- Add comprehensive JSDoc comments for better documentation
- Separate job handler logic from job definition for clarity
- Fix job scheduling to use correct field names
- Bump version to 0.2.1
2025-09-14 17:16:01 +02:00
Bas
ff94d72d49 Merge pull request #40 from xtr-dev/dev
BREAKING CHANGE: Remove custom transport support, use Payload's email…
2025-09-14 17:02:50 +02:00
ddee7d5a76 BREAKING CHANGE: Remove custom transport support, use Payload's email config
- Removed custom transport configuration from plugin
- Plugin now requires Payload email to be configured
- Simplified setup by relying on Payload's email adapter
- Updated README with new configuration requirements
- Bump version to 0.2.0 (breaking change)

Users must now configure email in their Payload config using an email adapter
like @payloadcms/email-nodemailer instead of configuring transport in the plugin.
2025-09-14 16:57:30 +02:00
Bas
0083e8e1fa Merge pull request #39 from xtr-dev/dev
Remove redundant queueName validation and debug log, bump version to …
2025-09-14 16:29:13 +02:00
63a7eef8d8 Remove redundant queueName validation and debug log, bump version to 0.1.24 2025-09-14 16:28:24 +02:00
Bas
6cf055178b Merge pull request #38 from xtr-dev/dev
Add debug log for email transporter configuration and bump version to…
2025-09-14 16:15:41 +02:00
aa978090fa Add debug log for email transporter configuration and bump version to 0.1.23 2025-09-14 16:14:56 +02:00
Bas
556d910e30 Merge pull request #37 from xtr-dev/dev
Remove conditional transporter initialization and bump version to 0.1.22
2025-09-14 13:53:32 +02:00
b4bad70634 Remove conditional transporter initialization and bump version to 0.1.22 2025-09-14 13:52:49 +02:00
13 changed files with 483 additions and 156 deletions

View File

@@ -28,26 +28,31 @@ npm install @xtr-dev/payload-mailing
## Quick Start
### 1. Add the plugin to your Payload config
### 1. Configure email in your Payload config and add the plugin
```typescript
import { buildConfig } from 'payload/config'
import { mailingPlugin } from '@xtr-dev/payload-mailing'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
export default buildConfig({
// ... your config
email: nodemailerAdapter({
defaultFromAddress: 'noreply@yoursite.com',
defaultFromName: 'Your Site',
transport: {
host: 'smtp.gmail.com',
port: 587,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
},
}),
plugins: [
mailingPlugin({
defaultFrom: 'noreply@yoursite.com',
transport: {
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
},
defaultFromName: 'Your Site Name',
retryAttempts: 3,
retryDelay: 300000, // 5 minutes
queue: 'email-queue', // optional
@@ -119,13 +124,6 @@ mailingPlugin({
return yourCustomEngine.render(template, variables)
},
// Email transport
transport: {
host: 'smtp.gmail.com',
port: 587,
auth: { user: '...', pass: '...' }
},
// Collection names (optional)
collections: {
templates: 'email-templates', // default
@@ -382,7 +380,16 @@ await processEmails(payload)
await retryFailedEmails(payload)
```
## PayloadCMS Task Integration
## PayloadCMS Integration
The plugin provides both tasks and workflows 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:
@@ -457,6 +464,34 @@ The task can also be triggered from the Payload admin panel with a user-friendly
-**Error Handling**: Comprehensive error reporting
-**Queue Management**: Leverage Payload's job queue system
### Workflow Integration
For advanced features, use the workflow instead:
```typescript
import { sendEmailWorkflow } from '@xtr-dev/payload-mailing'
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: {
processImmediately: true, // Send immediately (default: false)
templateSlug: 'welcome-email',
to: ['user@example.com'],
variables: { name: 'John' }
}
})
```
## Job Processing
The plugin automatically adds a unified email processing job to PayloadCMS:

View File

@@ -135,15 +135,6 @@ const buildConfigWithMemoryDB = async () => {
mailingPlugin({
defaultFrom: 'noreply@test.com',
initOrder: 'after',
transport: {
host: 'localhost',
port: 1025, // MailHog port for dev
secure: false,
auth: {
user: 'test',
pass: 'test',
},
},
retryAttempts: 3,
retryDelay: 60000, // 1 minute for dev
queue: 'email-queue',

View File

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

View File

@@ -15,6 +15,10 @@ export { default as Emails } from './collections/Emails.js'
export { mailingJobs, sendEmailJob } from './jobs/index.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
export { sendEmail, type SendEmailOptions } from './sendEmail.js'
export { default as sendEmailDefault } from './sendEmail.js'

View File

@@ -1,35 +1,14 @@
import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js'
import { processEmailsJob } from './processEmailsTask.js'
import { sendEmailJob } from './sendEmailTask.js'
import { MailingService } from '../services/MailingService.js'
/**
* All mailing-related jobs that get registered with Payload
*/
export const mailingJobs = [
{
slug: 'processEmails',
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 existing mailing service from context
await processEmailsJob(
job as { data: ProcessEmailsJobData },
{ req, mailingService: mailingContext.service }
)
return {
output: {
success: true,
message: 'Email queue processing completed successfully'
}
}
},
interfaceName: 'ProcessEmailsJob',
},
processEmailsJob,
sendEmailJob,
]
export * from './processEmailsJob.js'
export * from './sendEmailTask.js'
// Re-export everything from individual job files
export * from './processEmailsTask.js'
export * from './sendEmailTask.js'

View File

@@ -1,50 +0,0 @@
import type { PayloadRequest } from 'payload'
import { MailingService } from '../services/MailingService.js'
export interface ProcessEmailsJobData {
// No type needed - always processes both pending and failed emails
}
export const processEmailsJob = async (
job: { data: ProcessEmailsJobData },
context: { req: PayloadRequest; mailingService: MailingService }
) => {
const { mailingService } = context
try {
console.log('🔄 Processing email queue (pending + failed emails)...')
// Process pending emails first
await mailingService.processEmails()
// Then retry failed emails
await mailingService.retryFailedEmails()
console.log('✅ Email queue processing completed successfully (pending and failed emails)')
} catch (error) {
console.error('❌ Email queue processing failed:', error)
throw error
}
}
export const scheduleEmailsJob = async (
payload: any,
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: 'processEmails',
input: {},
waitUntil: delay ? new Date(Date.now() + delay) : undefined,
})
} catch (error) {
console.error('Failed to schedule email processing job:', error)
}
}

View File

@@ -0,0 +1,95 @@
import type { PayloadRequest, Payload } from 'payload'
import type { MailingService } from '../services/MailingService.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; mailingService: MailingService }
) => {
const { mailingService } = context
try {
console.log('🔄 Processing email queue (pending + failed emails)...')
// Process pending emails first
await mailingService.processEmails()
// Then retry failed emails
await mailingService.retryFailedEmails()
console.log('✅ Email queue processing completed successfully')
} catch (error) {
console.error('❌ Email queue processing failed:', error)
throw error
}
}
/**
* 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 existing mailing service from context
await processEmailsTaskHandler(
job as { data: ProcessEmailsTaskData },
{ req, mailingService: mailingContext.service }
)
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

@@ -64,6 +64,10 @@ function transformTaskInputToSendEmailOptions(taskInput: SendEmailTaskInput) {
return sendEmailOptions
}
/**
* Job definition for sending emails
* Can be used through Payload's job queue system to send emails programmatically
*/
export const sendEmailJob = {
slug: 'send-email',
label: 'Send Email',

View File

@@ -4,17 +4,12 @@ import { MailingService } from './services/MailingService.js'
import { createEmailTemplatesCollection } from './collections/EmailTemplates.js'
import Emails from './collections/Emails.js'
import { mailingJobs, scheduleEmailsJob } from './jobs/index.js'
import { mailingWorkflows } from './workflows/index.js'
export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
const queueName = pluginConfig.queue || 'default'
// Validate queueName
if (!queueName || typeof queueName !== 'string') {
throw new Error('Invalid queue configuration: queue must be a non-empty string')
}
// Handle templates collection configuration
const templatesConfig = pluginConfig.collections?.templates
const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates'
@@ -92,6 +87,10 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
...(config.jobs?.tasks || []),
...mailingJobs,
],
workflows: [
...(config.jobs?.workflows || []),
...mailingWorkflows,
],
},
onInit: async (payload: any) => {
if (pluginConfig.initOrder === 'after' && config.onInit) {

View File

@@ -1,11 +1,9 @@
import { Payload } from 'payload'
import { Liquid } from 'liquidjs'
import nodemailer, { Transporter } from 'nodemailer'
import {
MailingPluginConfig,
TemplateVariables,
MailingService as IMailingService,
MailingTransportConfig,
BaseEmail, BaseEmailTemplate, BaseEmailDocument, BaseEmailTemplateDocument
} from '../types/index.js'
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
@@ -13,11 +11,10 @@ import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richT
export class MailingService implements IMailingService {
public payload: Payload
private config: MailingPluginConfig
private transporter!: Transporter | any
private emailAdapter: any
private templatesCollection: string
private emailsCollection: string
private liquid: Liquid | null | false = null
private transporterInitialized = false
constructor(payload: Payload, config: MailingPluginConfig) {
this.payload = payload
@@ -29,37 +26,19 @@ export class MailingService implements IMailingService {
const emailsConfig = config.collections?.emails
this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails'
// Only initialize transporter if payload is properly set
if (payload && payload.db) {
this.initializeTransporter()
// Use Payload's configured email adapter
if (!this.payload.email) {
throw new Error('Payload email configuration is required. Please configure email in your Payload config.')
}
}
private initializeTransporter(): void {
if (this.transporterInitialized) return
if (this.config.transport) {
if ('sendMail' in this.config.transport) {
this.transporter = this.config.transport
} else {
this.transporter = nodemailer.createTransport(this.config.transport as MailingTransportConfig)
}
} else if (this.payload.email && 'sendMail' in this.payload.email) {
// Use Payload's configured mailer (cast to any to handle different adapter types)
this.transporter = this.payload.email as any
} else {
throw new Error('Email transport configuration is required either in plugin config or Payload config')
}
this.transporterInitialized = true
this.emailAdapter = this.payload.email
}
private ensureInitialized(): void {
if (!this.payload || !this.payload.db) {
throw new Error('MailingService payload not properly initialized')
}
if (!this.transporterInitialized) {
this.initializeTransporter()
if (!this.emailAdapter) {
throw new Error('Email adapter not configured. Please ensure Payload has email configured.')
}
}
@@ -246,7 +225,7 @@ export class MailingService implements IMailingService {
}
}
private async processEmailItem(emailId: string): Promise<void> {
async processEmailItem(emailId: string): Promise<void> {
try {
await this.payload.update({
collection: this.emailsCollection as any,
@@ -305,7 +284,8 @@ export class MailingService implements IMailingService {
}
}
await this.transporter.sendMail(mailOptions)
// Send email using Payload's email adapter
await this.emailAdapter.sendEmail(mailOptions)
await this.payload.update({
collection: this.emailsCollection as any,

View File

@@ -1,6 +1,5 @@
import { Payload } from 'payload'
import type { CollectionConfig, RichTextField } from 'payload'
import { Transporter } from 'nodemailer'
// JSON value type that matches Payload's JSON field type
export type JSONValue = string | number | boolean | { [k: string]: unknown } | unknown[] | null | undefined
@@ -70,7 +69,6 @@ export interface MailingPluginConfig {
}
defaultFrom?: string
defaultFromName?: string
transport?: Transporter | MailingTransportConfig
queue?: string
retryAttempts?: number
retryDelay?: number
@@ -82,17 +80,6 @@ export interface MailingPluginConfig {
initOrder?: 'before' | 'after'
}
export interface MailingTransportConfig {
host: string
port: number
secure?: boolean
auth?: {
user: string
pass: string
}
}
export interface QueuedEmail {
id: string
template?: string | null
@@ -124,6 +111,7 @@ export interface TemplateVariables {
export interface MailingService {
processEmails(): Promise<void>
processEmailItem(emailId: string): Promise<void>
retryFailedEmails(): Promise<void>
renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }>
}

11
src/workflows/index.ts Normal file
View File

@@ -0,0 +1,11 @@
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

@@ -0,0 +1,291 @@
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