diff --git a/README.md b/README.md index abe6e84..4b917c8 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,17 @@ 📧 **Template-based email system with scheduling and job processing for PayloadCMS** -⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use. +✨ **Simplified API**: Starting from v0.1.0, this plugin uses a simplified API that leverages PayloadCMS collections directly for better type safety and flexibility. ## Features -✅ **Template System**: Create reusable email templates with Handlebars syntax -✅ **Outbox Scheduling**: Schedule emails for future delivery -✅ **Job Integration**: Automatic processing via PayloadCMS jobs queue -✅ **Retry Failed Sends**: Automatic retry mechanism for failed emails -✅ **Template Variables**: Dynamic content with validation -✅ **Developer API**: Simple methods for sending emails programmatically +✅ **Template System**: Create reusable email templates with LiquidJS, Mustache, or simple variables +✅ **Type Safety**: Full TypeScript support using your generated Payload types +✅ **Flexible Template Engines**: LiquidJS, Mustache, or bring your own template renderer +✅ **Email Scheduling**: Schedule emails for future delivery using Payload collections +✅ **Job Integration**: Automatic processing via PayloadCMS jobs queue +✅ **Retry Failed Sends**: Automatic retry mechanism for failed emails +✅ **Payload Native**: Uses Payload collections directly - no custom APIs to learn ## Installation @@ -49,53 +50,124 @@ export default buildConfig({ }) ``` -### 2. Send emails in your code +### 2. Send emails using Payload collections ```typescript -import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing' +import { renderTemplate } from '@xtr-dev/payload-mailing' -// Send immediately using template slug -const emailId = await sendEmail(payload, { - templateSlug: 'welcome-email', - to: 'user@example.com', - variables: { - firstName: 'John', - welcomeUrl: 'https://yoursite.com/welcome' +// 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', + data: { + to: ['user@example.com'], + subject, + html, + text, + // 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! } }) -// Schedule for later -const scheduledId = await scheduleEmail(payload, { - templateSlug: 'reminder-email', - to: 'user@example.com', - scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours - variables: { - eventName: 'Product Launch', - eventDate: new Date('2024-01-15') +// Option 2: Direct HTML email (no template needed) +const directEmail = await payload.create({ + collection: 'emails', + data: { + to: ['user@example.com'], + subject: 'Welcome!', + html: '

Welcome John!

Thanks for joining!

