diff --git a/package.json b/package.json index a404dc2..94d8c49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-mailing", - "version": "0.0.8", + "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2f46f0..5db65df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/services/MailingService.ts b/src/services/MailingService.ts index fe4f5d5..adfc803 100644 --- a/src/services/MailingService.ts +++ b/src/services/MailingService.ts @@ -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, @@ -18,19 +18,19 @@ export class MailingService implements IMailingService { 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 { @@ -62,39 +62,49 @@ export class MailingService implements IMailingService { return fromEmail || '' } - 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 async ensureLiquidJSInitialized(): Promise { + 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 { @@ -104,7 +114,7 @@ export class MailingService implements IMailingService { }) await this.processEmailItem(emailId) - + return emailId } @@ -116,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}`) } @@ -164,7 +174,7 @@ export class MailingService implements IMailingService { async processEmails(): Promise { const currentTime = new Date().toISOString() - + const { docs: pendingEmails } = await this.payload.find({ collection: this.emailsCollection as any, where: { @@ -348,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) @@ -356,14 +366,62 @@ export class MailingService implements IMailingService { } } - private renderHandlebarsTemplate(template: string, variables: Record): 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): Promise { + // 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): Promise { + 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 { + return template.replace(/\{\{(\w+)\}\}/g, (match, key) => { + const value = variables[key] + return value !== undefined ? String(value) : match + }) } private async renderEmailTemplate(template: EmailTemplate, variables: Record = {}): Promise<{ html: string; text: string }> { @@ -375,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 } } -} \ No newline at end of file +} diff --git a/src/types/index.ts b/src/types/index.ts index 8a7c3e4..14bb179 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,6 +16,10 @@ export interface EmailObject { export type EmailWrapperHook = (email: EmailObject) => EmailObject | Promise +export type TemplateRendererHook = (template: string, variables: Record) => string | Promise + +export type TemplateEngine = 'liquidjs' | 'mustache' | 'simple' + export interface MailingPluginConfig { collections?: { templates?: string | Partial @@ -28,6 +32,8 @@ export interface MailingPluginConfig { retryAttempts?: number retryDelay?: number emailWrapper?: EmailWrapperHook + templateRenderer?: TemplateRendererHook + templateEngine?: TemplateEngine richTextEditor?: RichTextField['editor'] onReady?: (payload: any) => Promise initOrder?: 'before' | 'after' diff --git a/src/types/mustache.d.ts b/src/types/mustache.d.ts new file mode 100644 index 0000000..85a34b3 --- /dev/null +++ b/src/types/mustache.d.ts @@ -0,0 +1,7 @@ +declare module 'mustache' { + interface MustacheStatic { + render(template: string, view?: any, partials?: any, tags?: string[]): string + } + const mustache: MustacheStatic + export = mustache +} \ No newline at end of file diff --git a/template-syntax-migration.md b/template-syntax-migration.md new file mode 100644 index 0000000..03c3957 --- /dev/null +++ b/template-syntax-migration.md @@ -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) => { + 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