Compare commits

...

39 Commits

Author SHA1 Message Date
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
Bas
ff788c1ecf Merge pull request #31 from xtr-dev/dev
Fix variables field type to support all JSON-compatible values
2025-09-13 23:41:43 +02:00
c12438aaa2 Bump package version to 0.1.15 in package.json. 2025-09-13 23:40:31 +02:00
4dcbc1446a Fix variables field type to support all JSON-compatible values
- Replace restrictive Record<string, any> with flexible JSONValue type for variables field
- Add JSONValue type alias that matches Payload's JSON field type specification
- Support string, number, boolean, objects, arrays, null, and undefined for variables
- Update both BaseEmailDocument and QueuedEmail interfaces consistently
- Update documentation to reflect JSONValue support

Fixes type constraint error where customer Email.variables field type
(string | number | boolean | {...} | unknown[] | null | undefined)
was not assignable to Record<string, any>.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:38:46 +02:00
Bas
72f3d7f66d Merge pull request #30 from xtr-dev/dev
Add null value support to BaseEmailDocument interface
2025-09-13 23:35:25 +02:00
ecc0b0a73e Fix type inconsistencies and missing null checks
- Update QueuedEmail interface to include `| null` for optional fields to match BaseEmailDocument
- Add missing null checks for replyTo and from fields in sendEmail processing
- Add proper email validation for replyTo and from fields (single email addresses)
- Ensure type consistency across all email-related interfaces

Fixes potential type conflicts between QueuedEmail and BaseEmailDocument,
and ensures all nullable email fields are properly validated.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:32:44 +02:00
a959673fc1 Bump package version to 0.1.14 in package.json. 2025-09-13 23:31:23 +02:00
8809db6aff Add null value support to BaseEmailDocument interface
- Update BaseEmailDocument to support `| null` for optional fields (cc, bcc, from, replyTo, text, etc.)
- Update BaseEmailTemplateDocument to support `| null` for optional fields
- Add explicit null checks in sendEmail processing to handle null values properly
- Update CUSTOM-TYPES.md documentation to reflect null value compatibility

Fixes type constraint error where customer Email types had `cc?: string[] | null`
but BaseEmailDocument only supported `cc?: string[]`.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:27:53 +02:00
Bas
5905f732de Merge pull request #29 from xtr-dev/dev
Support custom ID types (string/number) for improved compatibility
2025-09-13 23:24:55 +02:00
4c495a72b0 Remove duplicate BaseEmailDocument definition
- Remove duplicate BaseEmailDocument interface from sendEmail.ts
- Import BaseEmailDocument from types/index.ts instead
- Update sendEmailTask.ts to import from types/index.ts
- Maintain single source of truth for BaseEmailDocument type definition

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:22:23 +02:00
8518c716e8 Bump package version to 0.1.13 in package.json. 2025-09-13 23:21:33 +02:00
570190be01 Support custom ID types (string/number) for improved compatibility
- Replace hardcoded payload-types imports with generic BaseEmailDocument interface
- Update sendEmail and sendEmailTask to work with both string and number IDs
- Refactor MailingService to use generic document types instead of specific ones
- Add BaseEmailDocument and BaseEmailTemplateDocument interfaces supporting id: string | number
- Export BaseEmailDocument for users to extend with their custom fields
- Fix TypeScript compilation error in template subject handling
- Add CUSTOM-TYPES.md documentation for users with different ID types

