UNPKG

@xtr-dev/payload-mailing

Version:

Template-based email system with scheduling and job processing for PayloadCMS

172 lines (171 loc) 8.04 kB
import { getMailing, renderTemplate, parseAndValidateEmails, sanitizeFromName } from './utils/helpers.js'; import { processJobById } from './utils/emailProcessor.js'; import { createContextLogger } from './utils/logger.js'; /** * Send an email with full type safety * * @example * ```typescript * // With your generated Email type * import { Email } from './payload-types' * * const email = await sendEmail<Email>(payload, { * template: { * slug: 'welcome', * variables: { name: 'John' } * }, * data: { * to: 'user@example.com', * customField: 'value' // Your custom fields are type-safe! * } * }) * ``` */ export const sendEmail = async (payload, options) => { const mailingConfig = getMailing(payload); const collectionSlug = options.collectionSlug || mailingConfig.collections.emails || 'emails'; let emailData = { ...options.data }; // If using a template, render it first if (options.template) { const { html, text, subject } = await renderTemplate(payload, options.template.slug, options.template.variables || {}); // Template values take precedence over data values emailData = { ...emailData, subject, html, text, }; } // Validate required fields if (!emailData.to) { 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); } if (emailData.cc) { emailData.cc = parseAndValidateEmails(emailData.cc); } if (emailData.bcc) { emailData.bcc = parseAndValidateEmails(emailData.bcc); } if (emailData.replyTo) { const validated = parseAndValidateEmails(emailData.replyTo); // 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); // 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); // Normalize Date objects to ISO strings for consistent database storage if (emailData.scheduledAt instanceof Date) { emailData.scheduledAt = emailData.scheduledAt.toISOString(); } if (emailData.sentAt instanceof Date) { emailData.sentAt = emailData.sentAt.toISOString(); } if (emailData.lastAttemptAt instanceof Date) { emailData.lastAttemptAt = emailData.lastAttemptAt.toISOString(); } if (emailData.createdAt instanceof Date) { emailData.createdAt = emailData.createdAt.toISOString(); } if (emailData.updatedAt instanceof Date) { 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'); if (!payload.jobs) { 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 startTime = Date.now(); let jobId; 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}. ` + `The auto-scheduling may have failed or is taking longer than expected.`); } // 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 (!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)`; throw new Error(`${errorType}: ${baseMessage}. ` + `This indicates the email was created but job auto-scheduling failed. ` + `The email exists in the database but immediate processing cannot proceed. ` + `You may need to: 1) Check job queue configuration, 2) Verify database hooks are working, ` + `3) Process the email later using processEmailById('${email.id}').`); } try { await processJobById(payload, jobId); logger.debug(`Successfully processed email ${email.id} immediately`); } catch (error) { logger.error(`Failed to process email ${email.id} immediately:`, error); throw new Error(`Failed to process email ${email.id} immediately: ${String(error)}`); } } return email; }; export default sendEmail;