Compare commits

..

18 Commits

Author SHA1 Message Date
Bas
cddcfb1e4c Merge pull request #16 from xtr-dev/dev
Replace Handlebars with flexible template engine system
2025-09-13 18:11:06 +02:00
cfc3ce5a7e IMPROVE: Clean up async initialization pattern
- Remove unnecessary initializeTemplateEngine() from constructor
- Rename initializeLiquidJS() to ensureLiquidJSInitialized() for clarity
- Make template engine loading truly lazy (only on first template render)
- Eliminate potential timing issues with constructor async calls
- Improve code clarity and maintainability

Now template engines are only loaded when actually needed for rendering.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:08:10 +02:00
5acf7d52f6 🚀 FINAL FIX: Standard dynamic imports with proper async handling
- Replace Function() constructor imports with standard dynamic imports
- Add proper state management for template engine loading (null | false | Liquid)
- Fix async initialization timing issues with lazy loading
- Add mustache type declarations for TypeScript compatibility
- Prevent retry attempts on failed module loads
- Ensure webpack/bundler compatibility across all environments

All webpack compatibility issues now fully resolved.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:02:40 +02:00
b854b17266 🚨 CRITICAL FIX: Replace require() with dynamic imports for webpack compatibility
- Replace require('liquidjs') and require('mustache') with dynamic imports
- Fix webpack compatibility issues and ES module support
- Make template engine initialization lazy and async
- Add proper error handling for optional dependencies
- Use Function('return import(...)') pattern to avoid TypeScript issues
- Maintain backward compatibility with existing configurations

