From f8b7dd8f4c598283cadae9ef6ec2cee08f3dd165 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sat, 13 Sep 2025 20:23:53 +0200 Subject: [PATCH 1/4] Remove WIP comments from README --- README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/README.md b/README.md index 181a886..d565be1 100644 --- a/README.md +++ b/README.md @@ -95,14 +95,6 @@ const directEmail = await payload.create({ }) ``` -## 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 From cb5ce2e7200639f1cc0339b111cec700899924a2 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sat, 13 Sep 2025 20:30:55 +0200 Subject: [PATCH 2/4] Add type-safe sendEmail helper with generics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New sendEmail() 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 --- README.md | 100 +++++++++++++++++++++++++++------- src/index.ts | 3 + src/utils/helpers.ts | 127 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index d565be1..0ceab05 100644 --- a/README.md +++ b/README.md @@ -56,41 +56,50 @@ export default buildConfig({ }) ``` -### 2. Send emails using Payload collections +### 2. Send emails with type-safe helper ```typescript -import { renderTemplate } from '@xtr-dev/payload-mailing' +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(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(payload, { data: { - to: ['user@example.com'], + to: ['user@example.com', 'another@example.com'], subject: 'Welcome!', html: '

Welcome John!

Thanks for joining!

', 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: '

Hello World

