Compare commits

..

34 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
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
Bas
efdfaf5889 Merge pull request #36 from xtr-dev/dev
Add beforeSend hook for email customization
2025-09-14 12:37:38 +02:00
ea7d8dfdd5 Add validation for beforeSend hook to ensure required properties remain intact
- Validate that 'from' field is not removed
- Validate that 'to' field is not removed or emptied
- Validate that 'subject' field is not removed
- Validate that at least 'html' or 'text' content exists
- Throw clear error messages if validation fails
- Bump version to 0.1.21
2025-09-14 12:27:43 +02:00
0d6d07de85 Add beforeSend hook for email customization
- Add BeforeSendHook type and BeforeSendMailOptions interface
- Implement hook execution in MailingService before sending emails
- Hook allows adding attachments, headers, and modifying email options
- Add comprehensive documentation with examples
- Bump version to 0.1.20
2025-09-14 12:19:52 +02:00
Bas
f12ac8172e Merge pull request #35 from xtr-dev/dev
Fix model overwrite error when plugin is initialized multiple times
2025-09-14 10:24:58 +02:00
347cd33e13 Fix model overwrite error when plugin is initialized multiple times
- Filter out existing collections with same slugs before adding plugin collections
- Prevents 'Cannot overwrite model once compiled' errors in Next.js apps
- Fixes issue during hot reload and multiple getPayload() calls
- Bump version to 0.1.19
2025-09-14 10:22:34 +02:00
Bas
672ab3236a Merge pull request #34 from xtr-dev/dev
Add fromName field support to emails collection
2025-09-14 00:10:22 +02:00
c7db65980a Fix security vulnerabilities in fromName field handling
- Add sanitizeDisplayName() method to prevent header injection attacks
- Remove newlines, carriage returns, and control characters from display names
- Fix quote escaping inconsistency between getDefaultFrom() and processEmailItem()
- Create formatEmailAddress() helper method for consistent email formatting
- Add fromName sanitization in sendEmail() function for input validation
- Prevent malformed email headers and potential security issues

Security improvements:
- Header injection prevention (removes \r\n and control characters)
- Consistent quote escaping across all display name usage
- Proper sanitization at both input and output stages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 00:07:53 +02:00
624dc12471 Bump package version to 0.1.18 in package.json. 2025-09-14 00:06:14 +02:00
e20ebe27bf Add fromName field support to emails collection
- Add fromName field to Emails collection schema for sender display name
- Update BaseEmailDocument and QueuedEmail interfaces to include fromName
- Add SendEmailTaskInput support for fromName field in job tasks
- Update MailingService to combine fromName and from into proper "Name <email>" format
- Add fromName, from, and replyTo fields to job input schema for admin UI
- Update field copying logic to handle new sender-related fields

Users can now specify a display name for emails (e.g., "John Doe <john@example.com>").

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 00:03:04 +02:00
Bas
7f04275d39 Merge pull request #33 from xtr-dev/dev
Dev
2025-09-13 23:53:56 +02:00
20afe30e88 Fix scheduledAt type in SendEmailTaskInput and add Date normalization
- Update SendEmailTaskInput.scheduledAt to support string | Date types
- Add Date object normalization to ISO strings in sendEmail processing
- Ensure consistent database storage format for all timestamp fields
- Convert Date objects to ISO strings before database operations

Resolves remaining "Type Date is not assignable to type string" error
for scheduledAt field in job task input.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:53:25 +02:00
02b3fecadf Bump package version to 0.1.17 in package.json. 2025-09-13 23:52:53 +02:00
Bas
ea87f14308 Merge pull request #32 from xtr-dev/dev
Dev
2025-09-13 23:48:28 +02:00
6886027727 Bump package version to 0.1.16 in package.json. 2025-09-13 23:45:39 +02:00
965569be06 Add Date type support for timestamp fields
- Update scheduledAt, sentAt, lastAttemptAt, createdAt, updatedAt fields to support Date | string | null
- Support both Date objects and ISO string formats for all timestamp fields
- Update BaseEmailDocument, BaseEmailTemplateDocument, and QueuedEmail interfaces consistently
- Update documentation to reflect Date object compatibility

Fixes type constraint error where customer timestamp fields use Date objects
but plugin interfaces only supported string formats.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:44:57 +02:00
19 changed files with 626 additions and 290 deletions

