Compare commits

...

11 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
9 changed files with 267 additions and 83 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.19", "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

@@ -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'

View File

@@ -83,25 +83,25 @@ 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 // Sanitize fromName to prevent header injection
if (emailData.fromName && emailData.fromName !== null) { if (emailData.fromName) {
emailData.fromName = emailData.fromName emailData.fromName = emailData.fromName
.trim() .trim()
// Remove/replace newlines and carriage returns to prevent header injection // Remove/replace newlines and carriage returns to prevent header injection

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,37 +26,19 @@ 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.')
} }
} }
@@ -270,7 +249,7 @@ export class MailingService implements IMailingService {
fromField = this.getDefaultFrom() fromField = this.getDefaultFrom()
} }
const mailOptions = { let mailOptions: any = {
from: fromField, from: fromField,
to: email.to, to: email.to,
cc: email.cc || undefined, cc: email.cc || undefined,
@@ -281,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
@@ -48,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>
@@ -55,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