', } }) ``` @@ -840,6 +849,55 @@ import { } from '@xtr-dev/payload-mailing' ``` +## API Reference + +### `sendEmail(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(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. diff --git a/src/index.ts b/src/index.ts index 3bed52c..70288ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,4 +21,7 @@ export { renderTemplate, processEmails, retryFailedEmails, + sendEmail, + type BaseEmailData, + type SendEmailOptions, } from './utils/helpers.js' \ No newline at end of file diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 1e37e48..3db18fb 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,6 +1,32 @@ import { Payload } from 'payload' import { TemplateVariables } from '../types/index.js' +// Base type for email data that all emails must have +export interface BaseEmailData { + to: string | string[] + cc?: string | string[] + bcc?: string | string[] + subject?: string + html?: string + text?: string + scheduledAt?: string | Date + priority?: number + [key: string]: any +} + +// Options for sending emails +export interface SendEmailOptions { + // Template-based email + template?: { + slug: string + variables?: Record + } + // Direct email data + data?: Partial + // Common options + collectionSlug?: string // defaults to 'emails' +} + export const getMailing = (payload: Payload) => { const mailing = (payload as any).mailing if (!mailing) { @@ -22,4 +48,105 @@ export const processEmails = async (payload: Payload): Promise => { export const retryFailedEmails = async (payload: Payload): Promise => { const mailing = getMailing(payload) return mailing.service.retryFailedEmails() +} + +/** + * Send an email with full type safety + * + * @example + * ```typescript + * // With your generated Email type + * import { Email } from './payload-types' + * + * const email = await sendEmail(payload, { + * template: { + * slug: 'welcome', + * variables: { name: 'John' } + * }, + * data: { + * to: 'user@example.com', + * customField: 'value' // Your custom fields are type-safe! + * } + * }) + * ``` + */ +export const sendEmail = async ( + payload: Payload, + options: SendEmailOptions +): Promise => { + const mailing = getMailing(payload) + const collectionSlug = options.collectionSlug || mailing.collections.emails || 'emails' + + let emailData: Partial = { ...options.data } as Partial + + // 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 + } + + // 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') + } + + // Parse and validate email addresses + const parseEmails = (emails: string | string[] | undefined): string[] | undefined => { + if (!emails) 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 + } + + // Process email addresses + if (emailData.to) { + emailData.to = parseEmails(emailData.to as string | string[]) + } + if (emailData.cc) { + emailData.cc = parseEmails(emailData.cc as string | string[]) + } + if (emailData.bcc) { + emailData.bcc = parseEmails(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 } \ No newline at end of file From dfa833fa5e41f497454941beeab57df0885a21b8 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sat, 13 Sep 2025 20:36:08 +0200 Subject: [PATCH 3/4] Eliminate code duplication between helpers and jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/jobs/sendEmailTask.ts | 107 ++++++++++---------------------------- src/utils/helpers.ts | 53 ++++++++++--------- 2 files changed, 55 insertions(+), 105 deletions(-) diff --git a/src/jobs/sendEmailTask.ts b/src/jobs/sendEmailTask.ts index c965d6e..a0e8042 100644 --- a/src/jobs/sendEmailTask.ts +++ b/src/jobs/sendEmailTask.ts @@ -1,4 +1,4 @@ -import { renderTemplate } from '../utils/helpers.js' +import { sendEmail, type BaseEmailData } from '../utils/helpers.js' export interface SendEmailTaskInput { // Template mode fields @@ -116,96 +116,44 @@ export const sendEmailJob = { } ], handler: async ({ input, payload }: any) => { - // Get mailing context from payload - const mailingContext = (payload as any).mailing - if (!mailingContext) { - throw new Error('Mailing plugin not properly initialized') - } - - // Cast input to our expected type with validation + // Cast input to our expected type const taskInput = input as SendEmailTaskInput - // Validate required fields - if (!taskInput.to) { - throw new Error('Field "to" is required') - } - try { - let html: string - let text: string | undefined - let subject: string + // Prepare options for sendEmail based on task input + const sendEmailOptions: any = { + data: {} + } - // Check if using template or direct email + // If using template mode if (taskInput.templateSlug) { - // Template mode: render the template - const rendered = await renderTemplate( - payload, - taskInput.templateSlug, - taskInput.variables || {} - ) - html = rendered.html - text = rendered.text - subject = rendered.subject - } else { - // Direct email mode: use provided content - if (!taskInput.subject || !taskInput.html) { - throw new Error('Subject and HTML content are required when not using a template') + sendEmailOptions.template = { + slug: taskInput.templateSlug, + variables: taskInput.variables || {} } - subject = taskInput.subject - html = taskInput.html - text = taskInput.text } - // Parse and validate email addresses - const parseEmails = (emails: string | string[] | undefined): string[] | undefined => { - if (!emails) return undefined + // Build data object from task input + const dataFields = ['to', 'cc', 'bcc', 'subject', 'html', 'text', 'scheduledAt', 'priority'] + const additionalFields: string[] = [] - let emailList: string[] - if (Array.isArray(emails)) { - emailList = emails - } else { - emailList = emails.split(',').map(email => email.trim()).filter(Boolean) + // Copy standard fields + dataFields.forEach(field => { + if (taskInput[field] !== undefined) { + sendEmailOptions.data[field] = taskInput[field] } + }) - // 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 - } - - // Prepare email data - const emailData: any = { - to: parseEmails(taskInput.to), - cc: parseEmails(taskInput.cc), - bcc: parseEmails(taskInput.bcc), - subject, - html, - text, - priority: taskInput.priority || 5, - } - - // Add scheduled date if provided - if (taskInput.scheduledAt) { - emailData.scheduledAt = new Date(taskInput.scheduledAt).toISOString() - } - - // Add any additional fields from input (excluding the ones we've already handled) - const handledFields = ['templateSlug', 'to', 'cc', 'bcc', 'variables', 'scheduledAt', 'priority'] + // Copy any additional custom fields Object.keys(taskInput).forEach(key => { - if (!handledFields.includes(key)) { - emailData[key] = taskInput[key] + if (!['templateSlug', 'variables', ...dataFields].includes(key)) { + sendEmailOptions.data[key] = taskInput[key] + additionalFields.push(key) } }) - // Create the email in the collection using configurable collection name - const email = await payload.create({ - collection: mailingContext.collections.emails, - data: emailData - }) + // Use the sendEmail helper to create the email + const email = await sendEmail(payload, sendEmailOptions) return { output: { @@ -214,15 +162,14 @@ export const sendEmailJob = { message: `Email queued successfully with ID: ${email.id}`, mode: taskInput.templateSlug ? 'template' : 'direct', templateSlug: taskInput.templateSlug || null, - subject: subject, - recipients: emailData.to?.length || 0, - scheduledAt: emailData.scheduledAt || 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}`) } } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 3db18fb..a2a70b9 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -27,6 +27,30 @@ export interface SendEmailOptions { collectionSlug?: string // defaults to 'emails' } +/** + * Parse and validate email addresses + * @internal + */ +export const parseAndValidateEmails = (emails: string | string[] | undefined): string[] | undefined => { + if (!emails) 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) { @@ -105,36 +129,15 @@ export const sendEmail = async ( throw new Error('Fields "subject" and "html" are required when not using a template') } - // Parse and validate email addresses - const parseEmails = (emails: string | string[] | undefined): string[] | undefined => { - if (!emails) 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 - } - - // Process email addresses + // Process email addresses using shared validation if (emailData.to) { - emailData.to = parseEmails(emailData.to as string | string[]) + emailData.to = parseAndValidateEmails(emailData.to as string | string[]) } if (emailData.cc) { - emailData.cc = parseEmails(emailData.cc as string | string[]) + emailData.cc = parseAndValidateEmails(emailData.cc as string | string[]) } if (emailData.bcc) { - emailData.bcc = parseEmails(emailData.bcc as string | string[]) + emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[]) } // Convert scheduledAt to ISO string if it's a Date From 25838bcba42b3a887cea69295c03ab75cec11d51 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sat, 13 Sep 2025 20:37:20 +0200 Subject: [PATCH 4/4] Bump package version to 0.1.5 in `package.json`. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0a25527..89ace5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-mailing", - "version": "0.1.4", + "version": "0.1.5", "description": "Template-based email system with scheduling and job processing for PayloadCMS", "type": "module", "main": "dist/index.js",