View File

@@ -1,93 +0,0 @@
# Using Custom ID Types
The mailing plugin now supports both `string` and `number` ID types. By default, it works with the generic `BaseEmailDocument` interface, but you can provide your own types for full type safety.
## Usage with Your Generated Types
When you have your own generated Payload types (e.g., from `payload generate:types`), you can use them with the mailing plugin:
```typescript
import { sendEmail, BaseEmailDocument } from '@xtr-dev/payload-mailing'
import { Email } from './payload-types' // Your generated types
// Option 1: Use your specific Email type
const email = await sendEmail<Email>(payload, {
template: {
slug: 'welcome',
variables: { name: 'John' }
},
data: {
to: 'user@example.com',
// All your custom fields are now type-safe
}
})
// Option 2: Extend BaseEmailDocument for custom fields
interface MyEmail extends BaseEmailDocument {
customField: string
anotherField?: number
}
const customEmail = await sendEmail<MyEmail>(payload, {
data: {
to: 'user@example.com',
subject: 'Hello',
html: '<p>Hello World</p>',
customField: 'my value', // Type-safe!
}
})
```
## Compatibility
The plugin works with:
- **String IDs**: `id: string`
- **Number IDs**: `id: number`
- **Nullable fields**: Fields can be `null`, `undefined`, or have values
- **Generated types**: Works with `payload generate:types` output
Your Payload configuration determines which types are used. The plugin automatically adapts to your setup.
## Type Definitions
The base interfaces provided by the plugin:
```typescript
// JSON value type that matches Payload's JSON field type
type JSONValue = string | number | boolean | { [k: string]: unknown } | unknown[] | null | undefined
interface BaseEmailDocument {
id: string | number
template?: any
to: string[]
cc?: string[] | null
bcc?: string[] | null
from?: string | null
replyTo?: string | null
subject: string
html: string
text?: string | null
variables?: JSONValue // Supports any JSON-compatible value
scheduledAt?: string | null
sentAt?: string | null
status?: 'pending' | 'processing' | 'sent' | 'failed' | null
attempts?: number | null
lastAttemptAt?: string | null
error?: string | null
priority?: number | null
createdAt?: string | null
updatedAt?: string | null
}
interface BaseEmailTemplateDocument {
id: string | number
name: string
slug: string
subject?: string | null
content?: any
createdAt?: string | null
updatedAt?: string | null
}
```
These provide a foundation that works with any ID type while maintaining type safety for the core email functionality.

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

121
README.md
View File