', + text: 'Welcome John! Thanks for joining!', } }) ``` +## 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 ```typescript -interface MailingPluginConfig { - collections?: { - templates?: string // default: 'email-templates' - emails?: string // default: 'emails' +mailingPlugin({ + // Template engine (optional) + templateEngine: 'liquidjs', // 'liquidjs' | 'mustache' | 'simple' + + // Custom template renderer (optional) + templateRenderer: async (template: string, variables: Record) => { + return yourCustomEngine.render(template, variables) + }, + + // Email transport + transport: { + host: 'smtp.gmail.com', + port: 587, + auth: { user: '...', pass: '...' } + }, + + // Collection names (optional) + collections: { + templates: 'email-templates', // default + emails: 'emails' // default + }, + + // Sending options + defaultFrom: 'noreply@yoursite.com', + defaultFromName: 'Your Site', + retryAttempts: 3, // default + retryDelay: 300000, // 5 minutes (default) + + // Advanced options + emailWrapper: (email) => ({ // optional layout wrapper + ...email, + html: `${email.html}` + }), + richTextEditor: lexicalEditor(), // optional custom editor + onReady: async (payload) => { // optional initialization hook + console.log('Mailing plugin ready!') } - defaultFrom?: string - transport?: Transporter | MailingTransportConfig - queue?: string // default: 'default' - retryAttempts?: number // default: 3 - retryDelay?: number // default: 300000 (5 minutes) - emailWrapper?: EmailWrapperHook // optional email layout wrapper - richTextEditor?: RichTextField['editor'] // optional custom rich text editor - onReady?: (payload: any) => Promise // optional callback after plugin initialization - initOrder?: 'before' | 'after' // default: 'before' -} +}) +``` + +### Template Engine Options + +Choose your preferred template engine: + +```typescript +// LiquidJS (default) - Modern syntax with logic +mailingPlugin({ + templateEngine: 'liquidjs' // {% if user.isPremium %}Premium!{% endif %} +}) + +// Mustache - Logic-less templates +mailingPlugin({ + templateEngine: 'mustache' // {{#user.isPremium}}Premium!{{/user.isPremium}} +}) + +// Simple variable replacement +mailingPlugin({ + templateEngine: 'simple' // Just {{variable}} replacement +}) + +// Custom template renderer +mailingPlugin({ + templateRenderer: async (template, variables) => { + return handlebars.compile(template)(variables) // Bring your own! + } +}) ``` ### Transport Configuration @@ -473,71 +545,50 @@ mailingPlugin({ }) ``` -## Handlebars Helpers +## Template Syntax Reference -The plugin includes several built-in helpers: +Depending on your chosen template engine, you can use different syntax: -- `{{formatDate date 'short'}}` - Format dates (short, long, or default) -- `{{formatCurrency amount 'USD'}}` - Format currency -- `{{capitalize string}}` - Capitalize first letter -- `{{#ifEquals value1 value2}}...{{/ifEquals}}` - Conditional equality +### LiquidJS (Default) +- Variables: `{{ user.name }}` +- Logic: `{% if user.isPremium %}Premium content{% endif %}` +- Loops: `{% for item in items %}{{ item.name }}{% endfor %}` +- Filters: `{{ amount | formatCurrency }}`, `{{ date | formatDate: "short" }}` -## API Methods +### Mustache +- Variables: `{{ user.name }}` +- Logic: `{{#user.isPremium}}Premium content{{/user.isPremium}}` +- Loops: `{{#items}}{{ name }}{{/items}}` +- No built-in filters (use variables with pre-formatted data) -### sendEmail(payload, options) +### Simple +- Variables only: `{{ user.name }}`, `{{ amount }}`, `{{ date }}` -Send an email immediately: +### Built-in Filters (LiquidJS only) +- `formatDate` - Format dates: `{{ createdAt | formatDate: "short" }}` +- `formatCurrency` - Format currency: `{{ amount | formatCurrency: "USD" }}` +- `capitalize` - Capitalize first letter: `{{ name | capitalize }}` + +## Available Helper Functions ```typescript -const emailId = await sendEmail(payload, { - templateSlug: 'order-confirmation', // optional - use template slug - to: ['customer@example.com'], // string or array of emails - cc: ['manager@example.com'], // optional - array of emails - bcc: ['archive@example.com'], // optional - array of emails - from: 'orders@yoursite.com', // optional, uses default - replyTo: 'support@yoursite.com', // optional - subject: 'Custom subject', // required if no template - html: '

Custom HTML

