diff --git a/README.md b/README.md index 181a886..0ceab05 100644 --- a/README.md +++ b/README.md @@ -56,53 +56,54 @@ 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

', } }) ``` -## 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 @@ -848,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/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", 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/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 1e37e48..a2a70b9 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,6 +1,56 @@ 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' +} + +/** + * 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) { @@ -22,4 +72,84 @@ 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') + } + + // Process email addresses using shared validation + if (emailData.to) { + emailData.to = parseAndValidateEmails(emailData.to as string | string[]) + } + if (emailData.cc) { + emailData.cc = parseAndValidateEmails(emailData.cc as string | string[]) + } + if (emailData.bcc) { + emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[]) + } + + // Convert scheduledAt to ISO string if it's a Date + if (emailData.scheduledAt instanceof Date) { + emailData.scheduledAt = emailData.scheduledAt.toISOString() + } + + // Create the email in the collection + const email = await payload.create({ + collection: collectionSlug as any, + data: emailData as any + }) + + return email as unknown as T } \ No newline at end of file