Remove email outbox collection and process job; refactor email templates with rich text support and slug generation

This commit is contained in:
2025-09-13 12:11:35 +02:00
parent ed9d979d3e
commit 3868e74770
34 changed files with 2674 additions and 374 deletions

View File

@@ -1,6 +1,7 @@
import { CollectionConfig } from 'payload/types'
import type { CollectionConfig, RichTextField } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
const EmailTemplates: CollectionConfig = {
export const createEmailTemplatesCollection = (editor?: RichTextField['editor']): CollectionConfig => ({
slug: 'email-templates',
admin: {
useAsTitle: 'name',
@@ -22,84 +23,50 @@ const EmailTemplates: CollectionConfig = {
description: 'A descriptive name for this email template',
},
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
description: 'Unique identifier for this template (e.g., "welcome-email", "password-reset")',
},
hooks: {
beforeChange: [
({ value }) => {
if (value) {
return value.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
}
return value
},
],
},
},
{
name: 'subject',
type: 'text',
required: true,
admin: {
description: 'Email subject line (supports Handlebars variables)',
description: 'Email subject line. You can use Handlebars variables like {{firstName}} or {{siteName}}.',
},
},
{
name: 'htmlTemplate',
type: 'textarea',
name: 'content',
type: 'richText',
required: true,
editor: editor || lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
],
}),
admin: {
description: 'HTML email template (supports Handlebars syntax)',
rows: 10,
},
},
{
name: 'textTemplate',
type: 'textarea',
admin: {
description: 'Plain text email template (supports Handlebars syntax)',
rows: 8,
},
},
{
name: 'variables',
type: 'array',
admin: {
description: 'Define variables that can be used in this template',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
admin: {
description: 'Variable name (e.g., "firstName", "orderTotal")',
},
},
{
name: 'type',
type: 'select',
required: true,
options: [
{ label: 'Text', value: 'text' },
{ label: 'Number', value: 'number' },
{ label: 'Boolean', value: 'boolean' },
{ label: 'Date', value: 'date' },
],
defaultValue: 'text',
},
{
name: 'required',
type: 'checkbox',
defaultValue: false,
admin: {
description: 'Is this variable required when sending emails?',
},
},
{
name: 'description',
type: 'text',
admin: {
description: 'Optional description of what this variable represents',
},
},
],
},
{
name: 'previewData',
type: 'json',
admin: {
description: 'Sample data for previewing this template (JSON format)',
description: 'Email content with rich text formatting. Supports Handlebars variables like {{firstName}} and helpers like {{formatDate createdAt "long"}}. Content is converted to HTML and plain text automatically.',
},
},
],
timestamps: true,
}
})
// Default export for backward compatibility
const EmailTemplates = createEmailTemplatesCollection()
export default EmailTemplates

View File