@@ -28,26 +28,31 @@ npm install @xtr-dev/payload-mailing
## Quick Start ## Quick Start
### 1. Add the plugin to your Payload config ### 1. Configure email in your Payload config and add the plugin
```typescript ```typescript
import { buildConfig } from 'payload/config' import { buildConfig } from 'payload/config'
import { mailingPlugin } from '@xtr-dev/payload-mailing' import { mailingPlugin } from '@xtr-dev/payload-mailing'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
export default buildConfig({ export default buildConfig({
// ... your config // ... 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: [ plugins: [
mailingPlugin({ mailingPlugin({
defaultFrom: 'noreply@yoursite.com', defaultFrom: 'noreply@yoursite.com',
transport: { defaultFromName: 'Your Site Name',
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
},
retryAttempts: 3, retryAttempts: 3,
retryDelay: 300000, // 5 minutes retryDelay: 300000, // 5 minutes
queue: 'email-queue', // optional queue: 'email-queue', // optional
@@ -119,13 +124,6 @@ mailingPlugin({
return yourCustomEngine.render(template, variables) return yourCustomEngine.render(template, variables)
}, },
// Email transport
transport: {
host: 'smtp.gmail.com',
port: 587,
auth: { user: '...', pass: '...' }
},
// Collection names (optional) // Collection names (optional)
collections: { collections: {
templates: 'email-templates', // default templates: 'email-templates', // default
@@ -142,6 +140,18 @@ mailingPlugin({
richTextEditor: lexicalEditor(), // optional custom editor richTextEditor: lexicalEditor(), // optional custom editor
onReady: async (payload) => { // optional initialization hook onReady: async (payload) => { // optional initialization hook
console.log('Mailing plugin ready!') console.log('Mailing plugin ready!')
},
// beforeSend hook - modify emails before sending
beforeSend: async (options, email) => {
// Add attachments, modify headers, etc.
options.attachments = [
{ filename: 'invoice.pdf', content: pdfBuffer }
]
options.headers = {
'X-Campaign-ID': email.campaignId
}
return options
} }
}) })
``` ```
@@ -255,6 +265,56 @@ mailingPlugin({
}) })
``` ```
### beforeSend Hook
Modify emails before they are sent to add attachments, custom headers, or make other changes:
```typescript
mailingPlugin({
// ... other config
beforeSend: async (options, email) => {
// Add attachments dynamically
if (email.invoiceId) {
const invoice = await generateInvoicePDF(email.invoiceId)
options.attachments = [
{
filename: `invoice-${email.invoiceId}.pdf`,
content: invoice.buffer,
contentType: 'application/pdf'
}
]
}
// Add custom headers
options.headers = {
'X-Campaign-ID': email.campaignId,
'X-Customer-ID': email.customerId,
'X-Priority': email.priority === 1 ? 'High' : 'Normal'
}
// Modify recipients based on conditions
if (process.env.NODE_ENV === 'development') {
// Redirect all emails to test address in dev
options.to = ['test@example.com']
options.subject = `[TEST] ${options.subject}`
}
// Add BCC for compliance
if (email.requiresAudit) {
options.bcc = ['audit@company.com']
}
return options
}
})
```
The `beforeSend` hook receives:
- `options`: The nodemailer mail options that will be sent
- `email`: The full email document from the database
You must return the modified options object.
### Initialization Hooks ### Initialization Hooks
Control plugin initialization order and add post-initialization logic: Control plugin initialization order and add post-initialization logic:
@@ -266,7 +326,7 @@ mailingPlugin({
onReady: async (payload) => { onReady: async (payload) => {
// Called after plugin is fully initialized // Called after plugin is fully initialized
console.log('Mailing plugin ready!') console.log('Mailing plugin ready!')
// Custom initialization logic here // Custom initialization logic here
await setupCustomEmailSettings(payload) await setupCustomEmailSettings(payload)
} }
@@ -320,9 +380,9 @@ await processEmails(payload)
await retryFailedEmails(payload) await retryFailedEmails(payload)
``` ```
## PayloadCMS Task Integration ## PayloadCMS Integration
The plugin provides a ready-to-use PayloadCMS task for queuing template emails: The plugin provides PayloadCMS tasks for email processing:
### 1. Add the task to your Payload config ### 1. Add the task to your Payload config
@@ -395,6 +455,27 @@ 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
### Immediate Processing
The send email task now supports immediate processing. Enable the `processImmediately` option to send emails instantly:
```typescript
await payload.jobs.queue({
task: 'send-email',
input: {
processImmediately: true, // Send immediately (default: false)
templateSlug: 'welcome-email',
to: ['user@example.com'],
variables: { name: 'John' }
}
})
```
**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

@@ -248,6 +248,10 @@ export interface Email {
* Sender email address (optional, uses default if not provided) * Sender email address (optional, uses default if not provided)
*/ */
from?: string | null; from?: string | null;
/**
* Sender display name (optional, e.g., "John Doe" for "John Doe <john@example.com>")
*/
fromName?: string | null;
/** /**
* Reply-to email address * Reply-to email address
*/ */
@@ -543,6 +547,7 @@ export interface EmailsSelect<T extends boolean = true> {
cc?: T; cc?: T;
bcc?: T; bcc?: T;
from?: T; from?: T;
fromName?: T;
replyTo?: T; replyTo?: T;
subject?: T; subject?: T;
html?: T; html?: T;
@@ -675,6 +680,18 @@ export interface TaskSendEmail {
* Optional comma-separated list of BCC email addresses * Optional comma-separated list of BCC email addresses
*/ */
bcc?: string | null; bcc?: string | null;
/**
* Optional sender email address (uses default if not provided)
*/
from?: string | null;
/**
* Optional sender display name (e.g., "John Doe")
*/
fromName?: string | null;
/**
* Optional reply-to email address
*/
replyTo?: string | null;
/** /**
* Optional date/time to schedule email for future delivery * Optional date/time to schedule email for future delivery
*/ */
@@ -684,7 +701,9 @@ export interface TaskSendEmail {
*/ */
priority?: number | null; priority?: number | null;
}; };
output?: unknown; output: {
id?: string | null;
};
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema

View File

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

113
dev/test-hook-validation.ts Normal file
View File

@@ -0,0 +1,113 @@
// Test hook validation in the dev environment
import { getPayload } from 'payload'
import config from './payload.config.js'
async function testHookValidation() {
const payload = await getPayload({ config: await config })
console.log('\n🧪 Testing beforeSend hook validation...\n')
// Test 1: Create an email to process
const email = await payload.create({
collection: 'emails',
data: {
to: ['test@example.com'],
subject: 'Test Email for Validation',
html: '<p>Testing hook validation</p>',
text: 'Testing hook validation',
status: 'pending'
}
})
console.log('✅ Test email created:', email.id)
// Get the mailing service
const mailingService = (payload as any).mailing.service
// Test 2: Temporarily replace the config with a bad hook
const originalBeforeSend = mailingService.config.beforeSend
console.log('\n📝 Test: Hook that removes "from" field...')
mailingService.config.beforeSend = async (options: any, email: any) => {
delete options.from
return options
}
try {
await mailingService.processEmails()
console.log('❌ Should have thrown error for missing "from"')
} catch (error: any) {
if (error.message.includes('must not remove the "from" property')) {
console.log('✅ Correctly caught missing "from" field')
} else {
console.log('❌ Unexpected error:', error.message)
}
}
console.log('\n📝 Test: Hook that empties "to" array...')
mailingService.config.beforeSend = async (options: any, email: any) => {
options.to = []
return options
}
try {
await mailingService.processEmails()
console.log('❌ Should have thrown error for empty "to"')
} catch (error: any) {
if (error.message.includes('must not remove or empty the "to" property')) {
console.log('✅ Correctly caught empty "to" array')
} else {
console.log('❌ Unexpected error:', error.message)
}
}
console.log('\n📝 Test: Hook that removes "subject"...')
mailingService.config.beforeSend = async (options: any, email: any) => {
delete options.subject
return options
}
try {
await mailingService.processEmails()
console.log('❌ Should have thrown error for missing "subject"')
} catch (error: any) {
if (error.message.includes('must not remove the "subject" property')) {
console.log('✅ Correctly caught missing "subject" field')
} else {
console.log('❌ Unexpected error:', error.message)
}
}
console.log('\n📝 Test: Hook that removes both "html" and "text"...')
mailingService.config.beforeSend = async (options: any, email: any) => {
delete options.html
delete options.text
return options
}
try {
await mailingService.processEmails()
console.log('❌ Should have thrown error for missing content')
} catch (error: any) {
if (error.message.includes('must not remove both "html" and "text" properties')) {
console.log('✅ Correctly caught missing content fields')
} else {
console.log('❌ Unexpected error:', error.message)
}
}
// Restore original hook
mailingService.config.beforeSend = originalBeforeSend
console.log('\n✅ All validation tests completed!\n')
// Clean up
await payload.delete({
collection: 'emails',
id: email.id
})
process.exit(0)
}
testHookValidation().catch(console.error)

View File

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

@@ -49,6 +49,13 @@ const Emails: CollectionConfig = {
description: 'Sender email address (optional, uses default if not provided)', description: 'Sender email address (optional, uses default if not provided)',
}, },
}, },
{
name: 'fromName',
type: 'text',
admin: {
description: 'Sender display name (optional, e.g., "John Doe" for "John Doe <john@example.com>")',
},
},
{ {
name: 'replyTo', name: 'replyTo',
type: 'text', type: 'text',

View File

@@ -26,4 +26,7 @@ export {
processEmails, processEmails,
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,35 +1,14 @@
import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js' import { processEmailsJob } from './processEmailsTask.js'
import { sendEmailJob } from './sendEmailTask.js' import { sendEmailJob } from './sendEmailTask.js'
import { MailingService } from '../services/MailingService.js'
/**
* All mailing-related jobs that get registered with Payload
*/
export const mailingJobs = [ export const mailingJobs = [
{ processEmailsJob,
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',
},
sendEmailJob, sendEmailJob,
] ]
export * from './processEmailsJob.js' // Re-export everything from individual job files
export * from './sendEmailTask.js' 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,93 @@
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
try {
console.log('🔄 Processing email queue (pending + failed emails)...')
// Use the shared email processing logic
await processAllEmails(payload)
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 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,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
@@ -15,8 +16,12 @@ export interface SendEmailTaskInput {
to: string | string[] to: string | string[]
cc?: string | string[] cc?: string | string[]
bcc?: string | string[] bcc?: string | string[]
scheduledAt?: string // ISO date string from?: string
fromName?: string
replyTo?: string
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
@@ -39,10 +44,10 @@ 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', '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 => {
@@ -51,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]
} }
}) })
@@ -61,10 +66,23 @@ function transformTaskInputToSendEmailOptions(taskInput: SendEmailTaskInput) {
return sendEmailOptions return sendEmailOptions
} }
/**
* Job definition for sending emails
* Can be used through Payload's job queue system to send emails programmatically
*/
export const sendEmailJob = { 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,
@@ -135,12 +153,37 @@ export const sendEmailJob = {
description: 'Optional comma-separated list of BCC email addresses' 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', name: 'scheduledAt',
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
} }
}, },
{ {
@@ -164,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
@@ -172,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 {
output: {
success: true,
id: email.id,
status: 'sent',
processedImmediately: true
}
}
}
return { return {
output: { output: {
success: true, success: true,
id: email.id, 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

@@ -143,6 +143,10 @@ export interface Email {
* Sender email address (optional, uses default if not provided) * Sender email address (optional, uses default if not provided)
*/ */
from?: string | null; from?: string | null;
/**
* Sender display name (optional, e.g., "John Doe" for "John Doe <john@example.com>")
*/
fromName?: string | null;
/** /**
* Reply-to email address * Reply-to email address
*/ */
@@ -336,6 +340,7 @@ export interface EmailsSelect<T extends boolean = true> {
cc?: T; cc?: T;
bcc?: T; bcc?: T;
from?: T; from?: T;
fromName?: T;
replyTo?: T; replyTo?: T;
subject?: T; subject?: T;
html?: T; html?: T;

View File

@@ -9,12 +9,6 @@ import { mailingJobs, scheduleEmailsJob } from './jobs/index.js'
export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => { export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
const queueName = pluginConfig.queue || 'default' 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 // Handle templates collection configuration
const templatesConfig = pluginConfig.collections?.templates const templatesConfig = pluginConfig.collections?.templates
const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates' const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates'
@@ -74,10 +68,15 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
}), }),
} satisfies CollectionConfig } satisfies CollectionConfig
// Filter out any existing collections with the same slugs to prevent duplicates
const existingCollections = (config.collections || []).filter(
(collection) => collection.slug !== templatesSlug && collection.slug !== emailsSlug
)
return { return {
...config, ...config,
collections: [ collections: [
...(config.collections || []), ...existingCollections,
templatesCollection, templatesCollection,
emailsCollection, emailsCollection,
], ],
@@ -107,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

@@ -83,23 +83,51 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
if (emailData.to) { if (emailData.to) {
emailData.to = parseAndValidateEmails(emailData.to as string | string[]) emailData.to = parseAndValidateEmails(emailData.to as string | string[])
} }
if (emailData.cc && emailData.cc !== null) { if (emailData.cc) {
emailData.cc = parseAndValidateEmails(emailData.cc as string | string[]) emailData.cc = parseAndValidateEmails(emailData.cc as string | string[])
} }
if (emailData.bcc && emailData.bcc !== null) { if (emailData.bcc) {
emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[]) emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[])
} }
if (emailData.replyTo && emailData.replyTo !== null) { if (emailData.replyTo) {
const validated = parseAndValidateEmails(emailData.replyTo as string | string[]) const validated = parseAndValidateEmails(emailData.replyTo as string | string[])
// replyTo should be a single email, so take the first one if array // replyTo should be a single email, so take the first one if array
emailData.replyTo = validated && validated.length > 0 ? validated[0] : undefined emailData.replyTo = validated && validated.length > 0 ? validated[0] : undefined
} }
if (emailData.from && emailData.from !== null) { if (emailData.from) {
const validated = parseAndValidateEmails(emailData.from as string | string[]) const validated = parseAndValidateEmails(emailData.from as string | string[])
// from should be a single email, so take the first one if array // from should be a single email, so take the first one if array
emailData.from = validated && validated.length > 0 ? validated[0] : undefined emailData.from = validated && validated.length > 0 ? validated[0] : undefined
} }
// Sanitize fromName to prevent header injection
if (emailData.fromName) {
emailData.fromName = emailData.fromName
.trim()
// Remove/replace newlines and carriage returns to prevent header injection
.replace(/[\r\n]/g, ' ')
// Remove control characters (except space and printable characters)
.replace(/[\x00-\x1F\x7F-\x9F]/g, '')
// Note: We don't escape quotes here as that's handled in MailingService
}
// Normalize Date objects to ISO strings for consistent database storage
if (emailData.scheduledAt instanceof Date) {
emailData.scheduledAt = emailData.scheduledAt.toISOString()
}
if (emailData.sentAt instanceof Date) {
emailData.sentAt = emailData.sentAt.toISOString()
}
if (emailData.lastAttemptAt instanceof Date) {
emailData.lastAttemptAt = emailData.lastAttemptAt.toISOString()
}
if (emailData.createdAt instanceof Date) {
emailData.createdAt = emailData.createdAt.toISOString()
}
if (emailData.updatedAt instanceof Date) {
emailData.updatedAt = emailData.updatedAt.toISOString()
}
// Create the email in the collection with proper typing // Create the email in the collection with proper typing
const email = await payload.create({ const email = await payload.create({
collection: collectionSlug, collection: collectionSlug,

View File

@@ -1,11 +1,9 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import { Liquid } from 'liquidjs' import { Liquid } from 'liquidjs'
import nodemailer, { Transporter } from 'nodemailer'
import { import {
MailingPluginConfig, MailingPluginConfig,
TemplateVariables, TemplateVariables,
MailingService as IMailingService, MailingService as IMailingService,
MailingTransportConfig,
BaseEmail, BaseEmailTemplate, BaseEmailDocument, BaseEmailTemplateDocument BaseEmail, BaseEmailTemplate, BaseEmailDocument, BaseEmailTemplateDocument
} from '../types/index.js' } from '../types/index.js'
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js' import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
@@ -13,11 +11,10 @@ import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richT
export class MailingService implements IMailingService { export class MailingService implements IMailingService {
public payload: Payload public payload: Payload
private config: MailingPluginConfig private config: MailingPluginConfig
private transporter!: Transporter | any private emailAdapter: any
private templatesCollection: string private templatesCollection: string
private emailsCollection: string private emailsCollection: string
private liquid: Liquid | null | false = null private liquid: Liquid | null | false = null
private transporterInitialized = false
constructor(payload: Payload, config: MailingPluginConfig) { constructor(payload: Payload, config: MailingPluginConfig) {
this.payload = payload this.payload = payload
@@ -29,49 +26,55 @@ export class MailingService implements IMailingService {
const emailsConfig = config.collections?.emails const emailsConfig = config.collections?.emails
this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails' this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails'
// Only initialize transporter if payload is properly set // Use Payload's configured email adapter
if (payload && payload.db) { if (!this.payload.email) {
this.initializeTransporter() throw new Error('Payload email configuration is required. Please configure email in your Payload config.')
} }
} this.emailAdapter = this.payload.email
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
} }
private ensureInitialized(): void { private ensureInitialized(): void {
if (!this.payload || !this.payload.db) { if (!this.payload || !this.payload.db) {
throw new Error('MailingService payload not properly initialized') throw new Error('MailingService payload not properly initialized')
} }
if (!this.transporterInitialized) { if (!this.emailAdapter) {
this.initializeTransporter() throw new Error('Email adapter not configured. Please ensure Payload has email configured.')
} }
} }
/**
* Sanitizes a display name for use in email headers to prevent header injection
* and ensure proper formatting
*/
private sanitizeDisplayName(name: string): string {
return name
.trim()
// Remove/replace newlines and carriage returns to prevent header injection
.replace(/[\r\n]/g, ' ')
// Remove control characters (except space and printable characters)
.replace(/[\x00-\x1F\x7F-\x9F]/g, '')
// Escape quotes to prevent malformed headers
.replace(/"/g, '\\"')
}
/**
* Formats an email address with optional display name
*/
private formatEmailAddress(email: string, displayName?: string | null): string {
if (displayName && displayName.trim()) {
const sanitizedName = this.sanitizeDisplayName(displayName)
return `"${sanitizedName}" <${email}>`
}
return email
}
private getDefaultFrom(): string { private getDefaultFrom(): string {
const fromEmail = this.config.defaultFrom const fromEmail = this.config.defaultFrom
const fromName = this.config.defaultFromName const fromName = this.config.defaultFromName
// Check if fromName exists, is not empty after trimming, and fromEmail exists // Check if fromName exists, is not empty after trimming, and fromEmail exists
if (fromName && fromName.trim() && fromEmail) { if (fromName && fromName.trim() && fromEmail) {
// Escape quotes in the display name to prevent malformed headers return this.formatEmailAddress(fromEmail, fromName)
const escapedName = fromName.replace(/"/g, '\\"')
return `"${escapedName}" <${fromEmail}>`
} }
return fromEmail || '' return fromEmail || ''
@@ -222,7 +225,7 @@ export class MailingService implements IMailingService {
} }
} }
private async processEmailItem(emailId: string): Promise<void> { async processEmailItem(emailId: string): Promise<void> {
try { try {
await this.payload.update({ await this.payload.update({
collection: this.emailsCollection as any, collection: this.emailsCollection as any,
@@ -238,8 +241,16 @@ export class MailingService implements IMailingService {
id: emailId, id: emailId,
}) as BaseEmailDocument }) as BaseEmailDocument
const mailOptions = { // Combine from and fromName for nodemailer using proper sanitization
from: email.from, let fromField: string
if (email.from) {
fromField = this.formatEmailAddress(email.from, email.fromName)
} else {
fromField = this.getDefaultFrom()
}
let mailOptions: any = {
from: fromField,
to: email.to, to: email.to,
cc: email.cc || undefined, cc: email.cc || undefined,
bcc: email.bcc || undefined, bcc: email.bcc || undefined,
@@ -249,7 +260,32 @@ export class MailingService implements IMailingService {
text: email.text || undefined, text: email.text || undefined,
} }
await this.transporter.sendMail(mailOptions) // Call beforeSend hook if configured
if (this.config.beforeSend) {
try {
mailOptions = await this.config.beforeSend(mailOptions, email)
// Validate required properties remain intact after hook execution
if (!mailOptions.from) {
throw new Error('beforeSend hook must not remove the "from" property')
}
if (!mailOptions.to || (Array.isArray(mailOptions.to) && mailOptions.to.length === 0)) {
throw new Error('beforeSend hook must not remove or empty the "to" property')
}
if (!mailOptions.subject) {
throw new Error('beforeSend hook must not remove the "subject" property')
}
if (!mailOptions.html && !mailOptions.text) {
throw new Error('beforeSend hook must not remove both "html" and "text" properties')
}
} catch (error) {
console.error('Error in beforeSend hook:', error)
throw new Error(`beforeSend hook failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
// Send email using Payload's email adapter
await this.emailAdapter.sendEmail(mailOptions)
await this.payload.update({ await this.payload.update({
collection: this.emailsCollection as any, collection: this.emailsCollection as any,

View File

@@ -1,6 +1,5 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import type { CollectionConfig, RichTextField } from 'payload' import type { CollectionConfig, RichTextField } from 'payload'
import { Transporter } from 'nodemailer'
// JSON value type that matches Payload's JSON field type // JSON value type that matches Payload's JSON field type
export type JSONValue = string | number | boolean | { [k: string]: unknown } | unknown[] | null | undefined export type JSONValue = string | number | boolean | { [k: string]: unknown } | unknown[] | null | undefined
@@ -13,20 +12,21 @@ export interface BaseEmailDocument {
cc?: string[] | null cc?: string[] | null
bcc?: string[] | null bcc?: string[] | null
from?: string | null from?: string | null
fromName?: string | null
replyTo?: string | null replyTo?: string | null
subject: string subject: string
html: string html: string
text?: string | null text?: string | null
variables?: JSONValue variables?: JSONValue
scheduledAt?: string | null scheduledAt?: string | Date | null
sentAt?: string | null sentAt?: string | Date | null
status?: 'pending' | 'processing' | 'sent' | 'failed' | null status?: 'pending' | 'processing' | 'sent' | 'failed' | null
attempts?: number | null attempts?: number | null
lastAttemptAt?: string | null lastAttemptAt?: string | Date | null
error?: string | null error?: string | null
priority?: number | null priority?: number | null
createdAt?: string | null createdAt?: string | Date | null
updatedAt?: string | null updatedAt?: string | Date | null
} }
export interface BaseEmailTemplateDocument { export interface BaseEmailTemplateDocument {
@@ -35,8 +35,8 @@ export interface BaseEmailTemplateDocument {
slug: string slug: string
subject?: string | null subject?: string | null
content?: any content?: any
createdAt?: string | null createdAt?: string | Date | null
updatedAt?: string | null updatedAt?: string | Date | null
} }
export type BaseEmail<TEmail extends BaseEmailDocument = BaseEmailDocument, TEmailTemplate extends BaseEmailTemplateDocument = BaseEmailTemplateDocument> = Omit<TEmail, 'id' | 'template'> & {template: Omit<TEmailTemplate, 'id'> | TEmailTemplate['id'] | undefined | null} export type BaseEmail<TEmail extends BaseEmailDocument = BaseEmailDocument, TEmailTemplate extends BaseEmailTemplateDocument = BaseEmailTemplateDocument> = Omit<TEmail, 'id' | 'template'> & {template: Omit<TEmailTemplate, 'id'> | TEmailTemplate['id'] | undefined | null}
@@ -47,6 +47,21 @@ export type TemplateRendererHook = (template: string, variables: Record<string,
export type TemplateEngine = 'liquidjs' | 'mustache' | 'simple' export type TemplateEngine = 'liquidjs' | 'mustache' | 'simple'
export interface BeforeSendMailOptions {
from: string
to: string[]
cc?: string[]
bcc?: string[]
replyTo?: string
subject: string
html: string
text?: string
attachments?: any[]
[key: string]: any
}
export type BeforeSendHook = (options: BeforeSendMailOptions, email: BaseEmailDocument) => BeforeSendMailOptions | Promise<BeforeSendMailOptions>
export interface MailingPluginConfig { export interface MailingPluginConfig {
collections?: { collections?: {
templates?: string | Partial<CollectionConfig> templates?: string | Partial<CollectionConfig>
@@ -54,28 +69,17 @@ export interface MailingPluginConfig {
} }
defaultFrom?: string defaultFrom?: string
defaultFromName?: string defaultFromName?: string
transport?: Transporter | MailingTransportConfig
queue?: string queue?: string
retryAttempts?: number retryAttempts?: number
retryDelay?: number retryDelay?: number
templateRenderer?: TemplateRendererHook templateRenderer?: TemplateRendererHook
templateEngine?: TemplateEngine templateEngine?: TemplateEngine
richTextEditor?: RichTextField['editor'] richTextEditor?: RichTextField['editor']
beforeSend?: BeforeSendHook
onReady?: (payload: any) => Promise<void> onReady?: (payload: any) => Promise<void>
initOrder?: 'before' | 'after' initOrder?: 'before' | 'after'
} }
export interface MailingTransportConfig {
host: string
port: number
secure?: boolean
auth?: {
user: string
pass: string
}
}
export interface QueuedEmail { export interface QueuedEmail {
id: string id: string
template?: string | null template?: string | null
@@ -83,16 +87,17 @@ export interface QueuedEmail {
cc?: string[] | null cc?: string[] | null
bcc?: string[] | null bcc?: string[] | null
from?: string | null from?: string | null
fromName?: string | null
replyTo?: string | null replyTo?: string | null
subject: string subject: string
html: string html: string
text?: string | null text?: string | null
variables?: JSONValue variables?: JSONValue
scheduledAt?: string | null scheduledAt?: string | Date | null
sentAt?: string | null sentAt?: string | Date | null
status: 'pending' | 'processing' | 'sent' | 'failed' status: 'pending' | 'processing' | 'sent' | 'failed'
attempts: number attempts: number
lastAttemptAt?: string | null lastAttemptAt?: string | Date | null
error?: string | null error?: string | null
priority?: number | null priority?: number | null
createdAt: string createdAt: string
@@ -106,6 +111,7 @@ export interface TemplateVariables {
export interface MailingService { export interface MailingService {
processEmails(): Promise<void> processEmails(): Promise<void>
processEmailItem(emailId: string): Promise<void>
retryFailedEmails(): Promise<void> retryFailedEmails(): Promise<void>
renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }>
} }

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()
}