This resolves critical webpack bundling issues in client applications.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 17:57:19 +02:00
dc3c4fdb44 Replace Handlebars with flexible template engine system
- Replace handlebars dependency with optional liquidjs and mustache
- Add templateEngine string configuration ('liquidjs', 'mustache', 'simple')
- Add custom templateRenderer hook for maximum flexibility
- Implement graceful fallbacks when optional dependencies unavailable
- Fix webpack compatibility issues with require.extensions
- Maintain backward compatibility with existing templates
- Add comprehensive template syntax documentation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 17:51:25 +02:00
Bas
80d32674a9 Merge pull request #15 from xtr-dev/dev
Add automatic job scheduling and rescheduling
2025-09-13 17:00:33 +02:00
243f7c96cf Bump package version to 0.0.8 in package.json. 2025-09-13 16:57:50 +02:00
159a99a1ec Fix race conditions and add validation for job scheduling
- Reuse duplicate prevention logic in rescheduling to prevent race conditions
- Add queueName validation in plugin initialization and helper function
- Enhanced scheduleEmailProcessingJob to return boolean and accept delay parameter
- Improve error handling: rescheduling failures warn but don't fail current job
- Prevent duplicate jobs when multiple handlers complete simultaneously

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:56:51 +02:00
860dd7e921 Add automatic job scheduling and rescheduling
- Add scheduleEmailProcessingJob helper to check and schedule jobs on init
- Only schedule if no existing uncompleted job exists
- Update job handler to always reschedule itself after completion (5min interval)
- Job reschedules regardless of success/failure for continuous processing
- Initial job starts 1 minute after init, subsequent jobs every 5 minutes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:51:59 +02:00
Bas
c7af628beb Merge pull request #14 from xtr-dev/dev
Add defaultFromName config option and bump to v0.0.7
2025-09-13 16:29:26 +02:00
fa54c5622c Improve email display name handling with proper escaping
- Add quote escaping in display names to prevent malformed email headers
- Handle empty string defaultFromName by checking trim()
- Prevent formatting when fromName is only whitespace
- Example: John "The Boss" Doe becomes "John \"The Boss\" Doe" <email>

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:26:53 +02:00
719b60b9ef Add defaultFromName config option and bump to v0.0.7
- Add defaultFromName to MailingPluginConfig interface
- Update MailingService to format from field with name when available
- Add getDefaultFrom() helper method for consistent formatting
- Format as "Name" <email@domain.com> when both name and email are provided
- Bump version to 0.0.7

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:23:39 +02:00
Bas
048fa33747 Merge pull request #13 from xtr-dev/dev
Fix TypeScript build error with payload email adapter
2025-09-13 16:18:41 +02:00
fa028ff540 Bump package version to 0.0.6 in package.json. 2025-09-13 16:18:04 +02:00
428a5f45cd Fix TypeScript build error with payload email adapter
- Update transporter type to handle different email adapter interfaces
- Add type casting for payload.email to resolve compatibility issues
- Build now completes successfully without TypeScript errors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:11:35 +02:00
Bas
cb62874500 Merge pull request #12 from xtr-dev/dev
Make mailer transport config optional, use Payload config fallback
2025-09-13 16:04:26 +02:00
196aaeab9c Fix critical typo and improve type safety
- Fix typo: nodemailer.createTransporter -> createTransport
- Add type safety check for payload.email with sendMail validation
- Prevent runtime errors from invalid transporter objects

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:02:11 +02:00
a32d5688c4 Make mailer transport config optional, use Payload config fallback
- Updated MailingService to check for Payload's email transporter when plugin transport is not configured
- Enhanced error message to indicate transport is required in either plugin or Payload config
- Bump version to 0.0.5

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 15:56:25 +02:00
7 changed files with 433 additions and 101 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/payload-mailing",
"version": "0.0.4",
"version": "0.0.12",
"description": "Template-based email system with scheduling and job processing for PayloadCMS",
"type": "module",
"main": "dist/index.js",
@@ -47,9 +47,12 @@
"payload": "^3.37.0"
},
"dependencies": {
"handlebars": "^4.7.8",
"nodemailer": "^6.9.8"
},
"optionalDependencies": {
"liquidjs": "^10.19.0",
"mustache": "^4.2.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@payloadcms/db-mongodb": "^3.37.0",

66
pnpm-lock.yaml generated
View File

@@ -8,9 +8,6 @@ importers:
.:
dependencies:
handlebars:
specifier: ^4.7.8
version: 4.7.8
nodemailer:
specifier: ^6.9.8
version: 6.10.1
@@ -117,6 +114,13 @@ importers:
vitest:
specifier: ^3.1.2
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(sass@1.77.4)(tsx@4.20.3)
optionalDependencies:
liquidjs:
specifier: ^10.19.0
version: 10.21.1
mustache:
specifier: ^4.2.0
version: 4.2.0
packages:
@@ -2637,6 +2641,10 @@ packages:
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
commander@10.0.1:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -3540,11 +3548,6 @@ packages:
resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'}
hasBin: true
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -3922,6 +3925,11 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
liquidjs@10.21.1:
resolution: {integrity: sha512-NZXmCwv3RG5nire3fmIn9HsOyJX3vo+ptp0yaXUHAMzSNBhx74Hm+dAGJvscUA6lNqbLuYfXgNavRQ9UbUJhQQ==}
engines: {node: '>=14'}
hasBin: true
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@@ -4186,6 +4194,10 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mustache@4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -4202,9 +4214,6 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
new-find-package-json@2.0.0:
resolution: {integrity: sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==}
engines: {node: '>=12.22.0'}
@@ -5170,11 +5179,6 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uglify-js@3.19.3:
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
engines: {node: '>=0.8.0'}
hasBin: true
uint8array-extras@1.5.0:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
@@ -5377,9 +5381,6 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -8540,6 +8541,9 @@ snapshots:
colorette@2.0.20: {}
commander@10.0.1:
optional: true
commander@2.20.3: {}
commander@6.2.1: {}
@@ -9689,15 +9693,6 @@ snapshots:
graphql@16.11.0: {}
handlebars@4.7.8:
dependencies:
minimist: 1.2.8
neo-async: 2.6.2
source-map: 0.6.1
wordwrap: 1.0.0
optionalDependencies:
uglify-js: 3.19.3
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
@@ -10044,6 +10039,11 @@ snapshots:
lines-and-columns@1.2.4: {}
liquidjs@10.21.1:
dependencies:
commander: 10.0.1
optional: true
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
@@ -10431,6 +10431,9 @@ snapshots:
ms@2.1.3: {}
mustache@4.2.0:
optional: true
nanoid@3.3.11: {}
napi-postinstall@0.3.3: {}
@@ -10439,8 +10442,6 @@ snapshots:
natural-compare@1.4.0: {}
neo-async@2.6.2: {}
new-find-package-json@2.0.0:
dependencies:
debug: 4.4.1
@@ -11571,9 +11572,6 @@ snapshots:
typescript@5.9.2: {}
uglify-js@3.19.3:
optional: true
uint8array-extras@1.5.0: {}
unbox-primitive@1.1.0:
@@ -11820,8 +11818,6 @@ snapshots:
word-wrap@1.2.5: {}
wordwrap@1.0.0: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0

View File

@@ -4,9 +4,61 @@ import { MailingService } from './services/MailingService.js'
import { createEmailTemplatesCollection } from './collections/EmailTemplates.js'
import Emails from './collections/Emails.js'
// Helper function to schedule the email processing job
async function scheduleEmailProcessingJob(payload: any, queueName: string, delayMs: number = 60000): Promise<boolean> {
if (!queueName || typeof queueName !== 'string') {
throw new Error('Invalid queueName: must be a non-empty string')
}
const jobSlug = 'process-email-queue'
// Check if there's already a scheduled job for this task
const existingJobs = await payload.find({
collection: 'payload-jobs',
where: {
and: [
{
taskSlug: {
equals: jobSlug,
},
},
{
hasCompleted: {
equals: false,
},
},
],
},
limit: 1,
})
// If no existing job, schedule a new one
if (existingJobs.docs.length === 0) {
await payload.create({
collection: 'payload-jobs',
data: {
taskSlug: jobSlug,
input: {},
queue: queueName,
waitUntil: new Date(Date.now() + delayMs),
},
})
console.log(`🔄 Scheduled email processing job in queue: ${queueName}`)
return true
} else {
console.log(`✅ Email processing job already scheduled in queue: ${queueName}`)
return false
}
}
export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
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
const templatesConfig = pluginConfig.collections?.templates
const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates'
@@ -80,8 +132,11 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
{
slug: 'process-email-queue',
handler: async ({ job, req }: { job: any; req: any }) => {
const payload = (req as any).payload
let jobResult = null
try {
const mailingService = new MailingService((req as any).payload, pluginConfig)
const mailingService = new MailingService(payload, pluginConfig)
console.log('🔄 Processing email queue (pending + failed emails)...')
@@ -91,19 +146,41 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
// Then retry failed emails
await mailingService.retryFailedEmails()
return {
jobResult = {
output: {
success: true,
message: 'Email queue processed successfully (pending and failed emails)'
}
}
console.log('✅ Email queue processing completed successfully')
} catch (error) {
console.error('❌ Error processing email queue:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
// Properly fail the job by throwing the error
throw new Error(`Email queue processing failed: ${errorMessage}`)
jobResult = new Error(`Email queue processing failed: ${errorMessage}`)
}
// Always reschedule the next job (success or failure) using duplicate prevention
let rescheduled = false
try {
rescheduled = await scheduleEmailProcessingJob(payload, queueName, 300000) // Reschedule in 5 minutes
if (rescheduled) {
console.log(`🔄 Rescheduled next email processing job in ${queueName} queue`)
}
} catch (rescheduleError) {
console.error('❌ Failed to reschedule email processing job:', rescheduleError)
// If rescheduling fails, we should warn but not fail the current job
// since the email processing itself may have succeeded
console.warn('⚠️ Email processing completed but next job could not be scheduled')
}
// Return the original result or throw the error
if (jobResult instanceof Error) {
throw jobResult
}
return jobResult
},
interfaceName: 'ProcessEmailQueueJob',
},
@@ -130,6 +207,13 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
console.log('PayloadCMS Mailing Plugin initialized successfully')
// Schedule the email processing job if not already scheduled
try {
await scheduleEmailProcessingJob(payload, queueName)
} catch (error) {
console.error('Failed to schedule email processing job:', error)
}
// Call onReady callback if provided
if (pluginConfig.onReady) {
await pluginConfig.onReady(payload)

View File

@@ -1,9 +1,9 @@
import { Payload } from 'payload'
import Handlebars from 'handlebars'
import { Liquid } from 'liquidjs'
import nodemailer, { Transporter } from 'nodemailer'
import {
MailingPluginConfig,
SendEmailOptions,
import {
MailingPluginConfig,
SendEmailOptions,
MailingService as IMailingService,
EmailTemplate,
QueuedEmail,
@@ -15,22 +15,22 @@ import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richT
export class MailingService implements IMailingService {
private payload: Payload
private config: MailingPluginConfig
private transporter!: Transporter
private transporter!: Transporter | any
private templatesCollection: string
private emailsCollection: string
private liquid: Liquid | null | false = null
constructor(payload: Payload, config: MailingPluginConfig) {
this.payload = payload
this.config = config
const templatesConfig = config.collections?.templates
this.templatesCollection = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates'
const emailsConfig = config.collections?.emails
this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails'
this.initializeTransporter()
this.registerHandlebarsHelpers()
}
private initializeTransporter(): void {
@@ -40,44 +40,71 @@ export class MailingService implements IMailingService {
} 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')
throw new Error('Email transport configuration is required either in plugin config or Payload config')
}
}
private registerHandlebarsHelpers(): void {
Handlebars.registerHelper('formatDate', (date: Date, format?: string) => {
if (!date) return ''
const d = new Date(date)
if (format === 'short') {
return d.toLocaleDateString()
}
if (format === 'long') {
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
private getDefaultFrom(): string {
const fromEmail = this.config.defaultFrom
const fromName = this.config.defaultFromName
// Check if fromName exists, is not empty after trimming, and fromEmail exists
if (fromName && fromName.trim() && fromEmail) {
// Escape quotes in the display name to prevent malformed headers
const escapedName = fromName.replace(/"/g, '\\"')
return `"${escapedName}" <${fromEmail}>`
}
return fromEmail || ''
}
private async ensureLiquidJSInitialized(): Promise<void> {
if (this.liquid !== null) return // Already initialized or failed
try {
const liquidModule = await import('liquidjs')
const { Liquid: LiquidEngine } = liquidModule
this.liquid = new LiquidEngine()
// Register custom filters (equivalent to Handlebars helpers)
if (this.liquid && typeof this.liquid !== 'boolean') {
this.liquid.registerFilter('formatDate', (date: any, format?: string) => {
if (!date) return ''
const d = new Date(date)
if (format === 'short') {
return d.toLocaleDateString()
}
if (format === 'long') {
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
return d.toLocaleString()
})
this.liquid.registerFilter('formatCurrency', (amount: any, currency = 'USD') => {
if (typeof amount !== 'number') return amount
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(amount)
})
this.liquid.registerFilter('capitalize', (str: any) => {
if (typeof str !== 'string') return str
return str.charAt(0).toUpperCase() + str.slice(1)
})
}
return d.toLocaleString()
})
Handlebars.registerHelper('formatCurrency', (amount: number, currency = 'USD') => {
if (typeof amount !== 'number') return amount
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(amount)
})
Handlebars.registerHelper('ifEquals', function(this: any, arg1: any, arg2: any, options: any) {
return (arg1 === arg2) ? options.fn(this) : options.inverse(this)
})
Handlebars.registerHelper('capitalize', (str: string) => {
if (typeof str !== 'string') return str
return str.charAt(0).toUpperCase() + str.slice(1)
})
} catch (error) {
console.warn('LiquidJS not available. Falling back to simple variable replacement. Install liquidjs or use a different templateEngine.')
this.liquid = false // Mark as failed to avoid retries
}
}
async sendEmail(options: SendEmailOptions): Promise<string> {
@@ -87,7 +114,7 @@ export class MailingService implements IMailingService {
})
await this.processEmailItem(emailId)
return emailId
}
@@ -99,14 +126,14 @@ export class MailingService implements IMailingService {
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 = this.renderHandlebarsTemplate(template.subject, variables)
subject = await this.renderTemplate(template.subject, variables)
} else {
throw new Error(`Email template not found: ${options.templateSlug}`)
}
@@ -125,7 +152,7 @@ export class MailingService implements IMailingService {
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.config.defaultFrom,
from: options.from || this.getDefaultFrom(),
replyTo: options.replyTo,
subject: subject || options.subject,
html,
@@ -147,7 +174,7 @@ export class MailingService implements IMailingService {
async processEmails(): Promise<void> {
const currentTime = new Date().toISOString()
const { docs: pendingEmails } = await this.payload.find({
collection: this.emailsCollection as any,
where: {
@@ -242,7 +269,7 @@ export class MailingService implements IMailingService {
}) as QueuedEmail
let emailObject: EmailObject = {
from: email.from || this.config.defaultFrom,
from: email.from || this.getDefaultFrom(),
to: email.to,
cc: email.cc || undefined,
bcc: email.bcc || undefined,
@@ -259,7 +286,7 @@ export class MailingService implements IMailingService {
}
const mailOptions = {
from: emailObject.from || this.config.defaultFrom,
from: emailObject.from,
to: emailObject.to,
cc: emailObject.cc || undefined,
bcc: emailObject.bcc || undefined,
@@ -331,7 +358,7 @@ export class MailingService implements IMailingService {
},
limit: 1,
})
return docs.length > 0 ? docs[0] as EmailTemplate : null
} catch (error) {
console.error(`Template with slug '${templateSlug}' not found:`, error)
@@ -339,14 +366,62 @@ export class MailingService implements IMailingService {
}
}
private renderHandlebarsTemplate(template: string, variables: Record<string, any>): string {
try {
const compiled = Handlebars.compile(template)
return compiled(variables)
} catch (error) {
console.error('Handlebars template rendering error:', error)
return template
private async renderTemplate(template: string, variables: Record<string, any>): Promise<string> {
// Use custom template renderer if provided
if (this.config.templateRenderer) {
try {
return await this.config.templateRenderer(template, variables)
} catch (error) {
console.error('Custom template renderer error:', error)
return template
}
}
const engine = this.config.templateEngine || 'liquidjs'
// Use LiquidJS if configured
if (engine === 'liquidjs') {
try {
await this.ensureLiquidJSInitialized()
if (this.liquid && typeof this.liquid !== 'boolean') {
return await this.liquid.parseAndRender(template, variables)
}
} catch (error) {
console.error('LiquidJS template rendering error:', error)
}
}
// Use Mustache if configured
if (engine === 'mustache') {
try {
const mustacheResult = await this.renderWithMustache(template, variables)
if (mustacheResult !== null) {
return mustacheResult
}
} catch (error) {
console.warn('Mustache not available. Falling back to simple variable replacement. Install mustache package.')
}
}
// Fallback to simple variable replacement
return this.simpleVariableReplacement(template, variables)
}
private async renderWithMustache(template: string, variables: Record<string, any>): Promise<string | null> {
try {
const mustacheModule = await import('mustache')
const Mustache = mustacheModule.default || mustacheModule
return Mustache.render(template, variables)
} catch (error) {
return null
}
}
private simpleVariableReplacement(template: string, variables: Record<string, any>): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
const value = variables[key]
return value !== undefined ? String(value) : match
})
}
private async renderEmailTemplate(template: EmailTemplate, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
@@ -358,11 +433,11 @@ export class MailingService implements IMailingService {
let html = serializeRichTextToHTML(template.content)
let text = serializeRichTextToText(template.content)
// Apply Handlebars variables to the rendered content
html = this.renderHandlebarsTemplate(html, variables)
text = this.renderHandlebarsTemplate(text, variables)
// Apply template variables to the rendered content
html = await this.renderTemplate(html, variables)
text = await this.renderTemplate(text, variables)
return { html, text }
}
}
}

View File

@@ -16,17 +16,24 @@ export interface EmailObject {
export type EmailWrapperHook = (email: EmailObject) => EmailObject | Promise<EmailObject>
export type TemplateRendererHook = (template: string, variables: Record<string, any>) => string | Promise<string>
export type TemplateEngine = 'liquidjs' | 'mustache' | 'simple'
export interface MailingPluginConfig {
collections?: {
templates?: string | Partial<CollectionConfig>
emails?: string | Partial<CollectionConfig>
}
defaultFrom?: string
defaultFromName?: string
transport?: Transporter | MailingTransportConfig
queue?: string
retryAttempts?: number
retryDelay?: number
emailWrapper?: EmailWrapperHook
templateRenderer?: TemplateRendererHook
templateEngine?: TemplateEngine
richTextEditor?: RichTextField['editor']
onReady?: (payload: any) => Promise<void>
initOrder?: 'before' | 'after'

7
src/types/mustache.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare module 'mustache' {
interface MustacheStatic {
render(template: string, view?: any, partials?: any, tags?: string[]): string
}
const mustache: MustacheStatic
export = mustache
}

View File

@@ -0,0 +1,160 @@
# 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<string, any>) => {
const compiled = Handlebars.compile(template)
return compiled(variables)
}
})
// Example with Mustache
import Mustache from 'mustache'
mailingPlugin({
templateRenderer: async (template: string, variables: Record<string, any>) => {
return Mustache.render(template, variables)
}
})
// Example with Nunjucks
import nunjucks from 'nunjucks'
mailingPlugin({
templateRenderer: async (template: string, variables: Record<string, any>) => {
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 %}
```