Fixes compatibility issue where plugin required number IDs but user projects used string IDs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:18:37 +02:00
Bas
685875d1b9 Merge pull request #28 from xtr-dev/dev
Dev
2025-09-13 23:11:16 +02:00
79044b7bc3 Remove unused BaseEmail imports
- Remove BaseEmail import from sendEmail.ts (no longer used after type refactoring)
- Remove BaseEmail import from sendEmailTask.ts (no longer used after type refactoring)
- BaseEmail types are still used in MailingService.ts for proper type casting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:10:20 +02:00
e7304fe1a2 Improve type safety, error handling, and code maintainability
- Simplify sendEmail generic constraints for better type safety
- Add validation before type assertions in sendEmail
- Preserve error stack traces in sendEmailTask error handling
- Extract field copying logic into reusable helper function
- Improve code documentation and separation of concerns

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:06:02 +02:00
790eedfee7 Bump package version to 0.1.12 in package.json. 2025-09-13 23:01:06 +02:00
9520ec5ed1 Refactor email types for enhanced consistency and type safety
- Replace `EmailTemplate` with `BaseEmailTemplate` for stricter type validation.
- Update `sendEmail` and `sendEmailTask` to utilize refined `BaseEmail` structure.
- Simplify type definitions in `MailingService` and related modules.
2025-09-13 23:00:41 +02:00
12 changed files with 508 additions and 162 deletions

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:

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.11", "version": "0.2.0",
"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

