mirror of
https://github.com/xtr-dev/payload-mailing.git
synced 2025-12-10 00:03:23 +00:00
Add mailing plugin with templates, outbox, and job processing
This commit is contained in:
180
src/collections/EmailOutbox.ts
Normal file
180
src/collections/EmailOutbox.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { CollectionConfig } from 'payload/types'
|
||||
|
||||
const EmailOutbox: CollectionConfig = {
|
||||
slug: 'email-outbox',
|
||||
admin: {
|
||||
useAsTitle: 'subject',
|
||||
defaultColumns: ['subject', 'to', 'status', 'scheduledAt', 'sentAt'],
|
||||
group: 'Mailing',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: () => true,
|
||||
update: () => true,
|
||||
delete: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'template',
|
||||
type: 'relationship',
|
||||
relationTo: 'email-templates',
|
||||
admin: {
|
||||
description: 'Email template used (optional if custom content provided)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'to',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Recipient email address(es), comma-separated',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cc',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'CC email address(es), comma-separated',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'bcc',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'BCC email address(es), comma-separated',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'from',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Sender email address (optional, uses default if not provided)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'replyTo',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Reply-to email address',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'subject',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Email subject line',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'html',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Rendered HTML content of the email',
|
||||
rows: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Plain text version of the email',
|
||||
rows: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'variables',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Template variables used to render this email',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'scheduledAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
description: 'When this email should be sent (leave empty for immediate)',
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sentAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
description: 'When this email was actually sent',
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Processing', value: 'processing' },
|
||||
{ label: 'Sent', value: 'sent' },
|
||||
{ label: 'Failed', value: 'failed' },
|
||||
],
|
||||
defaultValue: 'pending',
|
||||
admin: {
|
||||
description: 'Current status of this email',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'attempts',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
admin: {
|
||||
description: 'Number of send attempts made',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lastAttemptAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
description: 'When the last send attempt was made',
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'error',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Last error message if send failed',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'priority',
|
||||
type: 'number',
|
||||
defaultValue: 5,
|
||||
admin: {
|
||||
description: 'Email priority (1=highest, 10=lowest)',
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
fields: {
|
||||
status: 1,
|
||||
scheduledAt: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
fields: {
|
||||
priority: -1,
|
||||
createdAt: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default EmailOutbox
|
||||
105
src/collections/EmailTemplates.ts
Normal file
105
src/collections/EmailTemplates.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { CollectionConfig } from 'payload/types'
|
||||
|
||||
const EmailTemplates: CollectionConfig = {
|
||||
slug: 'email-templates',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'subject', 'updatedAt'],
|
||||
group: 'Mailing',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: () => true,
|
||||
update: () => true,
|
||||
delete: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'A descriptive name for this email template',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'subject',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Email subject line (supports Handlebars variables)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'htmlTemplate',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
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)',
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
}
|
||||
|
||||
export default EmailTemplates
|
||||
25
src/index.ts
Normal file
25
src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Main plugin export
|
||||
export { default as mailingPlugin } from './plugin'
|
||||
export { mailingPlugin } from './plugin'
|
||||
|
||||
// Types
|
||||
export * from './types'
|
||||
|
||||
// Services
|
||||
export { MailingService } from './services/MailingService'
|
||||
|
||||
// Collections
|
||||
export { default as EmailTemplates } from './collections/EmailTemplates'
|
||||
export { default as EmailOutbox } from './collections/EmailOutbox'
|
||||
|
||||
// Jobs
|
||||
export * from './jobs'
|
||||
|
||||
// Utility functions for developers
|
||||
export {
|
||||
getMailing,
|
||||
sendEmail,
|
||||
scheduleEmail,
|
||||
processOutbox,
|
||||
retryFailedEmails,
|
||||
} from './utils/helpers'
|
||||
20
src/jobs/index.ts
Normal file
20
src/jobs/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Job } from 'payload/jobs'
|
||||
import { processOutboxJob, ProcessOutboxJobData } from './processOutboxJob'
|
||||
import { MailingService } from '../services/MailingService'
|
||||
|
||||
export const createMailingJobs = (mailingService: MailingService): Job[] => {
|
||||
return [
|
||||
{
|
||||
slug: 'processOutbox',
|
||||
handler: async ({ job, req }) => {
|
||||
return processOutboxJob(
|
||||
job as { data: ProcessOutboxJobData },
|
||||
{ req, mailingService }
|
||||
)
|
||||
},
|
||||
interfaceName: 'ProcessOutboxJob',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export * from './processOutboxJob'
|
||||
50
src/jobs/processOutboxJob.ts
Normal file
50
src/jobs/processOutboxJob.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { PayloadRequest } from 'payload/types'
|
||||
import { MailingService } from '../services/MailingService'
|
||||
|
||||
export interface ProcessOutboxJobData {
|
||||
type: 'process-outbox' | 'retry-failed'
|
||||
}
|
||||
|
||||
export const processOutboxJob = async (
|
||||
job: { data: ProcessOutboxJobData },
|
||||
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')
|
||||
} else if (type === 'retry-failed') {
|
||||
await mailingService.retryFailedEmails()
|
||||
console.log('Failed email retry completed successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${type} job failed:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const scheduleOutboxJob = async (
|
||||
payload: any,
|
||||
queueName: string,
|
||||
jobType: 'process-outbox' | 'retry-failed',
|
||||
delay?: number
|
||||
) => {
|
||||
if (!payload.jobs) {
|
||||
console.warn('PayloadCMS jobs not configured - emails will not be processed automatically')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await payload.jobs.queue({
|
||||
queue: queueName,
|
||||
task: 'processOutbox',
|
||||
input: { type: jobType },
|
||||
waitUntil: delay ? new Date(Date.now() + delay) : undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to schedule ${jobType} job:`, error)
|
||||
}
|
||||
}
|
||||
98
src/plugin.ts
Normal file
98
src/plugin.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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'
|
||||
|
||||
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
|
||||
const templatesCollection = {
|
||||
...EmailTemplates,
|
||||
slug: templatesSlug,
|
||||
}
|
||||
|
||||
const outboxCollection = {
|
||||
...EmailOutbox,
|
||||
slug: outboxSlug,
|
||||
fields: EmailOutbox.fields.map(field => {
|
||||
if (field.name === 'template' && field.type === 'relationship') {
|
||||
return {
|
||||
...field,
|
||||
relationTo: templatesSlug,
|
||||
}
|
||||
}
|
||||
return field
|
||||
}),
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
collections: [
|
||||
...(config.collections || []),
|
||||
templatesCollection,
|
||||
outboxCollection,
|
||||
],
|
||||
jobs: {
|
||||
...(config.jobs || {}),
|
||||
tasks: [
|
||||
...(config.jobs?.tasks || []),
|
||||
// Jobs will be added via onInit hook
|
||||
],
|
||||
},
|
||||
onInit: async (payload) => {
|
||||
// Call original onInit if it exists
|
||||
if (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 = {
|
||||
service: mailingService,
|
||||
config: pluginConfig,
|
||||
collections: {
|
||||
templates: templatesSlug,
|
||||
outbox: outboxSlug,
|
||||
},
|
||||
} as MailingContext
|
||||
|
||||
console.log('PayloadCMS Mailing Plugin initialized successfully')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default mailingPlugin
|
||||
317
src/services/MailingService.ts
Normal file
317
src/services/MailingService.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Payload } from 'payload'
|
||||
import Handlebars from 'handlebars'
|
||||
import nodemailer, { Transporter } from 'nodemailer'
|
||||
import {
|
||||
MailingPluginConfig,
|
||||
SendEmailOptions,
|
||||
MailingService as IMailingService,
|
||||
EmailTemplate,
|
||||
OutboxEmail,
|
||||
MailingTransportConfig
|
||||
} from '../types'
|
||||
|
||||
export class MailingService implements IMailingService {
|
||||
private payload: Payload
|
||||
private config: MailingPluginConfig
|
||||
private transporter: Transporter
|
||||
private templatesCollection: string
|
||||
private outboxCollection: 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'
|
||||
|
||||
this.initializeTransporter()
|
||||
this.registerHandlebarsHelpers()
|
||||
}
|
||||
|
||||
private initializeTransporter(): void {
|
||||
if (this.config.transport) {
|
||||
if ('sendMail' in this.config.transport) {
|
||||
this.transporter = this.config.transport
|
||||
} else {
|
||||
this.transporter = nodemailer.createTransporter(this.config.transport as MailingTransportConfig)
|
||||
}
|
||||
} else {
|
||||
throw new Error('Email transport configuration is required')
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
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(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)
|
||||
})
|
||||
}
|
||||
|
||||
async sendEmail(options: SendEmailOptions): Promise<string> {
|
||||
const outboxId = await this.scheduleEmail({
|
||||
...options,
|
||||
scheduledAt: new Date()
|
||||
})
|
||||
|
||||
await this.processOutboxItem(outboxId)
|
||||
|
||||
return outboxId
|
||||
}
|
||||
|
||||
async scheduleEmail(options: SendEmailOptions): Promise<string> {
|
||||
let html = options.html || ''
|
||||
let text = options.text || ''
|
||||
let subject = options.subject || ''
|
||||
|
||||
if (options.templateId) {
|
||||
const template = await this.getTemplate(options.templateId)
|
||||
if (template) {
|
||||
const variables = options.variables || {}
|
||||
|
||||
html = this.renderTemplate(template.htmlTemplate, variables)
|
||||
text = template.textTemplate ? this.renderTemplate(template.textTemplate, variables) : ''
|
||||
subject = this.renderTemplate(template.subject, variables)
|
||||
}
|
||||
}
|
||||
|
||||
if (!subject && !options.subject) {
|
||||
throw new Error('Email subject is required')
|
||||
}
|
||||
|
||||
if (!html && !options.html) {
|
||||
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,
|
||||
from: options.from || this.config.defaultFrom,
|
||||
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.outboxCollection,
|
||||
data: outboxData,
|
||||
})
|
||||
|
||||
return result.id as string
|
||||
}
|
||||
|
||||
async processOutbox(): Promise<void> {
|
||||
const currentTime = new Date().toISOString()
|
||||
|
||||
const { docs: pendingEmails } = await this.payload.find({
|
||||
collection: this.outboxCollection,
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
status: {
|
||||
equals: 'pending',
|
||||
},
|
||||
},
|
||||
{
|
||||
or: [
|
||||
{
|
||||
scheduledAt: {
|
||||
exists: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
scheduledAt: {
|
||||
less_than_equal: currentTime,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
sort: 'priority,-createdAt',
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
for (const email of pendingEmails) {
|
||||
await this.processOutboxItem(email.id)
|
||||
}
|
||||
}
|
||||
|
||||
async retryFailedEmails(): Promise<void> {
|
||||
const maxAttempts = this.config.retryAttempts || 3
|
||||
const retryDelay = this.config.retryDelay || 300000 // 5 minutes
|
||||
const retryTime = new Date(Date.now() - retryDelay).toISOString()
|
||||
|
||||
const { docs: failedEmails } = await this.payload.find({
|
||||
collection: this.outboxCollection,
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
status: {
|
||||
equals: 'failed',
|
||||
},
|
||||
},
|
||||
{
|
||||
attempts: {
|
||||
less_than: maxAttempts,
|
||||
},
|
||||
},
|
||||
{
|
||||
or: [
|
||||
{
|
||||
lastAttemptAt: {
|
||||
exists: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
lastAttemptAt: {
|
||||
less_than: retryTime,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
for (const email of failedEmails) {
|
||||
await this.processOutboxItem(email.id)
|
||||
}
|
||||
}
|
||||
|
||||
private async processOutboxItem(outboxId: string): Promise<void> {
|
||||
try {
|
||||
await this.payload.update({
|
||||
collection: this.outboxCollection,
|
||||
id: outboxId,
|
||||
data: {
|
||||
status: 'processing',
|
||||
lastAttemptAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
const email = await this.payload.findByID({
|
||||
collection: this.outboxCollection,
|
||||
id: outboxId,
|
||||
}) as OutboxEmail
|
||||
|
||||
const mailOptions = {
|
||||
from: email.from || this.config.defaultFrom,
|
||||
to: email.to,
|
||||
cc: email.cc || undefined,
|
||||
bcc: email.bcc || undefined,
|
||||
replyTo: email.replyTo || undefined,
|
||||
subject: email.subject,
|
||||
html: email.html,
|
||||
text: email.text || undefined,
|
||||
}
|
||||
|
||||
await this.transporter.sendMail(mailOptions)
|
||||
|
||||
await this.payload.update({
|
||||
collection: this.outboxCollection,
|
||||
id: outboxId,
|
||||
data: {
|
||||
status: 'sent',
|
||||
sentAt: new Date().toISOString(),
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
const attempts = await this.incrementAttempts(outboxId)
|
||||
const maxAttempts = this.config.retryAttempts || 3
|
||||
|
||||
await this.payload.update({
|
||||
collection: this.outboxCollection,
|
||||
id: outboxId,
|
||||
data: {
|
||||
status: attempts >= maxAttempts ? 'failed' : 'pending',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
lastAttemptAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
console.error(`Email ${outboxId} failed permanently after ${attempts} attempts:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async incrementAttempts(outboxId: string): Promise<number> {
|
||||
const email = await this.payload.findByID({
|
||||
collection: this.outboxCollection,
|
||||
id: outboxId,
|
||||
}) as OutboxEmail
|
||||
|
||||
const newAttempts = (email.attempts || 0) + 1
|
||||
|
||||
await this.payload.update({
|
||||
collection: this.outboxCollection,
|
||||
id: outboxId,
|
||||
data: {
|
||||
attempts: newAttempts,
|
||||
},
|
||||
})
|
||||
|
||||
return newAttempts
|
||||
}
|
||||
|
||||
private async getTemplate(templateId: string): Promise<EmailTemplate | null> {
|
||||
try {
|
||||
const template = await this.payload.findByID({
|
||||
collection: this.templatesCollection,
|
||||
id: templateId,
|
||||
})
|
||||
return template as EmailTemplate
|
||||
} catch (error) {
|
||||
console.error(`Template ${templateId} not found:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private renderTemplate(template: string, variables: Record<string, any>): string {
|
||||
try {
|
||||
const compiled = Handlebars.compile(template)
|
||||
return compiled(variables)
|
||||
} catch (error) {
|
||||
console.error('Template rendering error:', error)
|
||||
return template
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/types/index.ts
Normal file
93
src/types/index.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Payload } from 'payload'
|
||||
import { Transporter } from 'nodemailer'
|
||||
|
||||
export interface MailingPluginConfig {
|
||||
collections?: {
|
||||
templates?: string
|
||||
outbox?: string
|
||||
}
|
||||
defaultFrom?: string
|
||||
transport?: Transporter | MailingTransportConfig
|
||||
queue?: string
|
||||
retryAttempts?: number
|
||||
retryDelay?: number
|
||||
}
|
||||
|
||||
export interface MailingTransportConfig {
|
||||
host: string
|
||||
port: number
|
||||
secure?: boolean
|
||||
auth?: {
|
||||
user: string
|
||||
pass: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface EmailTemplate {
|
||||
id: string
|
||||
name: string
|
||||
subject: string
|
||||
htmlTemplate: string
|
||||
textTemplate?: string
|
||||
variables?: TemplateVariable[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface TemplateVariable {
|
||||
name: string
|
||||
type: 'text' | 'number' | 'boolean' | 'date'
|
||||
required: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface OutboxEmail {
|
||||
id: string
|
||||
templateId?: string
|
||||
to: string | string[]
|
||||
cc?: string | string[]
|
||||
bcc?: string | string[]
|
||||
from?: string
|
||||
replyTo?: string
|
||||
subject: string
|
||||
html: string
|
||||
text?: string
|
||||
variables?: Record<string, any>
|
||||
scheduledAt?: string
|
||||
sentAt?: string
|
||||
status: 'pending' | 'processing' | 'sent' | 'failed'
|
||||
attempts: number
|
||||
lastAttemptAt?: string
|
||||
error?: string
|
||||
priority?: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SendEmailOptions {
|
||||
templateId?: string
|
||||
to: string | string[]
|
||||
cc?: string | string[]
|
||||
bcc?: string | string[]
|
||||
from?: string
|
||||
replyTo?: string
|
||||
subject?: string
|
||||
html?: string
|
||||
text?: string
|
||||
variables?: Record<string, any>
|
||||
scheduledAt?: Date
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export interface MailingService {
|
||||
sendEmail(options: SendEmailOptions): Promise<string>
|
||||
scheduleEmail(options: SendEmailOptions): Promise<string>
|
||||
processOutbox(): Promise<void>
|
||||
retryFailedEmails(): Promise<void>
|
||||
}
|
||||
|
||||
export interface MailingContext {
|
||||
payload: Payload
|
||||
config: MailingPluginConfig
|
||||
service: MailingService
|
||||
}
|
||||
30
src/utils/helpers.ts
Normal file
30
src/utils/helpers.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Payload } from 'payload'
|
||||
import { SendEmailOptions } from '../types'
|
||||
|
||||
export const getMailing = (payload: Payload) => {
|
||||
const mailing = (payload as any).mailing
|
||||
if (!mailing) {
|
||||
throw new Error('Mailing plugin not initialized. Make sure you have added the mailingPlugin to your Payload config.')
|
||||
}
|
||||
return mailing
|
||||
}
|
||||
|
||||
export const sendEmail = async (payload: Payload, options: SendEmailOptions): Promise<string> => {
|
||||
const mailing = getMailing(payload)
|
||||
return mailing.service.sendEmail(options)
|
||||
}
|
||||
|
||||
export const scheduleEmail = async (payload: Payload, options: SendEmailOptions): Promise<string> => {
|
||||
const mailing = getMailing(payload)
|
||||
return mailing.service.scheduleEmail(options)
|
||||
}
|
||||
|
||||
export const processOutbox = async (payload: Payload): Promise<void> => {
|
||||
const mailing = getMailing(payload)
|
||||
return mailing.service.processOutbox()
|
||||
}
|
||||
|
||||
export const retryFailedEmails = async (payload: Payload): Promise<void> => {
|
||||
const mailing = getMailing(payload)
|
||||
return mailing.service.retryFailedEmails()
|
||||
}
|
||||
Reference in New Issue
Block a user