Files
payload-mailing/src/collections/Emails.ts
Bas van den Aakster 2f46dde532 Add configurable logger with PAYLOAD_MAILING_LOG_LEVEL support
- Created centralized logger utility using Payload's built-in logger system
- Added PAYLOAD_MAILING_LOG_LEVEL environment variable for log level configuration
- Replaced all console.log/error/warn calls with structured logger
- Added debug logging for immediate processing flow to help troubleshoot issues
- Improved logging context with specific prefixes (IMMEDIATE, PROCESSOR, JOB_SCHEDULER, etc.)
- Bumped version to 0.4.10

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 18:57:18 +02:00

242 lines
5.8 KiB
TypeScript

import type { CollectionConfig } from 'payload'
import { findExistingJobs, ensureEmailJob, updateEmailJobRelationship } from '../utils/jobScheduler.js'
import { createContextLogger } from '../utils/logger.js'
const Emails: CollectionConfig = {
slug: 'emails',
admin: {
useAsTitle: 'subject',
defaultColumns: ['subject', 'to', 'status', 'jobs', 'scheduledAt', 'sentAt'],
group: 'Mailing',
description: 'Email delivery and status tracking',
},
fields: [
{
name: 'template',
type: 'relationship',
relationTo: 'email-templates' as const,
admin: {
description: 'Email template used (optional if custom content provided)',
},
},
{
name: 'to',
type: 'text',
required: true,
hasMany: true,
admin: {
description: 'Recipient email addresses',
},
},
{
name: 'cc',
type: 'text',
hasMany: true,
admin: {
description: 'CC email addresses',
},
},
{
name: 'bcc',
type: 'text',
hasMany: true,
admin: {
description: 'BCC email addresses',
},
},
{
name: 'from',
type: 'text',
admin: {
description: 'Sender email address (optional, uses default if not provided)',
},
},
{
name: 'fromName',
type: 'text',
admin: {
description: 'Sender display name (optional, e.g., "John Doe" for "John Doe <john@example.com>")',
},
},
{
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)',
},
},
{
name: 'jobs',
type: 'relationship',
relationTo: 'payload-jobs',
hasMany: true,
admin: {
description: 'Processing jobs associated with this email',
allowCreate: false,
readOnly: true,
},
filterOptions: ({ id }) => {
return {
'input.emailId': {
equals: id,
},
}
},
},
],
hooks: {
// Simple approach: Only use afterChange hook for job management
// This avoids complex interaction between hooks and ensures document ID is always available
afterChange: [
async ({ doc, previousDoc, req, operation }) => {
// Skip if:
// 1. Email is not pending status
// 2. Jobs are not configured
// 3. Email already has jobs (unless status just changed to pending)
const shouldSkip =
doc.status !== 'pending' ||
!req.payload.jobs ||
(doc.jobs?.length > 0 && previousDoc?.status === 'pending')
if (shouldSkip) {
return
}
try {
// Ensure a job exists for this email
// This function handles:
// - Checking for existing jobs (duplicate prevention)
// - Creating new job if needed
// - Returning all job IDs
const result = await ensureEmailJob(req.payload, doc.id, {
scheduledAt: doc.scheduledAt,
})
// Update the email's job relationship if we have jobs
// This handles both new jobs and existing jobs that weren't in the relationship
if (result.jobIds.length > 0) {
await updateEmailJobRelationship(req.payload, doc.id, result.jobIds, 'emails')
}
} catch (error) {
// Log error but don't throw - we don't want to fail the email operation
const logger = createContextLogger(req.payload, 'EMAILS_HOOK')
logger.error(`Failed to ensure job for email ${doc.id}:`, error)
}
}
]
},
timestamps: true,
indexes: [
{
fields: ['status', 'scheduledAt'],
},
{
fields: ['priority', 'createdAt'],
},
],
}
export default Emails