@@ -1,11 +1,12 @@
import { CollectionConfig } from 'payload/types'
import type { CollectionConfig } from 'payload'
const EmailOutbox: CollectionConfig = {
slug: 'email-outbox',
const Emails: CollectionConfig = {
slug: 'emails',
admin: {
useAsTitle: 'subject',
defaultColumns: ['subject', 'to', 'status', 'scheduledAt', 'sentAt'],
group: 'Mailing',
description: 'Email delivery and status tracking',
},
access: {
read: () => true,
@@ -17,7 +18,7 @@ const EmailOutbox: CollectionConfig = {
{
name: 'template',
type: 'relationship',
relationTo: 'email-templates',
relationTo: 'email-templates' as const,
admin: {
description: 'Email template used (optional if custom content provided)',
},
@@ -26,22 +27,25 @@ const EmailOutbox: CollectionConfig = {
name: 'to',
type: 'text',
required: true,
hasMany: true,
admin: {
description: 'Recipient email address(es), comma-separated',
description: 'Recipient email addresses',
},
},
{
name: 'cc',
type: 'text',
hasMany: true,
admin: {
description: 'CC email address(es), comma-separated',
description: 'CC email addresses',
},
},
{
name: 'bcc',
type: 'text',
hasMany: true,
admin: {
description: 'BCC email address(es), comma-separated',
description: 'BCC email addresses',
},
},
{
@@ -161,20 +165,20 @@ const EmailOutbox: CollectionConfig = {
},
],
timestamps: true,
indexes: [
{
fields: {
status: 1,
scheduledAt: 1,
},
},
{
fields: {
priority: -1,
createdAt: 1,
},
},
],
// indexes: [
// {
// fields: {
// status: 1,
// scheduledAt: 1,
// },
// },
// {
// fields: {
// priority: -1,
// createdAt: 1,
// },
// },
// ],
}
export default EmailOutbox
export default Emails

View File

@@ -1,25 +1,23 @@
// Main plugin export
export { default as mailingPlugin } from './plugin'
export { mailingPlugin } from './plugin'
export { mailingPlugin, default as mailingPluginDefault } from './plugin.js'
// Types
export * from './types'
export * from './types/index.js'
// Services
export { MailingService } from './services/MailingService'
export { MailingService } from './services/MailingService.js'
// Collections
export { default as EmailTemplates } from './collections/EmailTemplates'
export { default as EmailOutbox } from './collections/EmailOutbox'
export { default as EmailTemplates, createEmailTemplatesCollection } from './collections/EmailTemplates.js'
export { default as Emails } from './collections/Emails.js'
// Jobs
export * from './jobs'
// Jobs are integrated into the plugin configuration
// Utility functions for developers
export {
getMailing,
sendEmail,
scheduleEmail,
processOutbox,
processEmails,
retryFailedEmails,
} from './utils/helpers'
} from './utils/helpers.js'

View File

@@ -1,20 +1,19 @@
import { Job } from 'payload/jobs'
import { processOutboxJob, ProcessOutboxJobData } from './processOutboxJob'
import { MailingService } from '../services/MailingService'
import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js'
import { MailingService } from '../services/MailingService.js'
export const createMailingJobs = (mailingService: MailingService): Job[] => {
export const createMailingJobs = (mailingService: MailingService): any[] => {
return [
{
slug: 'processOutbox',
handler: async ({ job, req }) => {
return processOutboxJob(
job as { data: ProcessOutboxJobData },
slug: 'processEmails',
handler: async ({ job, req }: { job: any; req: any }) => {
return processEmailsJob(
job as { data: ProcessEmailsJobData },
{ req, mailingService }
)
},
interfaceName: 'ProcessOutboxJob',
interfaceName: 'ProcessEmailsJob',
},
]
}
export * from './processOutboxJob'
export * from './processEmailsJob.js'

View File

@@ -1,21 +1,21 @@
import { PayloadRequest } from 'payload/types'
import { MailingService } from '../services/MailingService'
import type { PayloadRequest } from 'payload'
import { MailingService } from '../services/MailingService.js'
export interface ProcessOutboxJobData {
type: 'process-outbox' | 'retry-failed'
export interface ProcessEmailsJobData {
type: 'process-emails' | 'retry-failed'
}
export const processOutboxJob = async (
job: { data: ProcessOutboxJobData },
export const processEmailsJob = async (
job: { data: ProcessEmailsJobData },
context: { req: PayloadRequest; mailingService: MailingService }
) => {
const { mailingService } = context
const { type } = job.data
try {
if (type === 'process-outbox') {
await mailingService.processOutbox()
console.log('Outbox processing completed successfully')
if (type === 'process-emails') {
await mailingService.processEmails()
console.log('Email processing completed successfully')
} else if (type === 'retry-failed') {
await mailingService.retryFailedEmails()
console.log('Failed email retry completed successfully')
@@ -26,10 +26,10 @@ export const processOutboxJob = async (
}
}
export const scheduleOutboxJob = async (
export const scheduleEmailsJob = async (
payload: any,
queueName: string,
jobType: 'process-outbox' | 'retry-failed',
jobType: 'process-emails' | 'retry-failed',
delay?: number
) => {
if (!payload.jobs) {
@@ -40,7 +40,7 @@ export const scheduleOutboxJob = async (
try {
await payload.jobs.queue({
queue: queueName,
task: 'processOutbox',
task: 'processEmails',
input: { type: jobType },
waitUntil: delay ? new Date(Date.now() + delay) : undefined,
})

View File

@@ -1,26 +1,57 @@
import { Config } from 'payload/config'
import { MailingPluginConfig, MailingContext } from './types'
import { MailingService } from './services/MailingService'
import { createMailingJobs } from './jobs'
import EmailTemplates from './collections/EmailTemplates'
import EmailOutbox from './collections/EmailOutbox'
import { scheduleOutboxJob } from './jobs/processOutboxJob'
import type { Config } from 'payload'
import { MailingPluginConfig, MailingContext } from './types/index.js'
import { MailingService } from './services/MailingService.js'
import { createEmailTemplatesCollection } from './collections/EmailTemplates.js'
import Emails from './collections/Emails.js'
export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
const templatesSlug = pluginConfig.collections?.templates || 'email-templates'
const outboxSlug = pluginConfig.collections?.outbox || 'email-outbox'
const queueName = pluginConfig.queue || 'default'
// Update collection slugs if custom ones are provided
// Handle templates collection configuration
const templatesConfig = pluginConfig.collections?.templates
const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates'
const templatesOverrides = typeof templatesConfig === 'object' ? templatesConfig : {}
// Create base templates collection with custom editor if provided
const baseTemplatesCollection = createEmailTemplatesCollection(pluginConfig.richTextEditor)
const templatesCollection = {
...EmailTemplates,
...baseTemplatesCollection,
slug: templatesSlug,
...templatesOverrides,
// Ensure admin config is properly merged
admin: {
...baseTemplatesCollection.admin,
...templatesOverrides.admin,
},
// Ensure access config is properly merged
access: {
...baseTemplatesCollection.access,
...templatesOverrides.access,
},
}
const outboxCollection = {
...EmailOutbox,
slug: outboxSlug,
fields: EmailOutbox.fields.map(field => {
// Handle emails collection configuration
const emailsConfig = pluginConfig.collections?.emails
const emailsSlug = typeof emailsConfig === 'string' ? emailsConfig : 'emails'
const emailsOverrides = typeof emailsConfig === 'object' ? emailsConfig : {}
const emailsCollection = {
...Emails,
slug: emailsSlug,
...emailsOverrides,
// Ensure admin config is properly merged
admin: {
...Emails.admin,
...emailsOverrides.admin,
},
// Ensure access config is properly merged
access: {
...Emails.access,
...emailsOverrides.access,
},
// Update relationship fields to point to correct templates collection
fields: (emailsOverrides.fields || Emails.fields).map((field: any) => {
if (field.name === 'template' && field.type === 'relationship') {
return {
...field,
@@ -36,63 +67,77 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
collections: [
...(config.collections || []),
templatesCollection,
outboxCollection,
emailsCollection,
],
jobs: {
...(config.jobs || {}),
tasks: [
...(config.jobs?.tasks || []),
// Jobs will be added via onInit hook
{
slug: 'process-email-queue',
handler: async ({ job, req }: { job: any; req: any }) => {
try {
const mailingService = new MailingService((req as any).payload, pluginConfig)
console.log('🔄 Processing email queue (pending + failed emails)...')
// Process pending emails first
await mailingService.processEmails()
// Then retry failed emails
await mailingService.retryFailedEmails()
return {
output: {
success: true,
message: 'Email queue processed successfully (pending and failed emails)'
}
}
} catch (error) {
console.error('❌ Error processing email queue:', error)
return {
output: {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
}
},
interfaceName: 'ProcessEmailQueueJob',
},
],
},
onInit: async (payload) => {
// Call original onInit if it exists
if (config.onInit) {
onInit: async (payload: any) => {
if (pluginConfig.initOrder === 'after' && config.onInit) {
await config.onInit(payload)
}
// Initialize mailing service
const mailingService = new MailingService(payload, pluginConfig)
// Add mailing jobs
const mailingJobs = createMailingJobs(mailingService)
if (payload.jobs) {
mailingJobs.forEach(job => {
payload.jobs.addTask(job)
})
}
// Schedule periodic outbox processing (every 5 minutes)
const schedulePeriodicJob = async () => {
await scheduleOutboxJob(payload, queueName, 'process-outbox', 5 * 60 * 1000) // 5 minutes
setTimeout(schedulePeriodicJob, 5 * 60 * 1000) // Schedule next run
}
// Schedule periodic retry job (every 30 minutes)
const scheduleRetryJob = async () => {
await scheduleOutboxJob(payload, queueName, 'retry-failed', 30 * 60 * 1000) // 30 minutes
setTimeout(scheduleRetryJob, 30 * 60 * 1000) // Schedule next run
}
// Start periodic jobs if jobs are enabled
if (payload.jobs) {
setTimeout(schedulePeriodicJob, 5 * 60 * 1000) // Start after 5 minutes
setTimeout(scheduleRetryJob, 15 * 60 * 1000) // Start after 15 minutes
}
// Add mailing context to payload for developer access
;(payload as any).mailing = {
payload,
service: mailingService,
config: pluginConfig,
collections: {
templates: templatesSlug,
outbox: outboxSlug,
emails: emailsSlug,
},
} as MailingContext
console.log('PayloadCMS Mailing Plugin initialized successfully')
// Call onReady callback if provided
if (pluginConfig.onReady) {
await pluginConfig.onReady(payload)
}
if (pluginConfig.initOrder !== 'after' && config.onInit) {
await config.onInit(payload)
}
},
}
}
export default mailingPlugin
export default mailingPlugin

View File

@@ -6,22 +6,28 @@ import {
SendEmailOptions,
MailingService as IMailingService,
EmailTemplate,
OutboxEmail,
MailingTransportConfig
} from '../types'
QueuedEmail,
MailingTransportConfig,
EmailObject
} from '../types/index.js'
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
export class MailingService implements IMailingService {
private payload: Payload
private config: MailingPluginConfig
private transporter: Transporter
private transporter!: Transporter
private templatesCollection: string
private outboxCollection: string
private emailsCollection: string
constructor(payload: Payload, config: MailingPluginConfig) {
this.payload = payload
this.config = config
this.templatesCollection = config.collections?.templates || 'email-templates'
this.outboxCollection = config.collections?.outbox || 'email-outbox'
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()
@@ -32,7 +38,7 @@ export class MailingService implements IMailingService {
if ('sendMail' in this.config.transport) {
this.transporter = this.config.transport
} else {
this.transporter = nodemailer.createTransporter(this.config.transport as MailingTransportConfig)
this.transporter = nodemailer.createTransport(this.config.transport as MailingTransportConfig)
}
} else {
throw new Error('Email transport configuration is required')
@@ -64,7 +70,7 @@ export class MailingService implements IMailingService {
}).format(amount)
})
Handlebars.registerHelper('ifEquals', function(arg1: any, arg2: any, options: any) {
Handlebars.registerHelper('ifEquals', function(this: any, arg1: any, arg2: any, options: any) {
return (arg1 === arg2) ? options.fn(this) : options.inverse(this)
})
@@ -75,29 +81,34 @@ export class MailingService implements IMailingService {
}
async sendEmail(options: SendEmailOptions): Promise<string> {
const outboxId = await this.scheduleEmail({
const emailId = await this.scheduleEmail({
...options,
scheduledAt: new Date()
})
await this.processOutboxItem(outboxId)
await this.processEmailItem(emailId)
return outboxId
return emailId
}
async scheduleEmail(options: SendEmailOptions): Promise<string> {
let html = options.html || ''
let text = options.text || ''
let subject = options.subject || ''
let templateId: string | undefined = undefined
if (options.templateId) {
const template = await this.getTemplate(options.templateId)
if (options.templateSlug) {
const template = await this.getTemplateBySlug(options.templateSlug)
if (template) {
templateId = template.id
const variables = options.variables || {}
html = this.renderTemplate(template.htmlTemplate, variables)
text = template.textTemplate ? this.renderTemplate(template.textTemplate, variables) : ''
subject = this.renderTemplate(template.subject, variables)
const renderedContent = await this.renderEmailTemplate(template, variables)
html = renderedContent.html
text = renderedContent.text
subject = this.renderHandlebarsTemplate(template.subject, variables)
} else {
throw new Error(`Email template not found: ${options.templateSlug}`)
}
}
@@ -109,11 +120,11 @@ export class MailingService implements IMailingService {
throw new Error('Email HTML content is required')
}
const outboxData = {
template: options.templateId || undefined,
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
cc: options.cc ? (Array.isArray(options.cc) ? options.cc.join(', ') : options.cc) : undefined,
bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc.join(', ') : options.bcc) : undefined,
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.config.defaultFrom,
replyTo: options.replyTo,
subject: subject || options.subject,
@@ -127,18 +138,18 @@ export class MailingService implements IMailingService {
}
const result = await this.payload.create({
collection: this.outboxCollection,
data: outboxData,
collection: this.emailsCollection as any,
data: queueData,
})
return result.id as string
}
async processOutbox(): Promise<void> {
async processEmails(): Promise<void> {
const currentTime = new Date().toISOString()
const { docs: pendingEmails } = await this.payload.find({
collection: this.outboxCollection,
collection: this.emailsCollection as any,
where: {
and: [
{
@@ -167,7 +178,7 @@ export class MailingService implements IMailingService {
})
for (const email of pendingEmails) {
await this.processOutboxItem(email.id)
await this.processEmailItem(String(email.id))
}
}
@@ -177,7 +188,7 @@ export class MailingService implements IMailingService {
const retryTime = new Date(Date.now() - retryDelay).toISOString()
const { docs: failedEmails } = await this.payload.find({
collection: this.outboxCollection,
collection: this.emailsCollection as any,
where: {
and: [
{
@@ -210,15 +221,15 @@ export class MailingService implements IMailingService {
})
for (const email of failedEmails) {
await this.processOutboxItem(email.id)
await this.processEmailItem(String(email.id))
}
}
private async processOutboxItem(outboxId: string): Promise<void> {
private async processEmailItem(emailId: string): Promise<void> {
try {
await this.payload.update({
collection: this.outboxCollection,
id: outboxId,
collection: this.emailsCollection as any,
id: emailId,
data: {
status: 'processing',
lastAttemptAt: new Date().toISOString(),
@@ -226,11 +237,11 @@ export class MailingService implements IMailingService {
})
const email = await this.payload.findByID({
collection: this.outboxCollection,
id: outboxId,
}) as OutboxEmail
collection: this.emailsCollection as any,
id: emailId,
}) as QueuedEmail
const mailOptions = {
let emailObject: EmailObject = {
from: email.from || this.config.defaultFrom,
to: email.to,
cc: email.cc || undefined,
@@ -239,13 +250,30 @@ export class MailingService implements IMailingService {
subject: email.subject,
html: email.html,
text: email.text || undefined,
variables: email.variables,
}
// Apply emailWrapper hook if configured
if (this.config.emailWrapper) {
emailObject = await this.config.emailWrapper(emailObject)
}
const mailOptions = {
from: emailObject.from || this.config.defaultFrom,
to: emailObject.to,
cc: emailObject.cc || undefined,
bcc: emailObject.bcc || undefined,
replyTo: emailObject.replyTo || undefined,
subject: emailObject.subject,
html: emailObject.html,
text: emailObject.text || undefined,
}
await this.transporter.sendMail(mailOptions)
await this.payload.update({
collection: this.outboxCollection,
id: outboxId,
collection: this.emailsCollection as any,
id: emailId,
data: {
status: 'sent',
sentAt: new Date().toISOString(),
@@ -254,12 +282,12 @@ export class MailingService implements IMailingService {
})
} catch (error) {
const attempts = await this.incrementAttempts(outboxId)
const attempts = await this.incrementAttempts(emailId)
const maxAttempts = this.config.retryAttempts || 3
await this.payload.update({
collection: this.outboxCollection,
id: outboxId,
collection: this.emailsCollection as any,
id: emailId,
data: {
status: attempts >= maxAttempts ? 'failed' : 'pending',
error: error instanceof Error ? error.message : 'Unknown error',
@@ -268,22 +296,22 @@ export class MailingService implements IMailingService {
})
if (attempts >= maxAttempts) {
console.error(`Email ${outboxId} failed permanently after ${attempts} attempts:`, error)
console.error(`Email ${emailId} failed permanently after ${attempts} attempts:`, error)
}
}
}
private async incrementAttempts(outboxId: string): Promise<number> {
private async incrementAttempts(emailId: string): Promise<number> {
const email = await this.payload.findByID({
collection: this.outboxCollection,
id: outboxId,
}) as OutboxEmail
collection: this.emailsCollection as any,
id: emailId,
}) as QueuedEmail
const newAttempts = (email.attempts || 0) + 1
await this.payload.update({
collection: this.outboxCollection,
id: outboxId,
collection: this.emailsCollection as any,
id: emailId,
data: {
attempts: newAttempts,
},
@@ -292,26 +320,49 @@ export class MailingService implements IMailingService {
return newAttempts
}
private async getTemplate(templateId: string): Promise<EmailTemplate | null> {
private async getTemplateBySlug(templateSlug: string): Promise<EmailTemplate | null> {
try {
const template = await this.payload.findByID({
collection: this.templatesCollection,
id: templateId,
const { docs } = await this.payload.find({
collection: this.templatesCollection as any,
where: {
slug: {
equals: templateSlug,
},
},
limit: 1,
})
return template as EmailTemplate
return docs.length > 0 ? docs[0] as EmailTemplate : null
} catch (error) {
console.error(`Template ${templateId} not found:`, error)
console.error(`Template with slug '${templateSlug}' not found:`, error)
return null
}
}
private renderTemplate(template: string, variables: Record<string, any>): string {
private renderHandlebarsTemplate(template: string, variables: Record<string, any>): string {
try {
const compiled = Handlebars.compile(template)
return compiled(variables)
} catch (error) {
console.error('Template rendering error:', error)
console.error('Handlebars template rendering error:', error)
return template
}
}
private async renderEmailTemplate(template: EmailTemplate, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
if (!template.content) {
return { html: '', text: '' }
}
// Serialize richtext to HTML and text
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)
return { html, text }
}
}

View File

@@ -1,16 +1,35 @@
import { Payload } from 'payload'
import type { CollectionConfig, RichTextField } from 'payload'
import { Transporter } from 'nodemailer'
export interface EmailObject {
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
from?: string
replyTo?: string
subject: string
html: string
text?: string
variables?: Record<string, any>
}
export type EmailWrapperHook = (email: EmailObject) => EmailObject | Promise<EmailObject>
export interface MailingPluginConfig {
collections?: {
templates?: string
outbox?: string
templates?: string | Partial<CollectionConfig>
emails?: string | Partial<CollectionConfig>
}
defaultFrom?: string
transport?: Transporter | MailingTransportConfig
queue?: string
retryAttempts?: number
retryDelay?: number
emailWrapper?: EmailWrapperHook
richTextEditor?: RichTextField['editor']
onReady?: (payload: any) => Promise<void>
initOrder?: 'before' | 'after'
}
export interface MailingTransportConfig {
@@ -26,27 +45,20 @@ export interface MailingTransportConfig {
export interface EmailTemplate {
id: string
name: string
slug: string
subject: string
htmlTemplate: string
textTemplate?: string
variables?: TemplateVariable[]
content: any // Lexical editor state
createdAt: string
updatedAt: string
}
export interface TemplateVariable {
name: string
type: 'text' | 'number' | 'boolean' | 'date'
required: boolean
description?: string
}
export interface OutboxEmail {
export interface QueuedEmail {
id: string
templateId?: string
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
template?: string
to: string[]
cc?: string[]
bcc?: string[]
from?: string
replyTo?: string
subject: string
@@ -65,7 +77,7 @@ export interface OutboxEmail {
}
export interface SendEmailOptions {
templateId?: string
templateSlug?: string
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
@@ -82,7 +94,7 @@ export interface SendEmailOptions {
export interface MailingService {
sendEmail(options: SendEmailOptions): Promise<string>
scheduleEmail(options: SendEmailOptions): Promise<string>
processOutbox(): Promise<void>
processEmails(): Promise<void>
retryFailedEmails(): Promise<void>
}
@@ -90,4 +102,4 @@ export interface MailingContext {
payload: Payload
config: MailingPluginConfig
service: MailingService
}
}

View File

@@ -1,5 +1,5 @@
import { Payload } from 'payload'
import { SendEmailOptions } from '../types'
import { SendEmailOptions } from '../types/index.js'
export const getMailing = (payload: Payload) => {
const mailing = (payload as any).mailing
@@ -19,9 +19,9 @@ export const scheduleEmail = async (payload: Payload, options: SendEmailOptions)
return mailing.service.scheduleEmail(options)
}
export const processOutbox = async (payload: Payload): Promise<void> => {
export const processEmails = async (payload: Payload): Promise<void> => {
const mailing = getMailing(payload)
return mailing.service.processOutbox()
return mailing.service.processEmails()
}
export const retryFailedEmails = async (payload: Payload): Promise<void> => {

View File

@@ -0,0 +1,150 @@
// Using any type for now since Lexical types have import issues
// import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
type SerializedEditorState = any
/**
* Converts Lexical richtext content to HTML
*/
export function serializeRichTextToHTML(richTextData: SerializedEditorState): string {
if (!richTextData?.root?.children) {
return ''
}
return serializeNodesToHTML(richTextData.root.children)
}
/**
* Converts Lexical richtext content to plain text
*/
export function serializeRichTextToText(richTextData: SerializedEditorState): string {
if (!richTextData?.root?.children) {
return ''
}
return serializeNodesToText(richTextData.root.children)
}
function serializeNodesToHTML(nodes: any[]): string {
return nodes.map(node => serializeNodeToHTML(node)).join('')
}
function serializeNodeToHTML(node: any): string {
if (!node) return ''
switch (node.type) {
case 'paragraph':
const children = node.children ? serializeNodesToHTML(node.children) : ''
return `<p>${children}</p>`
case 'heading':
const headingChildren = node.children ? serializeNodesToHTML(node.children) : ''
const tag = node.tag || 'h1'
return `<${tag}>${headingChildren}</${tag}>`
case 'text':
let text = node.text || ''
// Apply text formatting
if (node.format) {
if (node.format & 1) text = `<strong>${text}</strong>` // Bold
if (node.format & 2) text = `<em>${text}</em>` // Italic
if (node.format & 4) text = `<s>${text}</s>` // Strikethrough
if (node.format & 8) text = `<u>${text}</u>` // Underline
if (node.format & 16) text = `<code>${text}</code>` // Code
}
return text
case 'linebreak':
return '<br>'
case 'list':
const listChildren = node.children ? serializeNodesToHTML(node.children) : ''
const listTag = node.listType === 'number' ? 'ol' : 'ul'
return `<${listTag}>${listChildren}</${listTag}>`
case 'listitem':
const listItemChildren = node.children ? serializeNodesToHTML(node.children) : ''
return `<li>${listItemChildren}</li>`
case 'quote':
const quoteChildren = node.children ? serializeNodesToHTML(node.children) : ''
return `<blockquote>${quoteChildren}</blockquote>`
case 'link':
const linkChildren = node.children ? serializeNodesToHTML(node.children) : ''
const url = node.url || '#'
const target = node.newTab ? ' target="_blank" rel="noopener noreferrer"' : ''
return `<a href="${url}"${target}>${linkChildren}</a>`
case 'horizontalrule':
return '<hr>'
default:
// Handle unknown nodes by processing children
if (node.children) {
return serializeNodesToHTML(node.children)
}
return ''
}
}
function serializeNodesToText(nodes: any[]): string {
return nodes.map(node => serializeNodeToText(node)).join('')
}
function serializeNodeToText(node: any): string {
if (!node) return ''
switch (node.type) {
case 'paragraph':
const children = node.children ? serializeNodesToText(node.children) : ''
return `${children}\n\n`
case 'heading':
const headingChildren = node.children ? serializeNodesToText(node.children) : ''
return `${headingChildren}\n\n`
case 'text':
return node.text || ''
case 'linebreak':
return '\n'
case 'list':
const listChildren = node.children ? serializeNodesToText(node.children) : ''
return `${listChildren}\n`
case 'listitem':
const listItemChildren = node.children ? serializeNodesToText(node.children) : ''
return `${listItemChildren}\n`
case 'quote':
const quoteChildren = node.children ? serializeNodesToText(node.children) : ''
return `> ${quoteChildren}\n\n`
case 'link':
const linkChildren = node.children ? serializeNodesToText(node.children) : ''
const url = node.url || ''
return `${linkChildren} (${url})`
case 'horizontalrule':
return '---\n\n'
default:
// Handle unknown nodes by processing children
if (node.children) {
return serializeNodesToText(node.children)
}
return ''
}
}
/**
* Applies Handlebars variables to richtext-generated HTML/text
*/
export function applyVariablesToContent(content: string, variables: Record<string, any>): string {
// This function can be extended to handle more complex variable substitution
// For now, it works with the Handlebars rendering that happens later
return content
}