', // required if no template - text: 'Custom text version', // optional - variables: { // template variables - orderNumber: '12345', - customerName: 'John Doe' - }, - priority: 1 // optional, 1-10 (1 = highest) +import { + renderTemplate, // Render email templates with variables + processEmails, // Process pending emails manually + retryFailedEmails, // Retry failed emails + getMailing // Get mailing service instance +} from '@xtr-dev/payload-mailing' + +// Render a template +const { html, text, subject } = await renderTemplate(payload, 'welcome', { + name: 'John', + activationUrl: 'https://example.com/activate' }) -``` -### scheduleEmail(payload, options) - -Schedule an email for later delivery: - -```typescript -const emailId = await scheduleEmail(payload, { - templateSlug: 'newsletter', - to: ['user1@example.com', 'user2@example.com'], - scheduledAt: new Date('2024-01-15T10:00:00Z'), - variables: { - month: 'January', - highlights: ['Feature A', 'Feature B'] - } -}) -``` - -### processEmails(payload) - -Manually process pending emails: - -```typescript -import { processEmails } from '@xtr-dev/payload-mailing' +// Process emails manually await processEmails(payload) -``` -### retryFailedEmails(payload) - -Manually retry failed emails: - -```typescript -import { retryFailedEmails } from '@xtr-dev/payload-mailing' +// Retry failed emails await retryFailedEmails(payload) ``` @@ -716,35 +767,85 @@ import { } from '@xtr-dev/payload-mailing' ``` +## Migration Guide (v0.0.x → v0.1.0) + +**🚨 BREAKING CHANGES**: The API has been simplified to use Payload collections directly. + +### Before (v0.0.x) +```typescript +import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing' + +// Old way +const emailId = await sendEmail(payload, { + templateSlug: 'welcome', + to: 'user@example.com', + variables: { name: 'John' } +}) + +const scheduledId = await scheduleEmail(payload, { + templateSlug: 'reminder', + to: 'user@example.com', + scheduledAt: new Date('2024-01-15T10:00:00Z'), + variables: { eventName: 'Launch' } +}) +``` + +### After (v0.1.0+) +```typescript +import { renderTemplate } from '@xtr-dev/payload-mailing' + +// New way - render template first +const { html, text, subject } = await renderTemplate(payload, 'welcome', { + name: 'John' +}) + +// Then create email using Payload collections (full type safety!) +const email = await payload.create({ + collection: 'emails', + data: { + to: ['user@example.com'], + subject, + html, + text, + // For scheduling + scheduledAt: new Date('2024-01-15T10:00:00Z'), + // Add any custom fields from your collection + customField: 'value', + } +}) +``` + +### Benefits of Migration +- ✅ **Full TypeScript support** with your generated Payload types +- ✅ **Use any custom fields** you add to your email collection +- ✅ **Leverage Payload's features**: validation, hooks, access control +- ✅ **One consistent API** - just use `payload.create()` +- ✅ **No wrapper methods** - direct access to Payload's power + ## Recent Changes -### v0.0.x (Latest) +### v0.1.0 (Latest - Breaking Changes) -**🔄 Breaking Changes:** -- Removed email layouts system in favor of `emailWrapper` hook for better flexibility -- Email fields (`to`, `cc`, `bcc`) now use `hasMany: true` for proper array handling -- Templates now use slug-based lookup instead of ID-based for developer-friendly API -- Email collection renamed from "outbox" to "emails" -- Unified job processing: single `process-email-queue` job handles both pending and failed emails +**🚀 Major API Simplification:** +- **REMOVED**: `sendEmail()` and `scheduleEmail()` wrapper methods +- **REMOVED**: `SendEmailOptions` custom types +- **ADDED**: Direct Payload collection usage with full type safety +- **ADDED**: `renderTemplate()` helper for template rendering +- **ADDED**: Support for LiquidJS, Mustache, and custom template engines +- **IMPROVED**: Webpack compatibility with proper dynamic imports -**✨ New Features:** -- Rich text editor with automatic HTML/text conversion -- Template slugs for easier template reference -- `emailWrapper` hook for consistent email layouts -- Custom rich text editor configuration support -- Initialization hooks (`onReady`, `initOrder`) for better plugin lifecycle control -- Improved Handlebars variable interpolation with defensive programming +**Template Engine Enhancements:** +- **NEW**: LiquidJS support (default) with modern syntax and logic +- **NEW**: Mustache support for logic-less templates +- **NEW**: Custom template renderer hook for maximum flexibility +- **NEW**: Simple variable replacement as fallback +- **FIXED**: All webpack compatibility issues resolved -**🐛 Bug Fixes:** -- Fixed text version uppercase conversion in headings -- Fixed Handlebars interpolation issues in text version -- Improved plugin initialization order to prevent timing issues - -**💡 Improvements:** -- Better admin UI with proper array input controls -- More robust error handling and logging -- Enhanced TypeScript definitions -- Simplified template creation workflow +**Developer Experience:** +- **IMPROVED**: Full TypeScript inference using generated Payload types +- **IMPROVED**: Comprehensive migration guide and documentation +- **IMPROVED**: Better error handling and async patterns +- **SIMPLIFIED**: Cleaner codebase with fewer abstractions ## License diff --git a/package.json b/package.json index 94d8c49..27d97aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-mailing", - "version": "0.0.12", + "version": "0.1.1", "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 226aed5..74cc6bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,8 +16,7 @@ export { default as Emails } from './collections/Emails.js' // Utility functions for developers export { getMailing, - sendEmail, - scheduleEmail, + renderTemplate, processEmails, retryFailedEmails, } from './utils/helpers.js' \ No newline at end of file diff --git a/src/services/MailingService.ts b/src/services/MailingService.ts index adfc803..f835cba 100644 --- a/src/services/MailingService.ts +++ b/src/services/MailingService.ts @@ -3,7 +3,7 @@ import { Liquid } from 'liquidjs' import nodemailer, { Transporter } from 'nodemailer' import { MailingPluginConfig, - SendEmailOptions, + TemplateVariables, MailingService as IMailingService, EmailTemplate, QueuedEmail, @@ -107,69 +107,21 @@ export class MailingService implements IMailingService { } } - async sendEmail(options: SendEmailOptions): Promise { - const emailId = await this.scheduleEmail({ - ...options, - scheduledAt: new Date() - }) + async renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> { + const template = await this.getTemplateBySlug(templateSlug) - await this.processEmailItem(emailId) - - return emailId - } - - async scheduleEmail(options: SendEmailOptions): Promise { - let html = options.html || '' - let text = options.text || '' - let subject = options.subject || '' - let templateId: string | undefined = undefined - - if (options.templateSlug) { - const template = await this.getTemplateBySlug(options.templateSlug) - - if (template) { - templateId = template.id - const variables = options.variables || {} - const renderedContent = await this.renderEmailTemplate(template, variables) - html = renderedContent.html - text = renderedContent.text - subject = await this.renderTemplate(template.subject, variables) - } else { - throw new Error(`Email template not found: ${options.templateSlug}`) - } + if (!template) { + throw new Error(`Email template not found: ${templateSlug}`) } - if (!subject && !options.subject) { - throw new Error('Email subject is required') + const emailContent = await this.renderEmailTemplate(template, variables) + const subject = await this.renderTemplateString(template.subject, variables) + + return { + html: emailContent.html, + text: emailContent.text, + subject } - - if (!html && !options.html) { - throw new Error('Email HTML content is required') - } - - const queueData = { - template: templateId, - to: Array.isArray(options.to) ? options.to : [options.to], - cc: options.cc ? (Array.isArray(options.cc) ? options.cc : [options.cc]) : undefined, - bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : undefined, - from: options.from || this.getDefaultFrom(), - replyTo: options.replyTo, - subject: subject || options.subject, - html, - text, - variables: options.variables, - scheduledAt: options.scheduledAt?.toISOString(), - status: 'pending' as const, - attempts: 0, - priority: options.priority || 5, - } - - const result = await this.payload.create({ - collection: this.emailsCollection as any, - data: queueData, - }) - - return result.id as string } async processEmails(): Promise { @@ -366,7 +318,7 @@ export class MailingService implements IMailingService { } } - private async renderTemplate(template: string, variables: Record): Promise { + private async renderTemplateString(template: string, variables: Record): Promise { // Use custom template renderer if provided if (this.config.templateRenderer) { try { @@ -434,8 +386,8 @@ export class MailingService implements IMailingService { let text = serializeRichTextToText(template.content) // Apply template variables to the rendered content - html = await this.renderTemplate(html, variables) - text = await this.renderTemplate(text, variables) + html = await this.renderTemplateString(html, variables) + text = await this.renderTemplateString(text, variables) return { html, text } } diff --git a/src/types/index.ts b/src/types/index.ts index 14bb179..b29b150 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ import { Payload } from 'payload' -import type { CollectionConfig, RichTextField } from 'payload' +import type { CollectionConfig, RichTextField, TypedCollection } from 'payload' import { Transporter } from 'nodemailer' export interface EmailObject { @@ -83,26 +83,15 @@ export interface QueuedEmail { updatedAt: string } -export interface SendEmailOptions { - templateSlug?: string - to: string | string[] - cc?: string | string[] - bcc?: string | string[] - from?: string - replyTo?: string - subject?: string - html?: string - text?: string - variables?: Record - scheduledAt?: Date - priority?: number +// Simple helper type for template variables +export interface TemplateVariables { + [key: string]: any } export interface MailingService { - sendEmail(options: SendEmailOptions): Promise - scheduleEmail(options: SendEmailOptions): Promise processEmails(): Promise retryFailedEmails(): Promise + renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> } export interface MailingContext { diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 38212f6..1e37e48 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,5 +1,5 @@ import { Payload } from 'payload' -import { SendEmailOptions } from '../types/index.js' +import { TemplateVariables } from '../types/index.js' export const getMailing = (payload: Payload) => { const mailing = (payload as any).mailing @@ -9,14 +9,9 @@ export const getMailing = (payload: Payload) => { return mailing } -export const sendEmail = async (payload: Payload, options: SendEmailOptions): Promise => { +export const renderTemplate = async (payload: Payload, templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> => { const mailing = getMailing(payload) - return mailing.service.sendEmail(options) -} - -export const scheduleEmail = async (payload: Payload, options: SendEmailOptions): Promise => { - const mailing = getMailing(payload) - return mailing.service.scheduleEmail(options) + return mailing.service.renderTemplate(templateSlug, variables) } export const processEmails = async (payload: Payload): Promise => { diff --git a/template-syntax-migration.md b/template-syntax-migration.md deleted file mode 100644 index 03c3957..0000000 --- a/template-syntax-migration.md +++ /dev/null @@ -1,160 +0,0 @@ -# Template Engine Options - -The plugin now supports flexible template rendering with multiple options: - -1. **String-based Configuration** (easy setup with built-in engines) -2. **Custom Template Renderer Hook** (maximum flexibility) -3. **Simple Variable Replacement** (fallback, no dependencies) - -## Configuration Options - -### String-based Template Engine Configuration -Easy setup using built-in template engines: - -```typescript -// Using LiquidJS (default, requires: npm install liquidjs) -mailingPlugin({ - templateEngine: 'liquidjs' -}) - -// Using Mustache (requires: npm install mustache) -mailingPlugin({ - templateEngine: 'mustache' -}) - -// Using simple variable replacement (no dependencies) -mailingPlugin({ - templateEngine: 'simple' -}) -``` - -### Custom Template Renderer Hook -```typescript -// Example with Handlebars -import Handlebars from 'handlebars' - -mailingPlugin({ - templateRenderer: async (template: string, variables: Record) => { - const compiled = Handlebars.compile(template) - return compiled(variables) - } -}) - -// Example with Mustache -import Mustache from 'mustache' - -mailingPlugin({ - templateRenderer: async (template: string, variables: Record) => { - return Mustache.render(template, variables) - } -}) - -// Example with Nunjucks -import nunjucks from 'nunjucks' - -mailingPlugin({ - templateRenderer: async (template: string, variables: Record) => { - return nunjucks.renderString(template, variables) - } -}) -``` - -### Using LiquidJS (Optional) -Install the optional dependency: -```bash -npm install liquidjs -# or -pnpm add liquidjs -``` - -### Fallback Mode -If no custom renderer is provided and neither LiquidJS nor Mustache are installed, simple `{{variable}}` replacement is used. - -## Template Syntax Reference - -### Mustache Syntax (Logic-less) -```mustache -Hello {{user.name}}, - -{{#user.isPremium}} - Welcome to premium! Your balance is {{balance}}. -{{/user.isPremium}} - -{{#orders}} - Order: {{id}} - {{date}} -{{/orders}} -``` - -### LiquidJS Syntax (With Logic) -```liquid -Hello {{user.name}}, - -{% if user.isPremium %} - Welcome to premium! Your balance is {{balance | formatCurrency}}. -{% endif %} - -{% for order in orders %} - Order: {{order.id}} - {{order.date | formatDate: "short"}} -{% endfor %} -``` - -### Simple Variable Replacement -``` -Hello {{user.name}}, -Your balance is {{balance}}. -``` - -## Migration from Handlebars - -### Variables -- **Handlebars**: `{{variable}}` -- **LiquidJS**: `{{variable}}` (same) - -### Conditionals -- **Handlebars**: `{{#if condition}}content{{/if}}` -- **LiquidJS**: `{% if condition %}content{% endif %}` - -### Loops -- **Handlebars**: `{{#each items}}{{this}}{{/each}}` -- **LiquidJS**: `{% for item in items %}{{item}}{% endfor %}` - -### Filters/Helpers -- **Handlebars**: `{{formatDate date "short"}}` -- **LiquidJS**: `{{date | formatDate: "short"}}` - -### Available Filters -- `formatDate` - Format dates (short, long, or default) -- `formatCurrency` - Format currency amounts -- `capitalize` - Capitalize first letter - -### Comparison Operations (LiquidJS Advantage) -- **Handlebars**: Required `{{#ifEquals}}` helper -- **LiquidJS**: Built-in: `{% if user.role == "admin" %}` - -## Example Migration - -### Before (Handlebars) -```handlebars -Hello {{user.name}}, - -{{#if user.isPremium}} - Welcome to premium! Your balance is {{formatCurrency balance}}. -{{/if}} - -{{#each orders}} - Order: {{this.id}} - {{formatDate this.date "short"}} -{{/each}} -``` - -### After (LiquidJS) -```liquid -Hello {{user.name}}, - -{% if user.isPremium %} - Welcome to premium! Your balance is {{balance | formatCurrency}}. -{% endif %} - -{% for order in orders %} - Order: {{order.id}} - {{order.date | formatDate: "short"}} -{% endfor %} -``` \ No newline at end of file