@@ -1,6 +1,5 @@
import { sendEmail } from '../sendEmail.js' import { sendEmail } from '../sendEmail.js'
import { Email } from '../payload-types.js' import { BaseEmailDocument } from '../types/index.js'
import {BaseEmail} from "../types/index.js"
export interface SendEmailTaskInput { export interface SendEmailTaskInput {
// Template mode fields // Template mode fields
@@ -16,13 +15,55 @@ 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
// 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
} }
/**
* Transforms task input into sendEmail options by separating template and data fields
*/
function transformTaskInputToSendEmailOptions(taskInput: SendEmailTaskInput) {
const sendEmailOptions: any = {
data: {}
}
// If using template mode, set template options
if (taskInput.templateSlug) {
sendEmailOptions.template = {
slug: taskInput.templateSlug,
variables: taskInput.variables || {}
}
}
// Standard email fields that should be copied to data
const standardFields = ['to', 'cc', 'bcc', 'from', 'fromName', 'replyTo', 'subject', 'html', 'text', 'scheduledAt', 'priority']
// Template-specific fields that should not be copied to data
const templateFields = ['templateSlug', 'variables']
// Copy standard fields to data
standardFields.forEach(field => {
if (taskInput[field] !== undefined) {
sendEmailOptions.data[field] = taskInput[field]
}
})
// Copy any additional custom fields that aren't template or standard fields
Object.keys(taskInput).forEach(key => {
if (!templateFields.includes(key) && !standardFields.includes(key)) {
sendEmailOptions.data[key] = taskInput[key]
}
})
return sendEmailOptions
}
export const sendEmailJob = { export const sendEmailJob = {
slug: 'send-email', slug: 'send-email',
label: 'Send Email', label: 'Send Email',
@@ -97,6 +138,30 @@ 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,
@@ -128,40 +193,11 @@ export const sendEmailJob = {
const taskInput = input as SendEmailTaskInput const taskInput = input as SendEmailTaskInput
try { try {
// Prepare options for sendEmail based on task input // Transform task input into sendEmail options using helper function
const sendEmailOptions: any = { const sendEmailOptions = transformTaskInputToSendEmailOptions(taskInput)
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 // Use the sendEmail helper to create the email
const email = await sendEmail<BaseEmail>(payload, sendEmailOptions) const email = await sendEmail<BaseEmailDocument>(payload, sendEmailOptions)
return { return {
output: { output: {
@@ -171,8 +207,15 @@ export const sendEmailJob = {
} }
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error' if (error instanceof Error) {
throw new Error(`Failed to queue email: ${errorMessage}`) // Preserve original error and stack trace
const wrappedError = new Error(`Failed to queue email: ${error.message}`)
wrappedError.stack = error.stack
wrappedError.cause = error
throw wrappedError
} else {
throw new Error(`Failed to queue 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,
], ],

View File

@@ -1,10 +1,9 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js' import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js'
import {Email} from "./payload-types.js" import { BaseEmailDocument } from './types/index.js'
import {BaseEmail} from "./types/index.js"
// Options for sending emails // Options for sending emails
export interface SendEmailOptions<T extends BaseEmail = BaseEmail> { export interface SendEmailOptions<T extends BaseEmailDocument = BaseEmailDocument> {
// Template-based email // Template-based email
template?: { template?: {
slug: string slug: string
@@ -36,14 +35,14 @@ export interface SendEmailOptions<T extends BaseEmail = BaseEmail> {
* }) * })
* ``` * ```
*/ */
export const sendEmail = async <T extends BaseEmail = BaseEmail, ID = string | number>( export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocument>(
payload: Payload, payload: Payload,
options: SendEmailOptions<T> options: SendEmailOptions<TEmail>
): Promise<T & {id: ID}> => { ): Promise<TEmail> => {
const mailing = getMailing(payload) const mailing = getMailing(payload)
const collectionSlug = options.collectionSlug || mailing.collections.emails || 'emails' const collectionSlug = options.collectionSlug || mailing.collections.emails || 'emails'
let emailData: Partial<T> = { ...options.data } as Partial<T> let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail>
// If using a template, render it first // If using a template, render it first
if (options.template) { if (options.template) {
@@ -59,7 +58,7 @@ export const sendEmail = async <T extends BaseEmail = BaseEmail, ID = string | n
subject, subject,
html, html,
text, text,
} as Partial<T> } as Partial<TEmail>
} }
// Validate required fields // Validate required fields
@@ -90,6 +89,44 @@ export const sendEmail = async <T extends BaseEmail = BaseEmail, ID = string | n
if (emailData.bcc) { if (emailData.bcc) {
emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[]) emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[])
} }
if (emailData.replyTo) {
const validated = parseAndValidateEmails(emailData.replyTo as string | string[])
// replyTo should be a single email, so take the first one if array
emailData.replyTo = validated && validated.length > 0 ? validated[0] : undefined
}
if (emailData.from) {
const validated = parseAndValidateEmails(emailData.from as string | string[])
// from should be a single email, so take the first one if array
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({
@@ -97,7 +134,12 @@ export const sendEmail = async <T extends BaseEmail = BaseEmail, ID = string | n
data: emailData data: emailData
}) })
return email as T & {id: ID} // Validate that the created email has the expected structure
if (!email || typeof email !== 'object' || !email.id) {
throw new Error('Failed to create email: invalid response from database')
}
return email as TEmail
} }
export default sendEmail export default sendEmail

View File

@@ -1,25 +1,20 @@
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,
EmailTemplate, BaseEmail, BaseEmailTemplate, BaseEmailDocument, BaseEmailTemplateDocument
QueuedEmail,
MailingTransportConfig,
BaseEmail
} from '../types/index.js' } from '../types/index.js'
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js' import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
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
@@ -31,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 || ''
@@ -133,7 +134,7 @@ export class MailingService implements IMailingService {
} }
const emailContent = await this.renderEmailTemplate(template, variables) const emailContent = await this.renderEmailTemplate(template, variables)
const subject = await this.renderTemplateString(template.subject, variables) const subject = await this.renderTemplateString(template.subject || '', variables)
return { return {
html: emailContent.html, html: emailContent.html,
@@ -238,10 +239,18 @@ export class MailingService implements IMailingService {
const email = await this.payload.findByID({ const email = await this.payload.findByID({
collection: this.emailsCollection as any, collection: this.emailsCollection as any,
id: emailId, id: emailId,
}) as BaseEmail }) 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,
@@ -251,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,
@@ -287,7 +321,7 @@ export class MailingService implements IMailingService {
const email = await this.payload.findByID({ const email = await this.payload.findByID({
collection: this.emailsCollection as any, collection: this.emailsCollection as any,
id: emailId, id: emailId,
}) as QueuedEmail }) as BaseEmail
const newAttempts = (email.attempts || 0) + 1 const newAttempts = (email.attempts || 0) + 1
@@ -302,7 +336,7 @@ export class MailingService implements IMailingService {
return newAttempts return newAttempts
} }
private async getTemplateBySlug(templateSlug: string): Promise<EmailTemplate | null> { private async getTemplateBySlug(templateSlug: string): Promise<BaseEmailTemplateDocument | null> {
try { try {
const { docs } = await this.payload.find({ const { docs } = await this.payload.find({
collection: this.templatesCollection as any, collection: this.templatesCollection as any,
@@ -314,7 +348,7 @@ export class MailingService implements IMailingService {
limit: 1, limit: 1,
}) })
return docs.length > 0 ? docs[0] as EmailTemplate : null return docs.length > 0 ? docs[0] as BaseEmailTemplateDocument : null
} catch (error) { } catch (error) {
console.error(`Template with slug '${templateSlug}' not found:`, error) console.error(`Template with slug '${templateSlug}' not found:`, error)
return null return null
@@ -379,7 +413,7 @@ export class MailingService implements IMailingService {
}) })
} }
private async renderEmailTemplate(template: EmailTemplate, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> { private async renderEmailTemplate(template: BaseEmailTemplateDocument, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
if (!template.content) { if (!template.content) {
return { html: '', text: '' } return { html: '', text: '' }
} }

View File

@@ -1,14 +1,67 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import type { CollectionConfig, RichTextField } from 'payload' import type { CollectionConfig, RichTextField } from 'payload'
import { Transporter } from 'nodemailer'
import {Email} from "../payload-types.js"
export type BaseEmail<TEmail = Email, TEmailTemplate = EmailTemplate> = Omit<TEmail, 'id' | 'template'> & {template: Omit<TEmailTemplate, 'id'>} // JSON value type that matches Payload's JSON field type
export type JSONValue = string | number | boolean | { [k: string]: unknown } | unknown[] | null | undefined
// Generic base interfaces that work with any ID type and null values
export interface BaseEmailDocument {
id: string | number
template?: any
to: string[]
cc?: string[] | null
bcc?: string[] | null
from?: string | null
fromName?: string | null
replyTo?: string | null
subject: string
html: string
text?: string | null
variables?: JSONValue
scheduledAt?: string | Date | null
sentAt?: string | Date | null
status?: 'pending' | 'processing' | 'sent' | 'failed' | null
attempts?: number | null
lastAttemptAt?: string | Date | null
error?: string | null
priority?: number | null
createdAt?: string | Date | null
updatedAt?: string | Date | null
}
export interface BaseEmailTemplateDocument {
id: string | number
name: string
slug: string
subject?: string | null
content?: any
createdAt?: string | Date | 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 BaseEmailTemplate<TEmailTemplate extends BaseEmailTemplateDocument = BaseEmailTemplateDocument> = Omit<TEmailTemplate, 'id'>
export type TemplateRendererHook = (template: string, variables: Record<string, any>) => string | Promise<string> export type TemplateRendererHook = (template: string, variables: Record<string, any>) => string | Promise<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>
@@ -16,57 +69,37 @@ 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 EmailTemplate {
id: string
name: string
slug: string
subject: string
content: any // Lexical editor state
createdAt: string
updatedAt: string
}
export interface QueuedEmail { export interface QueuedEmail {
id: string id: string
template?: string template?: string | null
to: string[] to: string[]
cc?: string[] cc?: string[] | null
bcc?: string[] bcc?: string[] | null
from?: string from?: string | null
replyTo?: string fromName?: string | null
replyTo?: string | null
subject: string subject: string
html: string html: string
text?: string text?: string | null
variables?: Record<string, any> variables?: JSONValue
scheduledAt?: string scheduledAt?: string | Date | null
sentAt?: string sentAt?: string | Date | null
status: 'pending' | 'processing' | 'sent' | 'failed' status: 'pending' | 'processing' | 'sent' | 'failed'
attempts: number attempts: number
lastAttemptAt?: string lastAttemptAt?: string | Date | null
error?: string error?: string | null
priority?: number priority?: number | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }