mirror of
https://github.com/xtr-dev/payload-mailing.git
synced 2025-12-10 08:13:23 +00:00
Clean up sendEmail.ts and bump version to 0.4.14
- Remove duplicate nested if statement at line 188 - Remove redundant comments throughout the file - Simplify code structure for better readability - Bump patch version from 0.4.13 to 0.4.14 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -123,123 +123,6 @@ export default buildConfig({
|
|||||||
retryDelay: 60000, // 1 minute for dev
|
retryDelay: 60000, // 1 minute for dev
|
||||||
queue: 'default',
|
queue: 'default',
|
||||||
|
|
||||||
// Example: Collection overrides for customization
|
|
||||||
// Uncomment and modify as needed for your use case
|
|
||||||
/*
|
|
||||||
collections: {
|
|
||||||
templates: {
|
|
||||||
// Custom access controls - restrict who can manage templates
|
|
||||||
access: {
|
|
||||||
read: ({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
return user.role === 'admin' || user.permissions?.includes('mailing:read')
|
|
||||||
},
|
|
||||||
create: ({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
return user.role === 'admin' || user.permissions?.includes('mailing:create')
|
|
||||||
},
|
|
||||||
update: ({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
return user.role === 'admin' || user.permissions?.includes('mailing:update')
|
|
||||||
},
|
|
||||||
delete: ({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
return user.role === 'admin'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Custom admin UI settings
|
|
||||||
admin: {
|
|
||||||
group: 'Marketing',
|
|
||||||
description: 'Email templates with enhanced security and categorization'
|
|
||||||
},
|
|
||||||
// Add custom fields to templates
|
|
||||||
fields: [
|
|
||||||
// Default plugin fields are automatically included
|
|
||||||
{
|
|
||||||
name: 'category',
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{ label: 'Marketing', value: 'marketing' },
|
|
||||||
{ label: 'Transactional', value: 'transactional' },
|
|
||||||
{ label: 'System Notifications', value: 'system' }
|
|
||||||
],
|
|
||||||
defaultValue: 'transactional',
|
|
||||||
admin: {
|
|
||||||
position: 'sidebar',
|
|
||||||
description: 'Template category for organization'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'tags',
|
|
||||||
type: 'text',
|
|
||||||
hasMany: true,
|
|
||||||
admin: {
|
|
||||||
position: 'sidebar',
|
|
||||||
description: 'Tags for easy template filtering'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'isActive',
|
|
||||||
type: 'checkbox',
|
|
||||||
defaultValue: true,
|
|
||||||
admin: {
|
|
||||||
position: 'sidebar',
|
|
||||||
description: 'Only active templates can be used'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
// Custom validation hooks
|
|
||||||
hooks: {
|
|
||||||
beforeChange: [
|
|
||||||
({ data, req }) => {
|
|
||||||
// Example: Only admins can create system templates
|
|
||||||
if (data.category === 'system' && req.user?.role !== 'admin') {
|
|
||||||
throw new Error('Only administrators can create system notification templates')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example: Auto-generate slug if not provided
|
|
||||||
if (!data.slug && data.name) {
|
|
||||||
data.slug = data.name.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
emails: {
|
|
||||||
// Restrict access to emails collection
|
|
||||||
access: {
|
|
||||||
read: ({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
return user.role === 'admin' || user.permissions?.includes('mailing:read')
|
|
||||||
},
|
|
||||||
create: ({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
return user.role === 'admin' || user.permissions?.includes('mailing:create')
|
|
||||||
},
|
|
||||||
update: ({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
return user.role === 'admin' || user.permissions?.includes('mailing:update')
|
|
||||||
},
|
|
||||||
delete: ({ req: { user } }) => {
|
|
||||||
if (!user) return false
|
|
||||||
return user.role === 'admin'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Custom admin configuration for emails
|
|
||||||
admin: {
|
|
||||||
group: 'Marketing',
|
|
||||||
description: 'Email delivery tracking and management',
|
|
||||||
defaultColumns: ['subject', 'to', 'status', 'priority', 'scheduledAt'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Optional: Custom rich text editor configuration
|
// Optional: Custom rich text editor configuration
|
||||||
// Comment out to use default lexical editor
|
// Comment out to use default lexical editor
|
||||||
richTextEditor: lexicalEditor({
|
richTextEditor: lexicalEditor({
|
||||||
@@ -256,12 +139,6 @@ export default buildConfig({
|
|||||||
// etc.
|
// etc.
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
||||||
// Called after mailing plugin is fully initialized
|
|
||||||
onReady: async (payload) => {
|
|
||||||
await seedUser(payload)
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-mailing",
|
"name": "@xtr-dev/payload-mailing",
|
||||||
"version": "0.4.13",
|
"version": "0.4.14",
|
||||||
"description": "Template-based email system with scheduling and job processing for PayloadCMS",
|
"description": "Template-based email system with scheduling and job processing for PayloadCMS",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -10,6 +10,26 @@ const Emails: CollectionConfig = {
|
|||||||
group: 'Mailing',
|
group: 'Mailing',
|
||||||
description: 'Email delivery and status tracking',
|
description: 'Email delivery and status tracking',
|
||||||
},
|
},
|
||||||
|
defaultPopulate: {
|
||||||
|
template: true,
|
||||||
|
to: true,
|
||||||
|
cc: true,
|
||||||
|
bcc: true,
|
||||||
|
from: true,
|
||||||
|
replyTo: true,
|
||||||
|
jobs: true,
|
||||||
|
status: true,
|
||||||
|
attempts: true,
|
||||||
|
lastAttemptAt: true,
|
||||||
|
error: true,
|
||||||
|
priority: true,
|
||||||
|
scheduledAt: true,
|
||||||
|
sentAt: true,
|
||||||
|
variables: true,
|
||||||
|
html: true,
|
||||||
|
text: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'template',
|
name: 'template',
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
|||||||
|
|
||||||
let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail>
|
let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail>
|
||||||
|
|
||||||
// If using a template, render it first
|
|
||||||
if (options.template) {
|
if (options.template) {
|
||||||
const { html, text, subject } = await renderTemplate(
|
const { html, text, subject } = await renderTemplate(
|
||||||
payload,
|
payload,
|
||||||
@@ -56,7 +55,6 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
|||||||
options.template.variables || {}
|
options.template.variables || {}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Template values take precedence over data values
|
|
||||||
emailData = {
|
emailData = {
|
||||||
...emailData,
|
...emailData,
|
||||||
subject,
|
subject,
|
||||||
@@ -70,20 +68,16 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
|||||||
throw new Error('Field "to" is required for sending emails')
|
throw new Error('Field "to" is required for sending emails')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields based on whether template was used
|
|
||||||
if (options.template) {
|
if (options.template) {
|
||||||
// When using template, subject and html should have been set by renderTemplate
|
|
||||||
if (!emailData.subject || !emailData.html) {
|
if (!emailData.subject || !emailData.html) {
|
||||||
throw new Error(`Template rendering failed: template "${options.template.slug}" did not provide required subject and html content`)
|
throw new Error(`Template rendering failed: template "${options.template.slug}" did not provide required subject and html content`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// When not using template, user must provide subject and html directly
|
|
||||||
if (!emailData.subject || !emailData.html) {
|
if (!emailData.subject || !emailData.html) {
|
||||||
throw new Error('Fields "subject" and "html" are required when sending direct emails without a template')
|
throw new Error('Fields "subject" and "html" are required when sending direct emails without a template')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process email addresses using shared validation (handle null values)
|
|
||||||
if (emailData.to) {
|
if (emailData.to) {
|
||||||
emailData.to = parseAndValidateEmails(emailData.to as string | string[])
|
emailData.to = parseAndValidateEmails(emailData.to as string | string[])
|
||||||
}
|
}
|
||||||
@@ -95,19 +89,15 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
|||||||
}
|
}
|
||||||
if (emailData.replyTo) {
|
if (emailData.replyTo) {
|
||||||
const validated = parseAndValidateEmails(emailData.replyTo as string | string[])
|
const validated = parseAndValidateEmails(emailData.replyTo as string | string[])
|
||||||
// replyTo should be a single email, so take the first one if array
|
|
||||||
emailData.replyTo = validated && validated.length > 0 ? validated[0] : undefined
|
emailData.replyTo = validated && validated.length > 0 ? validated[0] : undefined
|
||||||
}
|
}
|
||||||
if (emailData.from) {
|
if (emailData.from) {
|
||||||
const validated = parseAndValidateEmails(emailData.from as string | string[])
|
const validated = parseAndValidateEmails(emailData.from as string | string[])
|
||||||
// from should be a single email, so take the first one if array
|
|
||||||
emailData.from = validated && validated.length > 0 ? validated[0] : undefined
|
emailData.from = validated && validated.length > 0 ? validated[0] : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize fromName to prevent header injection
|
|
||||||
emailData.fromName = sanitizeFromName(emailData.fromName as string)
|
emailData.fromName = sanitizeFromName(emailData.fromName as string)
|
||||||
|
|
||||||
// Normalize Date objects to ISO strings for consistent database storage
|
|
||||||
if (emailData.scheduledAt instanceof Date) {
|
if (emailData.scheduledAt instanceof Date) {
|
||||||
emailData.scheduledAt = emailData.scheduledAt.toISOString()
|
emailData.scheduledAt = emailData.scheduledAt.toISOString()
|
||||||
}
|
}
|
||||||
@@ -124,19 +114,15 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
|||||||
emailData.updatedAt = emailData.updatedAt.toISOString()
|
emailData.updatedAt = emailData.updatedAt.toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the email in the collection with proper typing
|
|
||||||
// The hooks will automatically create and populate the job relationship
|
|
||||||
const email = await payload.create({
|
const email = await payload.create({
|
||||||
collection: collectionSlug,
|
collection: collectionSlug,
|
||||||
data: emailData
|
data: emailData
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validate that the created email has the expected structure
|
|
||||||
if (!email || typeof email !== 'object' || !email.id) {
|
if (!email || typeof email !== 'object' || !email.id) {
|
||||||
throw new Error('Failed to create email: invalid response from database')
|
throw new Error('Failed to create email: invalid response from database')
|
||||||
}
|
}
|
||||||
|
|
||||||
// If processImmediately is true, get the job from the relationship and process it now
|
|
||||||
if (options.processImmediately) {
|
if (options.processImmediately) {
|
||||||
const logger = createContextLogger(payload, 'IMMEDIATE')
|
const logger = createContextLogger(payload, 'IMMEDIATE')
|
||||||
|
|
||||||
@@ -144,17 +130,13 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
|||||||
throw new Error('PayloadCMS jobs not configured - cannot process email immediately')
|
throw new Error('PayloadCMS jobs not configured - cannot process email immediately')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for the job with optimized backoff and timeout protection
|
const maxAttempts = 5
|
||||||
// This handles the async nature of hooks and ensures we wait for job creation
|
const initialDelay = 25
|
||||||
const maxAttempts = 5 // Reduced from 10 to minimize delay
|
const maxTotalTime = 3000
|
||||||
const initialDelay = 25 // Reduced from 50ms for faster response
|
|
||||||
const maxTotalTime = 3000 // 3 second total timeout
|
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
let jobId: string | undefined
|
let jobId: string | undefined
|
||||||
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
// Check total timeout before continuing
|
|
||||||
if (Date.now() - startTime > maxTotalTime) {
|
if (Date.now() - startTime > maxTotalTime) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Job polling timed out after ${maxTotalTime}ms for email ${email.id}. ` +
|
`Job polling timed out after ${maxTotalTime}ms for email ${email.id}. ` +
|
||||||
@@ -162,41 +144,31 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate delay with exponential backoff (25ms, 50ms, 100ms, 200ms, 400ms)
|
|
||||||
// Cap at 400ms per attempt for better responsiveness
|
|
||||||
const delay = Math.min(initialDelay * Math.pow(2, attempt), 400)
|
const delay = Math.min(initialDelay * Math.pow(2, attempt), 400)
|
||||||
|
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
await new Promise(resolve => setTimeout(resolve, delay))
|
await new Promise(resolve => setTimeout(resolve, delay))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refetch the email to check for jobs
|
|
||||||
const emailWithJobs = await payload.findByID({
|
const emailWithJobs = await payload.findByID({
|
||||||
collection: collectionSlug,
|
collection: collectionSlug,
|
||||||
id: email.id,
|
id: email.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
if (emailWithJobs.jobs && emailWithJobs.jobs.length > 0) {
|
if (emailWithJobs.jobs && emailWithJobs.jobs.length > 0) {
|
||||||
// Job found! Get the first job ID (should only be one for a new email)
|
|
||||||
const firstJob = Array.isArray(emailWithJobs.jobs) ? emailWithJobs.jobs[0] : emailWithJobs.jobs
|
const firstJob = Array.isArray(emailWithJobs.jobs) ? emailWithJobs.jobs[0] : emailWithJobs.jobs
|
||||||
jobId = typeof firstJob === 'string' ? firstJob : String(firstJob.id || firstJob)
|
jobId = typeof firstJob === 'string' ? firstJob : String(firstJob.id || firstJob)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log on later attempts to help with debugging (reduced threshold)
|
|
||||||
if (attempt >= 1) {
|
|
||||||
if (attempt >= 2) {
|
if (attempt >= 2) {
|
||||||
logger.debug(`Waiting for job creation for email ${email.id}, attempt ${attempt + 1}/${maxAttempts}`)
|
logger.debug(`Waiting for job creation for email ${email.id}, attempt ${attempt + 1}/${maxAttempts}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
// Distinguish between different failure scenarios for better error handling
|
|
||||||
const timeoutMsg = Date.now() - startTime >= maxTotalTime
|
const timeoutMsg = Date.now() - startTime >= maxTotalTime
|
||||||
const errorType = timeoutMsg ? 'POLLING_TIMEOUT' : 'JOB_NOT_FOUND'
|
const errorType = timeoutMsg ? 'POLLING_TIMEOUT' : 'JOB_NOT_FOUND'
|
||||||
|
|
||||||
const baseMessage = timeoutMsg
|
const baseMessage = timeoutMsg
|
||||||
? `Job polling timed out after ${maxTotalTime}ms for email ${email.id}`
|
? `Job polling timed out after ${maxTotalTime}ms for email ${email.id}`
|
||||||
: `No processing job found for email ${email.id} after ${maxAttempts} attempts (${Date.now() - startTime}ms)`
|
: `No processing job found for email ${email.id} after ${maxAttempts} attempts (${Date.now() - startTime}ms)`
|
||||||
|
|||||||
Reference in New Issue
Block a user