diff --git a/README.md b/README.md index 3f01fe3..9540532 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,18 @@ mailingPlugin({ richTextEditor: lexicalEditor(), // optional custom editor onReady: async (payload) => { // optional initialization hook 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 +267,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 Control plugin initialization order and add post-initialization logic: @@ -266,7 +328,7 @@ mailingPlugin({ onReady: async (payload) => { // Called after plugin is fully initialized console.log('Mailing plugin ready!') - + // Custom initialization logic here await setupCustomEmailSettings(payload) } diff --git a/dev/payload-types.ts b/dev/payload-types.ts index 6c034a3..f40e95b 100644 --- a/dev/payload-types.ts +++ b/dev/payload-types.ts @@ -248,6 +248,10 @@ export interface Email { * Sender email address (optional, uses default if not provided) */ from?: string | null; + /** + * Sender display name (optional, e.g., "John Doe" for "John Doe ") + */ + fromName?: string | null; /** * Reply-to email address */ @@ -543,6 +547,7 @@ export interface EmailsSelect { cc?: T; bcc?: T; from?: T; + fromName?: T; replyTo?: T; subject?: T; html?: T; @@ -675,6 +680,18 @@ export interface TaskSendEmail { * Optional comma-separated list of BCC email addresses */ 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 */ @@ -684,7 +701,9 @@ export interface TaskSendEmail { */ priority?: number | null; }; - output?: unknown; + output: { + id?: string | null; + }; } /** * This interface was referenced by `Config`'s JSON-Schema diff --git a/dev/test-hook-validation.ts b/dev/test-hook-validation.ts new file mode 100644 index 0000000..9179d7f --- /dev/null +++ b/dev/test-hook-validation.ts @@ -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: '

Testing hook validation

', + 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) \ No newline at end of file diff --git a/package.json b/package.json index 8448d49..d8ab084 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-mailing", - "version": "0.1.19", + "version": "0.1.21", "description": "Template-based email system with scheduling and job processing for PayloadCMS", "type": "module", "main": "dist/index.js", diff --git a/src/sendEmail.ts b/src/sendEmail.ts index 12862a4..f7ed142 100644 --- a/src/sendEmail.ts +++ b/src/sendEmail.ts @@ -83,25 +83,25 @@ export const sendEmail = async 0 ? validated[0] : undefined } - if (emailData.from && emailData.from !== null) { + 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 !== null) { + if (emailData.fromName) { emailData.fromName = emailData.fromName .trim() // Remove/replace newlines and carriage returns to prevent header injection diff --git a/src/services/MailingService.ts b/src/services/MailingService.ts index b88b9a6..f71aabf 100644 --- a/src/services/MailingService.ts +++ b/src/services/MailingService.ts @@ -270,7 +270,7 @@ export class MailingService implements IMailingService { fromField = this.getDefaultFrom() } - const mailOptions = { + let mailOptions: any = { from: fromField, to: email.to, cc: email.cc || undefined, @@ -281,6 +281,30 @@ export class MailingService implements IMailingService { text: email.text || undefined, } + // 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'}`) + } + } + await this.transporter.sendMail(mailOptions) await this.payload.update({ diff --git a/src/types/index.ts b/src/types/index.ts index 88e44cb..8944721 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -48,6 +48,21 @@ export type TemplateRendererHook = (template: string, variables: Record BeforeSendMailOptions | Promise + export interface MailingPluginConfig { collections?: { templates?: string | Partial @@ -62,6 +77,7 @@ export interface MailingPluginConfig { templateRenderer?: TemplateRendererHook templateEngine?: TemplateEngine richTextEditor?: RichTextField['editor'] + beforeSend?: BeforeSendHook onReady?: (payload: any) => Promise initOrder?: 'before' | 'after' }