Compare commits

...

21 Commits

Author SHA1 Message Date
Bas
c78a8c2480 Merge pull request #23 from xtr-dev/dev
Fix TypeScript compatibility with PayloadCMS generated types
2025-09-13 21:10:09 +02:00
a27481c818 Bump package version to 0.1.7 in package.json. 2025-09-13 21:07:22 +02:00
b342f32d97 Simplify null checks in sendEmail validation logic 2025-09-13 21:06:54 +02:00
e1800f5a6e Fix TypeScript compatibility with PayloadCMS generated types
Resolves: TS2344: Type Email does not satisfy the constraint BaseEmailData
- Add null support to BaseEmailData interface for all optional fields
- Update parseAndValidateEmails to handle null values
- Update sendEmail validation to properly check for null values
- Maintain compatibility with PayloadCMS generated types that include null

Generated Email types like cc?: string[] | null | undefined now work correctly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 21:03:21 +02:00
Bas
0c4d894f51 Merge pull request #22 from xtr-dev/dev
Move sendEmail to dedicated file for better visibility
2025-09-13 20:58:13 +02:00
1af54c6573 Bump package version to 0.1.6 in package.json. 2025-09-13 20:55:00 +02:00
24f1f4c5a4 Fix broken development routes and imports
Critical fixes:
- Update dev/app/api/test-email/route.ts to use new sendEmail API instead of deprecated sendEmail/scheduleEmail
- Fix dev/test-plugin.mjs imports to remove scheduleEmail reference
- Update dev/README.md examples to use new sendEmail pattern
- Replace templateId with template.slug throughout dev examples
- Add support for direct HTML emails in test route

The development routes now work correctly with v0.1.5 API changes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:53:54 +02:00
de41f4ecb2 Move sendEmail to dedicated file for better visibility
- Extract sendEmail function to src/sendEmail.ts as primary module
- Export BaseEmailData and SendEmailOptions interfaces alongside
- Update all imports to use new location
- Add sendEmailDefault export for CommonJS compatibility
- Export parseAndValidateEmails for external utility use
- Updated README to highlight sendEmail as primary export

Breaking change: BaseEmailData and SendEmailOptions now imported from main module, not utils/helpers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:46:38 +02:00
Bas
6d4e020133 Merge pull request #21 from xtr-dev/dev
Dev
2025-09-13 20:39:44 +02:00
25838bcba4 Bump package version to 0.1.5 in package.json. 2025-09-13 20:37:20 +02:00
dfa833fa5e Eliminate code duplication between helpers and jobs
- Extract parseAndValidateEmails() as shared utility function
- Refactor sendEmailJob to use sendEmail helper internally
- Remove 100+ lines of duplicated validation and processing logic
- Maintain single source of truth for email handling logic
- Cleaner, more maintainable codebase with DRY principles

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:36:08 +02:00
cb5ce2e720 Add type-safe sendEmail helper with generics
- New sendEmail<T>() helper that extends BaseEmailData for full type safety
- Supports both template-based and direct HTML emails
- Automatic email validation and address parsing
- Merges template output with custom data fields
- Full TypeScript autocomplete for custom Email collection fields
- Updated README with comprehensive examples and API reference
- Exports BaseEmailData and SendEmailOptions types for external use

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:30:55 +02:00
f8b7dd8f4c Remove WIP comments from README 2025-09-13 20:23:53 +02:00
Bas
b3de54b953 Merge pull request #20 from xtr-dev/dev
Simplify job system architecture
2025-09-13 20:16:10 +02:00
186c340d96 Bump package version to 0.1.4 in package.json. 2025-09-13 20:14:59 +02:00
08b4d49019 Simplify job system architecture
- Replace createMailingJobs() function with static mailingJobs array
- Remove complex initialization dependencies and function wrappers
- Jobs now get MailingService from payload context instead of factory injection
- Fix PayloadCMS task handler return types to use proper {output: {}} format
- Eliminate potential initialization race conditions
- Cleaner, more straightforward job registration process

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:12:14 +02:00
Bas
ed058c0721 Merge pull request #19 from xtr-dev/dev
Dev
2025-09-13 19:23:08 +02:00
6db27093d1 Fix critical bugs and improve type safety
- Fix hard-coded collection name in sendEmailTask - now uses configurable collection name
- Add type validation for task input with proper error handling
- Add email format validation with regex to prevent invalid email addresses
- Fix potential memory leak in plugin initialization by properly initializing MailingService
- Add runtime validation for required fields
- Improve error messages and validation feedback

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 19:15:55 +02:00
43557c9a03 Consolidate and simplify email job system
- Replace inline plugin task with jobs directory system
- Move sendTemplateEmailTask to jobs/sendEmailTask.ts and integrate with createMailingJobs()
- Simplify processEmailsJob to always process both pending and failed emails in one task
- Remove separate 'retry-failed' task type - retry logic now runs automatically
- Update MailingService to support lazy initialization for job context
- Update exports to include consolidated job system

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 19:10:32 +02:00
2deefc8eaa FEATURE: Add PayloadCMS task for queuing template emails
- Add sendTemplateEmailTask with comprehensive input schema
- Support template rendering, email parsing, and scheduling
- Include TypeScript interface SendTemplateEmailInput for type safety
- Add task to exports for easy import and usage
- Support custom email collection fields via extensible input
- Add comprehensive documentation with usage examples

