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:
@@ -10,6 +10,26 @@ const Emails: CollectionConfig = {
|
||||
group: 'Mailing',
|
||||
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: [
|
||||
{
|
||||
name: 'template',
|
||||
|
||||
@@ -48,7 +48,6 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
||||
|
||||
let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail>
|
||||
|
||||
// If using a template, render it first
|
||||
if (options.template) {
|
||||
const { html, text, subject } = await renderTemplate(
|
||||
payload,
|
||||
@@ -56,7 +55,6 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
||||
options.template.variables || {}
|
||||
)
|
||||
|
||||
// Template values take precedence over data values
|
||||
emailData = {
|
||||
...emailData,
|
||||
subject,
|
||||
@@ -70,20 +68,16 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
||||
throw new Error('Field "to" is required for sending emails')
|
||||
}
|
||||
|
||||
// Validate required fields based on whether template was used
|
||||
if (options.template) {
|
||||
// When using template, subject and html should have been set by renderTemplate
|
||||
if (!emailData.subject || !emailData.html) {
|
||||
throw new Error(`Template rendering failed: template "${options.template.slug}" did not provide required subject and html content`)
|
||||
}
|
||||
} else {
|
||||
// When not using template, user must provide subject and html directly
|
||||
if (!emailData.subject || !emailData.html) {
|
||||
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) {
|
||||
emailData.to = parseAndValidateEmails(emailData.to as string | string[])
|
||||
}
|
||||
@@ -95,19 +89,15 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
||||
}
|
||||
if (emailData.replyTo) {
|
||||
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
|
||||
}
|
||||
if (emailData.from) {
|
||||
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
|
||||
}
|
||||
|
||||
// Sanitize fromName to prevent header injection
|
||||
emailData.fromName = sanitizeFromName(emailData.fromName as string)
|
||||
|
||||
// Normalize Date objects to ISO strings for consistent database storage
|
||||
if (emailData.scheduledAt instanceof Date) {
|
||||
emailData.scheduledAt = emailData.scheduledAt.toISOString()
|
||||
}
|
||||
@@ -124,19 +114,15 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
||||
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({
|
||||
collection: collectionSlug,
|
||||
data: emailData
|
||||
})
|
||||
|
||||
// Validate that the created email has the expected structure
|
||||
if (!email || typeof email !== 'object' || !email.id) {
|
||||
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) {
|
||||
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')
|
||||
}
|
||||
|
||||
// Poll for the job with optimized backoff and timeout protection
|
||||
// This handles the async nature of hooks and ensures we wait for job creation
|
||||
const maxAttempts = 5 // Reduced from 10 to minimize delay
|
||||
const initialDelay = 25 // Reduced from 50ms for faster response
|
||||
const maxTotalTime = 3000 // 3 second total timeout
|
||||
const maxAttempts = 5
|
||||
const initialDelay = 25
|
||||
const maxTotalTime = 3000
|
||||
const startTime = Date.now()
|
||||
let jobId: string | undefined
|
||||
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
// Check total timeout before continuing
|
||||
if (Date.now() - startTime > maxTotalTime) {
|
||||
throw new Error(
|
||||
`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)
|
||||
|
||||
if (attempt > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
|
||||
// Refetch the email to check for jobs
|
||||
const emailWithJobs = await payload.findByID({
|
||||
collection: collectionSlug,
|
||||
id: email.id,
|
||||
})
|
||||
|
||||
|
||||
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
|
||||
jobId = typeof firstJob === 'string' ? firstJob : String(firstJob.id || firstJob)
|
||||
break
|
||||
}
|
||||
|
||||
// Log on later attempts to help with debugging (reduced threshold)
|
||||
if (attempt >= 1) {
|
||||
if (attempt >= 2) {
|
||||
logger.debug(`Waiting for job creation for email ${email.id}, attempt ${attempt + 1}/${maxAttempts}`)
|
||||
}
|
||||
if (attempt >= 2) {
|
||||
logger.debug(`Waiting for job creation for email ${email.id}, attempt ${attempt + 1}/${maxAttempts}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!jobId) {
|
||||
// Distinguish between different failure scenarios for better error handling
|
||||
const timeoutMsg = Date.now() - startTime >= maxTotalTime
|
||||
const errorType = timeoutMsg ? 'POLLING_TIMEOUT' : 'JOB_NOT_FOUND'
|
||||
|
||||
const baseMessage = timeoutMsg
|
||||
? `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)`
|
||||
|
||||
Reference in New Issue
Block a user