Users can now:
 Import and add task to their Payload jobs configuration
 Queue emails programmatically via payload.jobs.queue()
 Use admin panel form interface for manual email queuing
 Get full TypeScript support with proper input types
 Extend with custom fields from their email collection

Example usage:
```typescript
import { sendTemplateEmailTask } from '@xtr-dev/payload-mailing'

// Add to Payload config
export default buildConfig({
  jobs: { tasks: [sendTemplateEmailTask] }
})

// Queue from code
await payload.jobs.queue({
  task: 'send-template-email',
  input: {
    templateSlug: 'welcome',
    to: ['user@example.com'],
    variables: { name: 'John' }
  }
})
```

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:51:46 +02:00
12952ad41c Add pre-release warning to README
- Highlight active development status (v
2025-09-13 18:41:28 +02:00
13 changed files with 621 additions and 205 deletions

186
README.md
View File

@@ -2,7 +2,7 @@
📧 **Template-based email system with scheduling and job processing for PayloadCMS**
**Simplified API**: Starting from v0.1.0, this plugin uses a simplified API that leverages PayloadCMS collections directly for better type safety and flexibility.
⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
## Features
@@ -56,53 +56,55 @@ export default buildConfig({
})
```
### 2. Send emails using Payload collections
### 2. Send emails with type-safe helper
```typescript
import { renderTemplate } from '@xtr-dev/payload-mailing'
// sendEmail is a primary export for easy access
import { sendEmail } from '@xtr-dev/payload-mailing'
import { Email } from './payload-types' // Your generated types
// Option 1: Using templates with variables
const { html, text, subject } = await renderTemplate(payload, 'welcome-email', {
firstName: 'John',
welcomeUrl: 'https://yoursite.com/welcome'
})
// Create email using Payload's collection API (full type safety!)
const email = await payload.create({
collection: 'emails',
// Option 1: Using templates with full type safety
const email = await sendEmail<Email>(payload, {
template: {
slug: 'welcome-email',
variables: {
firstName: 'John',
welcomeUrl: 'https://yoursite.com/welcome'
}
},
data: {
to: ['user@example.com'],
subject,
html,
text,
to: 'user@example.com',
// Schedule for later (optional)
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
// Add any custom fields you've defined
priority: 1,
customField: 'your-value', // Your custom collection fields work!
// Your custom fields are type-safe!
customField: 'your-value',
}
})
// Option 2: Direct HTML email (no template needed)
const directEmail = await payload.create({
collection: 'emails',
// Option 2: Direct HTML email (no template)
const directEmail = await sendEmail<Email>(payload, {
data: {
to: ['user@example.com'],
to: ['user@example.com', 'another@example.com'],
subject: 'Welcome!',
html: '<h1>Welcome John!</h1><p>Thanks for joining!</p>',
text: 'Welcome John! Thanks for joining!',
// All your custom fields work with TypeScript autocomplete!
customField: 'value',
}
})
// Option 3: Use payload.create() directly for full control
const manualEmail = await payload.create({
collection: 'emails',
data: {
to: ['user@example.com'],
subject: 'Hello',
html: '<p>Hello World</p>',
}
})
```
## Why This Approach is Better
-**Full Type Safety**: Use your generated Payload types
-**No Learning Curve**: Just use `payload.create()` like any collection
-**Maximum Flexibility**: Add any custom fields to your email collection
-**Payload Integration**: Leverage validation, hooks, access control
-**Consistent API**: One way to create data in Payload
## Configuration
### Plugin Options
@@ -598,6 +600,81 @@ await processEmails(payload)
await retryFailedEmails(payload)
```
## PayloadCMS Task Integration
The plugin provides a ready-to-use PayloadCMS task for queuing template emails:
### 1. Add the task to your Payload config
```typescript
import { buildConfig } from 'payload/config'
import { sendTemplateEmailTask } from '@xtr-dev/payload-mailing'
export default buildConfig({
// ... your config
jobs: {
tasks: [
sendTemplateEmailTask,
// ... your other tasks
]
}
})
```
### 2. Queue emails from your code
```typescript
import type { SendTemplateEmailInput } from '@xtr-dev/payload-mailing'
// Queue a template email
const result = await payload.jobs.queue({
task: 'send-template-email',
input: {
templateSlug: 'welcome-email',
to: ['user@example.com'],
cc: ['manager@example.com'],
variables: {
firstName: 'John',
activationUrl: 'https://example.com/activate/123'
},
priority: 1,
// Add any custom fields from your email collection
customField: 'value'
} as SendTemplateEmailInput
})
// Queue a scheduled email
await payload.jobs.queue({
task: 'send-template-email',
input: {
templateSlug: 'reminder-email',
to: ['user@example.com'],
variables: { eventName: 'Product Launch' },
scheduledAt: new Date('2024-01-15T10:00:00Z').toISOString(),
priority: 3
}
})
```
### 3. Use in admin panel workflows
The task can also be triggered from the Payload admin panel with a user-friendly form interface that includes:
- Template slug selection
- Email recipients (to, cc, bcc)
- Template variables as JSON
- Optional scheduling
- Priority setting
- Any custom fields you've added to your email collection
### Task Benefits
-**Easy Integration**: Just add to your tasks array
-**Type Safety**: Full TypeScript support with `SendTemplateEmailInput`
-**Admin UI**: Ready-to-use form interface
-**Flexible**: Supports all your custom email collection fields
-**Error Handling**: Comprehensive error reporting
-**Queue Management**: Leverage Payload's job queue system
## Job Processing
The plugin automatically adds a unified email processing job to PayloadCMS:
@@ -773,6 +850,55 @@ import {
} from '@xtr-dev/payload-mailing'
```
## API Reference
### `sendEmail<T>(payload, options)`
Type-safe email sending with automatic template rendering and validation.
```typescript
import { sendEmail } from '@xtr-dev/payload-mailing'
import { Email } from './payload-types'
const email = await sendEmail<Email>(payload, {
template: {
slug: 'template-slug',
variables: { /* template variables */ }
},
data: {
to: 'user@example.com',
// Your custom fields are type-safe here!
}
})
```
**Type Parameters:**
- `T extends BaseEmailData` - Your generated Email type for full type safety
**Options:**
- `template.slug` - Template slug to render
- `template.variables` - Variables to pass to template
- `data` - Email data (merged with template output)
- `collectionSlug` - Custom collection name (defaults to 'emails')
### `renderTemplate(payload, slug, variables)`
Render an email template without sending.
```typescript
const { html, text, subject } = await renderTemplate(
payload,
'welcome-email',
{ name: 'John' }
)
```
### Helper Functions
- `getMailing(payload)` - Get mailing context
- `processEmails(payload)` - Manually trigger email processing
- `retryFailedEmails(payload)` - Manually retry failed emails
## Migration Guide (v0.0.x → v0.1.0)
**🚨 BREAKING CHANGES**: The API has been simplified to use Payload collections directly.

View File

@@ -184,25 +184,43 @@ The plugin automatically processes the outbox every 5 minutes and retries failed
## Plugin API Usage
```javascript
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
import { sendEmail } from '@xtr-dev/payload-mailing'
// Send immediate email
const emailId = await sendEmail(payload, {
templateId: 'welcome-template-id',
to: 'user@example.com',
variables: {
firstName: 'John',
siteName: 'My App'
// Send immediate email with template
const email = await sendEmail(payload, {
template: {
slug: 'welcome-email',
variables: {
firstName: 'John',
siteName: 'My App'
}
},
data: {
to: 'user@example.com',
}
})
// Schedule email
const scheduledId = await scheduleEmail(payload, {
templateId: 'reminder-template-id',
to: 'user@example.com',
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
variables: {
eventName: 'Product Launch'
// Schedule email for later
const scheduledEmail = await sendEmail(payload, {
template: {
slug: 'reminder',
variables: {
eventName: 'Product Launch'
}
},
data: {
to: 'user@example.com',
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
}
})
// Send direct HTML email (no template)
const directEmail = await sendEmail(payload, {
data: {
to: 'user@example.com',
subject: 'Direct Email',
html: '<h1>Hello World</h1>',
text: 'Hello World'
}
})
```

View File

@@ -1,37 +1,50 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
import { sendEmail } from '@xtr-dev/payload-mailing'
export async function POST(request: Request) {
try {
const payload = await getPayload({ config })
const body = await request.json()
const { type = 'send', templateSlug, to, variables, scheduledAt } = body
const { type = 'send', templateSlug, to, variables, scheduledAt, subject, html, text } = body
let result
if (type === 'send') {
// Send immediately
result = await sendEmail(payload, {
templateSlug,
// Use the new sendEmail API
const emailOptions: any = {
data: {
to,
variables,
})
} else if (type === 'schedule') {
// Schedule for later
result = await scheduleEmail(payload, {
templateSlug,
to,
variables,
scheduledAt: scheduledAt ? new Date(scheduledAt) : new Date(Date.now() + 60000), // Default to 1 minute
})
} else {
return Response.json({ error: 'Invalid type. Use "send" or "schedule"' }, { status: 400 })
}
}
// Add template if provided
if (templateSlug) {
emailOptions.template = {
slug: templateSlug,
variables: variables || {}
}
} else if (subject && html) {
// Direct email without template
emailOptions.data.subject = subject
emailOptions.data.html = html
if (text) {
emailOptions.data.text = text
}
} else {
return Response.json({
error: 'Either templateSlug or subject+html must be provided'
}, { status: 400 })
}
// Add scheduling if needed
if (type === 'schedule' || scheduledAt) {
emailOptions.data.scheduledAt = scheduledAt ? new Date(scheduledAt) : new Date(Date.now() + 60000)
}
const result = await sendEmail(payload, emailOptions)
return Response.json({
success: true,
emailId: result,
message: type === 'send' ? 'Email sent successfully' : 'Email scheduled successfully',
emailId: result.id,
message: scheduledAt ? 'Email scheduled successfully' : 'Email queued successfully',
})
} catch (error) {
console.error('Test email error:', error)

View File

@@ -1,10 +1,10 @@
// Simple test to verify plugin can be imported and initialized
import { mailingPlugin, sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
import { mailingPlugin, sendEmail, renderTemplate } from '@xtr-dev/payload-mailing'
console.log('✅ Plugin imports successfully')
console.log('✅ mailingPlugin:', typeof mailingPlugin)
console.log('✅ sendEmail:', typeof sendEmail)
console.log('✅ scheduleEmail:', typeof scheduleEmail)
console.log('✅ renderTemplate:', typeof renderTemplate)
// Test plugin configuration
try {

View File

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

View File

@@ -11,7 +11,13 @@ export { MailingService } from './services/MailingService.js'
export { default as EmailTemplates, createEmailTemplatesCollection } from './collections/EmailTemplates.js'
export { default as Emails } from './collections/Emails.js'
// Jobs are integrated into the plugin configuration
// Jobs (includes the send email task)
export { mailingJobs, sendEmailJob } from './jobs/index.js'
export type { SendEmailTaskInput } from './jobs/sendEmailTask.js'
// Main email sending function
export { sendEmail, type BaseEmailData, type SendEmailOptions } from './sendEmail.js'
export { default as sendEmailDefault } from './sendEmail.js'
// Utility functions for developers
export {
@@ -19,4 +25,5 @@ export {
renderTemplate,
processEmails,
retryFailedEmails,
parseAndValidateEmails,
} from './utils/helpers.js'

View File

@@ -1,19 +1,35 @@
import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js'
import { sendEmailJob } from './sendEmailTask.js'
import { MailingService } from '../services/MailingService.js'
export const createMailingJobs = (mailingService: MailingService): any[] => {
return [
{
slug: 'processEmails',
handler: async ({ job, req }: { job: any; req: any }) => {
return processEmailsJob(
job as { data: ProcessEmailsJobData },
{ req, mailingService }
)
},
interfaceName: 'ProcessEmailsJob',
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',
},
sendEmailJob,
]
export * from './processEmailsJob.js'
export * from './sendEmailTask.js'

View File

@@ -2,7 +2,7 @@ import type { PayloadRequest } from 'payload'
import { MailingService } from '../services/MailingService.js'
export interface ProcessEmailsJobData {
type: 'process-emails' | 'retry-failed'
// No type needed - always processes both pending and failed emails
}
export const processEmailsJob = async (
@@ -10,18 +10,19 @@ export const processEmailsJob = async (
context: { req: PayloadRequest; mailingService: MailingService }
) => {
const { mailingService } = context
const { type } = job.data
try {
if (type === 'process-emails') {
await mailingService.processEmails()
console.log('Email processing completed successfully')
} else if (type === 'retry-failed') {
await mailingService.retryFailedEmails()
console.log('Failed email retry completed successfully')
}
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(`${type} job failed:`, error)
console.error('❌ Email queue processing failed:', error)
throw error
}
}
@@ -29,7 +30,6 @@ export const processEmailsJob = async (
export const scheduleEmailsJob = async (
payload: any,
queueName: string,
jobType: 'process-emails' | 'retry-failed',
delay?: number
) => {
if (!payload.jobs) {
@@ -41,10 +41,10 @@ export const scheduleEmailsJob = async (
await payload.jobs.queue({
queue: queueName,
task: 'processEmails',
input: { type: jobType },
input: {},
waitUntil: delay ? new Date(Date.now() + delay) : undefined,
})
} catch (error) {
console.error(`Failed to schedule ${jobType} job:`, error)
console.error('Failed to schedule email processing job:', error)
}
}

178
src/jobs/sendEmailTask.ts Normal file
View File

@@ -0,0 +1,178 @@
import { sendEmail, type BaseEmailData } from '../sendEmail.js'
export interface SendEmailTaskInput {
// 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[]
scheduledAt?: string // ISO date string
priority?: number
// Allow any additional fields that users might have in their email collection
[key: string]: any
}
export const sendEmailJob = {
slug: 'send-email',
label: 'Send Email',
inputSchema: [
{
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: 'scheduledAt',
type: 'date' as const,
label: 'Schedule For',
admin: {
description: 'Optional date/time to schedule email for future delivery'
}
},
{
name: 'priority',
type: 'number' as const,
label: 'Priority',
min: 1,
max: 10,
defaultValue: 5,
admin: {
description: 'Email priority (1 = highest, 10 = lowest)'
}
}
],
handler: async ({ input, payload }: any) => {
// Cast input to our expected type
const taskInput = input as SendEmailTaskInput
try {
// Prepare options for sendEmail based on task input
const sendEmailOptions: any = {
data: {}
}
// If using template mode
if (taskInput.templateSlug) {
sendEmailOptions.template = {
slug: taskInput.templateSlug,
variables: taskInput.variables || {}
}
}
// Build data object from task input
const dataFields = ['to', 'cc', 'bcc', 'subject', 'html', 'text', 'scheduledAt', 'priority']
const additionalFields: string[] = []
// Copy standard fields
dataFields.forEach(field => {
if (taskInput[field] !== undefined) {
sendEmailOptions.data[field] = taskInput[field]
}
})
// Copy any additional custom fields
Object.keys(taskInput).forEach(key => {
if (!['templateSlug', 'variables', ...dataFields].includes(key)) {
sendEmailOptions.data[key] = taskInput[key]
additionalFields.push(key)
}
})
// Use the sendEmail helper to create the email
const email = await sendEmail<BaseEmailData>(payload, sendEmailOptions)
return {
output: {
success: true,
emailId: email.id,
message: `Email queued successfully with ID: ${email.id}`,
mode: taskInput.templateSlug ? 'template' : 'direct',
templateSlug: taskInput.templateSlug || null,
subject: email.subject,
recipients: Array.isArray(email.to) ? email.to.length : 1,
scheduledAt: email.scheduledAt || null
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
throw new Error(`Failed to queue email: ${errorMessage}`)
}
}
}
export default sendEmailJob

View File

@@ -3,53 +3,8 @@ import { MailingPluginConfig, MailingContext } from './types/index.js'
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'
// Helper function to schedule the email processing job
async function scheduleEmailProcessingJob(payload: any, queueName: string, delayMs: number = 60000): Promise<boolean> {
if (!queueName || typeof queueName !== 'string') {
throw new Error('Invalid queueName: must be a non-empty string')
}
const jobSlug = 'process-email-queue'
// Check if there's already a scheduled job for this task
const existingJobs = await payload.find({
collection: 'payload-jobs',
where: {
and: [
{
taskSlug: {
equals: jobSlug,
},
},
{
hasCompleted: {
equals: false,
},
},
],
},
limit: 1,
})
// If no existing job, schedule a new one
if (existingJobs.docs.length === 0) {
await payload.create({
collection: 'payload-jobs',
data: {
taskSlug: jobSlug,
input: {},
queue: queueName,
waitUntil: new Date(Date.now() + delayMs),
},
})
console.log(`🔄 Scheduled email processing job in queue: ${queueName}`)
return true
} else {
console.log(`✅ Email processing job already scheduled in queue: ${queueName}`)
return false
}
}
export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
const queueName = pluginConfig.queue || 'default'
@@ -59,6 +14,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
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'
@@ -129,61 +85,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
...(config.jobs || {}),
tasks: [
...(config.jobs?.tasks || []),
{
slug: 'process-email-queue',
handler: async ({ job, req }: { job: any; req: any }) => {
const payload = (req as any).payload
let jobResult = null
try {
const mailingService = new MailingService(payload, pluginConfig)
console.log('🔄 Processing email queue (pending + failed emails)...')
// Process pending emails first
await mailingService.processEmails()
// Then retry failed emails
await mailingService.retryFailedEmails()
jobResult = {
output: {
success: true,
message: 'Email queue processed successfully (pending and failed emails)'
}
}
console.log('✅ Email queue processing completed successfully')
} catch (error) {
console.error('❌ Error processing email queue:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
jobResult = new Error(`Email queue processing failed: ${errorMessage}`)
}
// Always reschedule the next job (success or failure) using duplicate prevention
let rescheduled = false
try {
rescheduled = await scheduleEmailProcessingJob(payload, queueName, 300000) // Reschedule in 5 minutes
if (rescheduled) {
console.log(`🔄 Rescheduled next email processing job in ${queueName} queue`)
}
} catch (rescheduleError) {
console.error('❌ Failed to reschedule email processing job:', rescheduleError)
// If rescheduling fails, we should warn but not fail the current job
// since the email processing itself may have succeeded
console.warn('⚠️ Email processing completed but next job could not be scheduled')
}
// Return the original result or throw the error
if (jobResult instanceof Error) {
throw jobResult
}
return jobResult
},
interfaceName: 'ProcessEmailQueueJob',
},
...mailingJobs,
],
},
onInit: async (payload: any) => {
@@ -191,7 +93,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
await config.onInit(payload)
}
// Initialize mailing service
// Initialize mailing service with proper payload instance
const mailingService = new MailingService(payload, pluginConfig)
// Add mailing context to payload for developer access
@@ -207,9 +109,10 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
console.log('PayloadCMS Mailing Plugin initialized successfully')
// Schedule the email processing job if not already scheduled
// Schedule the initial email processing job
try {
await scheduleEmailProcessingJob(payload, queueName)
await scheduleEmailsJob(payload, queueName, 60000) // Schedule in 1 minute
console.log(`🔄 Scheduled initial email processing job in queue: ${queueName}`)
} catch (error) {
console.error('Failed to schedule email processing job:', error)
}

111
src/sendEmail.ts Normal file
View File

@@ -0,0 +1,111 @@
import { Payload } from 'payload'
import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js'
// Base type for email data that all emails must have
// Compatible with PayloadCMS generated types that include null
export interface BaseEmailData {
to: string | string[]
cc?: string | string[] | null
bcc?: string | string[] | null
subject?: string | null
html?: string | null
text?: string | null
scheduledAt?: string | Date | null
priority?: number | null
[key: string]: any
}
// Options for sending emails
export interface SendEmailOptions<T extends BaseEmailData = BaseEmailData> {
// Template-based email
template?: {
slug: string
variables?: Record<string, any>
}
// Direct email data
data?: Partial<T>
// Common options
collectionSlug?: string // defaults to 'emails'
}
/**
* Send an email with full type safety
*
* @example
* ```typescript
* // With your generated Email type
* import { Email } from './payload-types'
*
* const email = await sendEmail<Email>(payload, {
* template: {
* slug: 'welcome',
* variables: { name: 'John' }
* },
* data: {
* to: 'user@example.com',
* customField: 'value' // Your custom fields are type-safe!
* }
* })
* ```
*/
export const sendEmail = async <T extends BaseEmailData = BaseEmailData>(
payload: Payload,
options: SendEmailOptions<T>
): Promise<T> => {
const mailing = getMailing(payload)
const collectionSlug = options.collectionSlug || mailing.collections.emails || 'emails'
let emailData: Partial<T> = { ...options.data } as Partial<T>
// If using a template, render it first
if (options.template) {
const { html, text, subject } = await renderTemplate(
payload,
options.template.slug,
options.template.variables || {}
)
// Template values take precedence over data values
emailData = {
...emailData,
subject,
html,
text,
} as Partial<T>
}
// Validate required fields
if (!emailData.to) {
throw new Error('Field "to" is required for sending emails')
}
if (!emailData.subject || !emailData.html) {
throw new Error('Fields "subject" and "html" are required when not using a template')
}
// Process email addresses using shared validation (handle null values)
if (emailData.to) {
emailData.to = parseAndValidateEmails(emailData.to as string | string[])
}
if (emailData.cc) {
emailData.cc = parseAndValidateEmails(emailData.cc as string | string[])
}
if (emailData.bcc) {
emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[])
}
// Convert scheduledAt to ISO string if it's a Date
if (emailData.scheduledAt instanceof Date) {
emailData.scheduledAt = emailData.scheduledAt.toISOString()
}
// Create the email in the collection
const email = await payload.create({
collection: collectionSlug as any,
data: emailData as any
})
return email as unknown as T
}
export default sendEmail

View File

@@ -13,12 +13,13 @@ import {
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
export class MailingService implements IMailingService {
private payload: Payload
public payload: Payload
private config: MailingPluginConfig
private transporter!: Transporter | any
private templatesCollection: string
private emailsCollection: string
private liquid: Liquid | null | false = null
private transporterInitialized = false
constructor(payload: Payload, config: MailingPluginConfig) {
this.payload = payload
@@ -30,10 +31,15 @@ export class MailingService implements IMailingService {
const emailsConfig = config.collections?.emails
this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails'
this.initializeTransporter()
// Only initialize transporter if payload is properly set
if (payload && payload.db) {
this.initializeTransporter()
}
}
private initializeTransporter(): void {
if (this.transporterInitialized) return
if (this.config.transport) {
if ('sendMail' in this.config.transport) {
this.transporter = this.config.transport
@@ -46,6 +52,17 @@ export class MailingService implements IMailingService {
} else {
throw new Error('Email transport configuration is required either in plugin config or Payload config')
}
this.transporterInitialized = true
}
private ensureInitialized(): void {
if (!this.payload || !this.payload.db) {
throw new Error('MailingService payload not properly initialized')
}
if (!this.transporterInitialized) {
this.initializeTransporter()
}
}
private getDefaultFrom(): string {
@@ -108,6 +125,7 @@ export class MailingService implements IMailingService {
}
async renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> {
this.ensureInitialized()
const template = await this.getTemplateBySlug(templateSlug)
if (!template) {
@@ -125,6 +143,7 @@ export class MailingService implements IMailingService {
}
async processEmails(): Promise<void> {
this.ensureInitialized()
const currentTime = new Date().toISOString()
const { docs: pendingEmails } = await this.payload.find({
@@ -162,6 +181,7 @@ export class MailingService implements IMailingService {
}
async retryFailedEmails(): Promise<void> {
this.ensureInitialized()
const maxAttempts = this.config.retryAttempts || 3
const retryDelay = this.config.retryDelay || 300000 // 5 minutes
const retryTime = new Date(Date.now() - retryDelay).toISOString()

View File

@@ -1,6 +1,30 @@
import { Payload } from 'payload'
import { TemplateVariables } from '../types/index.js'
/**
* Parse and validate email addresses
* @internal
*/
export const parseAndValidateEmails = (emails: string | string[] | null | undefined): string[] | undefined => {
if (!emails || emails === null) return undefined
let emailList: string[]
if (Array.isArray(emails)) {
emailList = emails
} else {
emailList = emails.split(',').map(email => email.trim()).filter(Boolean)
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const invalidEmails = emailList.filter(email => !emailRegex.test(email))
if (invalidEmails.length > 0) {
throw new Error(`Invalid email addresses: ${invalidEmails.join(', ')}`)
}
return emailList
}
export const getMailing = (payload: Payload) => {
const mailing = (payload as any).mailing
if (!mailing) {