Compare commits

..

5 Commits
dev ... v0.0.7

Author SHA1 Message Date
Bas
c7af628beb Merge pull request #14 from xtr-dev/dev
Add defaultFromName config option and bump to v0.0.7
2025-09-13 16:29:26 +02:00
Bas
048fa33747 Merge pull request #13 from xtr-dev/dev
Fix TypeScript build error with payload email adapter
2025-09-13 16:18:41 +02:00
Bas
cb62874500 Merge pull request #12 from xtr-dev/dev
Make mailer transport config optional, use Payload config fallback
2025-09-13 16:04:26 +02:00
Bas
9efea193b1 Merge pull request #11 from xtr-dev/dev
### 🔧 Improvements
2025-09-13 15:07:08 +02:00
Bas
c09d7d4fc5 Merge pull request #10 from xtr-dev/dev
### 📚 Documentation
2025-09-13 15:00:31 +02:00
35 changed files with 1416 additions and 2656 deletions

2
.gitignore vendored
View File

@@ -4,6 +4,4 @@ node_modules/
payload-docs
dist/
/dev/payload.db
/dev/dev.db
dev.db
tsconfig.tsbuildinfo

View File

@@ -126,10 +126,9 @@ When you start the dev server, look for these messages:
🎯 Test interface will be available at: /mailing-test
✅ Example email templates created successfully
PayloadCMS Mailing Plugin initialized successfully
```
**Note**: The plugin initializes silently on success (no "initialized successfully" message). If you see no errors, the plugin loaded correctly.
## Troubleshooting
### Server won't start

1220
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
USE_MEMORY_DB=true
PAYLOAD_SECRET=YOUR_SECRET_HERE

View File

@@ -184,43 +184,25 @@ The plugin automatically processes the outbox every 5 minutes and retries failed
## Plugin API Usage
```javascript
import { sendEmail } from '@xtr-dev/payload-mailing'
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
// Send immediate email with template
const email = await sendEmail(payload, {
template: {
slug: 'welcome-email',
variables: {
firstName: 'John',
siteName: 'My App'
}
},
data: {
to: 'user@example.com',
// Send immediate email
const emailId = await sendEmail(payload, {
templateId: 'welcome-template-id',
to: 'user@example.com',
variables: {
firstName: 'John',
siteName: 'My App'
}
})
// Schedule email for later
const scheduledEmail = await sendEmail(payload, {
template: {
slug: 'reminder',
variables: {
eventName: 'Product Launch'
}
},
data: {
to: 'user@example.com',
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
}
})
// Send direct HTML email (no template)
const directEmail = await sendEmail(payload, {
data: {
to: 'user@example.com',
subject: 'Direct Email',
html: '<h1>Hello World</h1>',
text: 'Hello World'
// Schedule email
const scheduledId = await scheduleEmail(payload, {
templateId: 'reminder-template-id',
to: 'user@example.com',
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
variables: {
eventName: 'Product Launch'
}
})
```

View File

@@ -1,310 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
interface EmailStats {
total: number
sent: number
pending: number
failed: number
processing: number
}
export default function HomePage() {
const [emailStats, setEmailStats] = useState<EmailStats>({
total: 0,
sent: 0,
pending: 0,
failed: 0,
processing: 0
})
const [loading, setLoading] = useState<boolean>(true)
useEffect(() => {
fetchEmailStats()
}, [])
const fetchEmailStats = async () => {
try {
const response = await fetch('/api/test-email')
const data = await response.json()
if (data.outbox?.emails) {
const emails = data.outbox.emails
const stats: EmailStats = {
total: emails.length,
sent: emails.filter((email: any) => email.status === 'sent').length,
pending: emails.filter((email: any) => email.status === 'pending').length,
failed: emails.filter((email: any) => email.status === 'failed').length,
processing: emails.filter((email: any) => email.status === 'processing').length
}
setEmailStats(stats)
}
} catch (error) {
console.error('Error fetching email statistics:', error)
} finally {
setLoading(false)
}
}
const StatCard = ({ label, value, color, description }: { label: string; value: number; color: string; description: string }) => (
<div style={{
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '12px',
padding: '24px',
textAlign: 'center',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
}}>
<div style={{
fontSize: '3rem',
fontWeight: 'bold',
color: color,
marginBottom: '8px'
}}>
{value}
</div>
<div style={{
fontSize: '1.1rem',
fontWeight: '600',
color: '#374151',
marginBottom: '4px'
}}>
{label}
</div>
<div style={{
fontSize: '0.875rem',
color: '#6b7280'
}}>
{description}
</div>
</div>
)
return (
<div style={{
backgroundColor: '#f9fafb',
padding: '40px 20px',
minHeight: 'calc(100vh - 80px)'
}}>
<div style={{
maxWidth: '1200px',
margin: '0 auto'
}}>
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: '48px' }}>
<h1 style={{
fontSize: '3rem',
fontWeight: 'bold',
color: '#1f2937',
marginBottom: '16px'
}}>
📧 PayloadCMS Mailing Plugin
</h1>
<p style={{
fontSize: '1.25rem',
color: '#6b7280',
marginBottom: '24px'
}}>
Development Dashboard
</p>
<div style={{ display: 'flex', gap: '16px', justifyContent: 'center', flexWrap: 'wrap' }}>
<Link
href="/admin"
style={{
backgroundColor: '#3b82f6',
color: 'white',
padding: '12px 24px',
borderRadius: '8px',
textDecoration: 'none',
fontWeight: '500',
transition: 'background-color 0.2s'
}}
>
📊 Admin Panel
</Link>
<Link
href="/mailing-test"
style={{
backgroundColor: '#10b981',
color: 'white',
padding: '12px 24px',
borderRadius: '8px',
textDecoration: 'none',
fontWeight: '500',
transition: 'background-color 0.2s'
}}
>
🧪 Test Interface
</Link>
</div>
</div>
{/* Email Statistics */}
<div style={{ marginBottom: '48px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px'
}}>
<h2 style={{
fontSize: '2rem',
fontWeight: 'bold',
color: '#1f2937'
}}>
Email Statistics
</h2>
<button
onClick={fetchEmailStats}
disabled={loading}
style={{
backgroundColor: loading ? '#9ca3af' : '#6b7280',
color: 'white',
padding: '8px 16px',
borderRadius: '6px',
border: 'none',
cursor: loading ? 'not-allowed' : 'pointer',
fontWeight: '500'
}}
>
{loading ? 'Loading...' : 'Refresh'}
</button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '48px' }}>
<div style={{ color: '#6b7280', fontSize: '1.1rem' }}>Loading email statistics...</div>
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '24px'
}}>
<StatCard
label="Total Emails"
value={emailStats.total}
color="#1f2937"
description="All emails in the system"
/>
<StatCard
label="Successfully Sent"
value={emailStats.sent}
color="#10b981"
description="Delivered successfully"
/>
<StatCard
label="Pending"
value={emailStats.pending}
color="#f59e0b"
description="Waiting to be sent"
/>
<StatCard
label="Failed"
value={emailStats.failed}
color="#ef4444"
description="Failed to send"
/>
{emailStats.processing > 0 && (
<StatCard
label="Processing"
value={emailStats.processing}
color="#3b82f6"
description="Currently being sent"
/>
)}
</div>
)}
</div>
{/* Quick Actions */}
<div style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '32px',
border: '1px solid #e5e7eb',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
<h3 style={{
fontSize: '1.5rem',
fontWeight: 'bold',
color: '#1f2937',
marginBottom: '16px'
}}>
Quick Actions
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '16px'
}}>
<div style={{ padding: '16px', backgroundColor: '#f9fafb', borderRadius: '8px' }}>
<h4 style={{ marginBottom: '8px', color: '#1f2937' }}>🎯 Test Email Sending</h4>
<p style={{ color: '#6b7280', marginBottom: '12px', fontSize: '0.9rem' }}>
Send test emails using templates with the interactive testing interface.
</p>
<Link
href="/mailing-test"
style={{
color: '#3b82f6',
textDecoration: 'none',
fontWeight: '500'
}}
>
Open Test Interface
</Link>
</div>
<div style={{ padding: '16px', backgroundColor: '#f9fafb', borderRadius: '8px' }}>
<h4 style={{ marginBottom: '8px', color: '#1f2937' }}>📝 Manage Templates</h4>
<p style={{ color: '#6b7280', marginBottom: '12px', fontSize: '0.9rem' }}>
Create and edit email templates in the Payload admin interface.
</p>
<Link
href="/admin/collections/email-templates"
style={{
color: '#3b82f6',
textDecoration: 'none',
fontWeight: '500'
}}
>
Manage Templates
</Link>
</div>
<div style={{ padding: '16px', backgroundColor: '#f9fafb', borderRadius: '8px' }}>
<h4 style={{ marginBottom: '8px', color: '#1f2937' }}>📬 Email Queue</h4>
<p style={{ color: '#6b7280', marginBottom: '12px', fontSize: '0.9rem' }}>
View and manage the email outbox and delivery status.
</p>
<Link
href="/admin/collections/emails"
style={{
color: '#3b82f6',
textDecoration: 'none',
fontWeight: '500'
}}
>
View Email Queue
</Link>
</div>
</div>
</div>
{/* Footer */}
<div style={{
textAlign: 'center',
marginTop: '48px',
padding: '24px',
color: '#6b7280',
fontSize: '0.875rem'
}}>
PayloadCMS Mailing Plugin Development Environment
</div>
</div>
</div>
)
}

View File

@@ -1,24 +0,0 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'PayloadCMS Mailing Plugin - Development',
description: 'Development environment for PayloadCMS Mailing Plugin',
}
export default function FrontendLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body style={{
margin: 0,
padding: 0,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
}}>
{children}
</body>
</html>
)
}

View File

@@ -4,25 +4,24 @@ import config from '@payload-config'
export async function POST(request: Request) {
try {
const payload = await getPayload({ config })
// Run jobs in the default queue (the plugin already schedules email processing on init)
const results = await payload.jobs.run({
queue: 'default',
// Queue the combined email queue processing job
const job = await payload.jobs.queue({
task: 'process-email-queue',
input: {},
})
const processedCount = Array.isArray(results) ? results.length : (results ? 1 : 0)
return Response.json({
success: true,
message: `Email queue processing completed. Processed ${processedCount} jobs.`,
processedJobs: processedCount,
message: 'Email queue processing job queued successfully (will process both pending and failed emails)',
jobId: job.id,
})
} catch (error) {
console.error('Process emails error:', error)
return Response.json(
{
{
error: 'Failed to process emails',
details: error instanceof Error ? error.message : 'Unknown error'
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
)

View File

@@ -1,75 +1,37 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { sendEmail } from '@xtr-dev/payload-mailing'
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
export async function POST(request: Request) {
try {
const payload = await getPayload({ config })
const body = await request.json()
const { type = 'send', templateSlug, to, variables, scheduledAt, subject, html, text } = body
const { type = 'send', templateSlug, to, variables, scheduledAt } = body
// Validate required fields
if (!to) {
return Response.json(
{ error: 'Recipient email address (to) is required' },
{ status: 400 }
)
}
// Validate email has either template or direct content
if (!templateSlug && (!subject || !html)) {
return Response.json(
{ error: 'Either templateSlug or both subject and html must be provided' },
{ status: 400 }
)
}
// Use the new sendEmail API
const emailOptions: any = {
data: {
let result
if (type === 'send') {
// Send immediately
result = await sendEmail(payload, {
templateSlug,
to,
}
}
// Add template if provided
if (templateSlug) {
emailOptions.template = {
slug: templateSlug,
variables: variables || {}
}
} else if (subject && html) {
// Direct email without template
emailOptions.data.subject = subject
emailOptions.data.html = html
if (text) {
emailOptions.data.text = text
}
variables,
})
} else if (type === 'schedule') {
// Schedule for later
result = await scheduleEmail(payload, {
templateSlug,
to,
variables,
scheduledAt: scheduledAt ? new Date(scheduledAt) : new Date(Date.now() + 60000), // Default to 1 minute
})
} else {
return Response.json({
error: 'Either templateSlug or subject+html must be provided'
}, { status: 400 })
return Response.json({ error: 'Invalid type. Use "send" or "schedule"' }, { status: 400 })
}
// Add scheduling if needed
if (type === 'schedule' || scheduledAt) {
emailOptions.data.scheduledAt = scheduledAt ? new Date(scheduledAt) : new Date(Date.now() + 60000)
}
// Set processImmediately for "send now" type
const processImmediately = (type === 'send' && !scheduledAt)
emailOptions.processImmediately = processImmediately
const result = await sendEmail(payload, emailOptions)
return Response.json({
success: true,
emailId: result.id,
message: processImmediately ? 'Email sent successfully' :
scheduledAt ? 'Email scheduled successfully' :
'Email queued successfully',
status: processImmediately ? 'sent' :
scheduledAt ? 'scheduled' :
'queued'
emailId: result,
message: type === 'send' ? 'Email sent successfully' : 'Email scheduled successfully',
})
} catch (error) {
console.error('Test email error:', error)
@@ -107,8 +69,8 @@ export async function GET() {
total: totalDocs,
},
mailing: {
pluginActive: 'mailing' in payload && !!payload.mailing,
service: 'mailing' in payload && payload.mailing && 'service' in payload.mailing && !!payload.mailing.service,
pluginActive: !!(payload as any).mailing,
service: !!(payload as any).mailing?.service,
},
})
} catch (error) {

View File

@@ -33,8 +33,6 @@ export default function MailingTestPage() {
const [selectedTemplate, setSelectedTemplate] = useState<string>('')
const [toEmail, setToEmail] = useState<string>('test@example.com')
const [variables, setVariables] = useState<Record<string, any>>({})
const [jsonVariables, setJsonVariables] = useState<string>('{}')
const [jsonError, setJsonError] = useState<string>('')
const [emailType, setEmailType] = useState<'send' | 'schedule'>('send')
const [scheduleDate, setScheduleDate] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
@@ -60,23 +58,6 @@ export default function MailingTestPage() {
const template = templates.find(t => t.slug === templateSlug)
if (template?.previewData) {
setVariables(template.previewData)
setJsonVariables(JSON.stringify(template.previewData, null, 2))
} else {
setVariables({})
setJsonVariables('{}')
}
setJsonError('')
}
const handleJsonVariablesChange = (jsonString: string) => {
setJsonVariables(jsonString)
setJsonError('')
try {
const parsed = JSON.parse(jsonString)
setVariables(parsed)
} catch (error) {
setJsonError(error instanceof Error ? error.message : 'Invalid JSON')
}
}
@@ -86,11 +67,6 @@ export default function MailingTestPage() {
return
}
if (jsonError) {
setMessage('Please fix the JSON syntax error before sending')
return
}
setLoading(true)
setMessage('')
@@ -112,8 +88,7 @@ export default function MailingTestPage() {
const result = await response.json()
if (result.success) {
const statusIcon = result.status === 'sent' ? '📧' : '📫'
setMessage(`${statusIcon} ${result.message} (ID: ${result.emailId})`)
setMessage(`${result.message} (ID: ${result.emailId})`)
fetchData() // Refresh email queue
} else {
setMessage(`❌ Error: ${result.error}`)
@@ -229,43 +204,28 @@ export default function MailingTestPage() {
</div>
)}
{selectedTemplate && (
{selectedTemplateData?.variables && (
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
<strong>Template Variables (JSON):</strong>
{selectedTemplateData?.variables && (
<small style={{ color: '#666', marginLeft: '8px' }}>
Available variables: {selectedTemplateData.variables.map(v => v.name).join(', ')}
</small>
)}
</label>
<textarea
value={jsonVariables}
onChange={(e) => handleJsonVariablesChange(e.target.value)}
placeholder='{\n "firstName": "John",\n "siteName": "MyApp",\n "createdAt": "2023-01-01T00:00:00Z"\n}'
style={{
width: '100%',
height: '150px',
padding: '8px',
borderRadius: '4px',
border: jsonError ? '2px solid #dc3545' : '1px solid #ddd',
fontFamily: 'monaco, "Courier New", monospace',
fontSize: '13px',
resize: 'vertical'
}}
/>
{jsonError && (
<div style={{
color: '#dc3545',
fontSize: '12px',
marginTop: '5px',
padding: '5px',
backgroundColor: '#f8d7da',
borderRadius: '4px'
}}>
Invalid JSON: {jsonError}
<h3>Template Variables:</h3>
{selectedTemplateData.variables.map(variable => (
<div key={variable.name} style={{ marginBottom: '10px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
{variable.name} {variable.required && <span style={{ color: 'red' }}>*</span>}
{variable.description && <small style={{ color: '#666' }}> - {variable.description}</small>}
</label>
<input
type={variable.type === 'number' ? 'number' : variable.type === 'date' ? 'datetime-local' : 'text'}
value={variables[variable.name] || ''}
onChange={(e) => setVariables({
...variables,
[variable.name]: variable.type === 'number' ? Number(e.target.value) :
variable.type === 'boolean' ? e.target.checked :
e.target.value
})}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
)}
))}
</div>
)}

View File

@@ -1,11 +0,0 @@
import { Metadata } from 'next'
import {redirect} from "next/navigation.js"
export const metadata: Metadata = {
title: 'PayloadCMS Mailing Plugin - Development',
description: 'Development environment for PayloadCMS Mailing Plugin',
}
export default function HomePage() {
redirect('/dashboard')
}

View File

@@ -90,7 +90,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
defaultIDType: string;
};
globals: {};
globalsSelect: {};
@@ -100,8 +100,7 @@ export interface Config {
};
jobs: {
tasks: {
'process-emails': ProcessEmailsTask;
'send-email': TaskSendEmail;
'process-email-queue': ProcessEmailQueueJob;
inline: {
input: unknown;
output: unknown;
@@ -133,7 +132,7 @@ export interface UserAuthOperations {
* via the `definition` "users".
*/
export interface User {
id: number;
id: string;
firstName?: string | null;
lastName?: string | null;
updatedAt: string;
@@ -159,7 +158,7 @@ export interface User {
* via the `definition` "posts".
*/
export interface Post {
id: number;
id: string;
updatedAt: string;
createdAt: string;
}
@@ -168,7 +167,7 @@ export interface Post {
* via the `definition` "media".
*/
export interface Media {
id: number;
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -186,7 +185,7 @@ export interface Media {
* via the `definition` "email-templates".
*/
export interface EmailTemplate {
id: number;
id: string;
/**
* A descriptive name for this email template
*/
@@ -227,31 +226,31 @@ export interface EmailTemplate {
* via the `definition` "emails".
*/
export interface Email {
id: number;
id: string;
/**
* Email template used (optional if custom content provided)
*/
template?: (number | null) | EmailTemplate;
template?: (string | null) | EmailTemplate;
/**
* Recipient email addresses
* Template slug used for this email
*/
to: string[];
templateSlug?: string | null;
/**
* CC email addresses
* Recipient email address(es), comma-separated
*/
cc?: string[] | null;
to: string;
/**
* BCC email addresses
* CC email address(es), comma-separated
*/
bcc?: string[] | null;
cc?: string | null;
/**
* BCC email address(es), comma-separated
*/
bcc?: string | null;
/**
* Sender email address (optional, uses default if not provided)
*/
from?: string | null;
/**
* Sender display name (optional, e.g., "John Doe" for "John Doe <john@example.com>")
*/
fromName?: string | null;
/**
* Reply-to email address
*/
@@ -316,7 +315,7 @@ export interface Email {
* via the `definition` "payload-jobs".
*/
export interface PayloadJob {
id: number;
id: string;
/**
* Input data provided to the job
*/
@@ -363,7 +362,7 @@ export interface PayloadJob {
| {
executedAt: string;
completedAt: string;
taskSlug: 'inline' | 'process-emails' | 'send-email';
taskSlug: 'inline' | 'process-email-queue';
taskID: string;
input?:
| {
@@ -396,7 +395,7 @@ export interface PayloadJob {
id?: string | null;
}[]
| null;
taskSlug?: ('inline' | 'process-emails' | 'send-email') | null;
taskSlug?: ('inline' | 'process-email-queue') | null;
queue?: string | null;
waitUntil?: string | null;
processing?: boolean | null;
@@ -408,36 +407,36 @@ export interface PayloadJob {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
id: string;
document?:
| ({
relationTo: 'users';
value: number | User;
value: string | User;
} | null)
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'media';
value: number | Media;
value: string | Media;
} | null)
| ({
relationTo: 'email-templates';
value: number | EmailTemplate;
value: string | EmailTemplate;
} | null)
| ({
relationTo: 'emails';
value: number | Email;
value: string | Email;
} | null)
| ({
relationTo: 'payload-jobs';
value: number | PayloadJob;
value: string | PayloadJob;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
value: string | User;
};
updatedAt: string;
createdAt: string;
@@ -447,10 +446,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
id: string;
user: {
relationTo: 'users';
value: number | User;
value: string | User;
};
key?: string | null;
value?:
@@ -470,7 +469,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -543,11 +542,11 @@ export interface EmailTemplatesSelect<T extends boolean = true> {
*/
export interface EmailsSelect<T extends boolean = true> {
template?: T;
templateSlug?: T;
to?: T;
cc?: T;
bcc?: T;
from?: T;
fromName?: T;
replyTo?: T;
subject?: T;
html?: T;
@@ -628,87 +627,12 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ProcessEmailsTask".
* via the `definition` "ProcessEmailQueueJob".
*/
export interface ProcessEmailsTask {
export interface ProcessEmailQueueJob {
input?: unknown;
output?: unknown;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TaskSend-email".
*/
export interface TaskSendEmail {
input: {
/**
* Process and send the email immediately instead of waiting for the queue processor
*/
processImmediately?: boolean | null;
/**
* Use a template (leave empty for direct email)
*/
templateSlug?: string | null;
/**
* JSON object with variables for template rendering
*/
variables?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Email subject (required if not using template)
*/
subject?: string | null;
/**
* HTML email content (required if not using template)
*/
html?: string | null;
/**
* Plain text email content (optional)
*/
text?: string | null;
/**
* Comma-separated list of email addresses
*/
to: string;
/**
* Optional comma-separated list of CC email addresses
*/
cc?: string | null;
/**
* Optional comma-separated list of BCC email addresses
*/
bcc?: string | null;
/**
* Optional sender email address (uses default if not provided)
*/
from?: string | null;
/**
* Optional sender display name (e.g., "John Doe")
*/
fromName?: string | null;
/**
* Optional reply-to email address
*/
replyTo?: string | null;
/**
* Optional date/time to schedule email for future delivery
*/
scheduledAt?: string | null;
/**
* Email priority (1 = highest, 10 = lowest)
*/
priority?: number | null;
};
output: {
id?: string | null;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".

View File

@@ -1,11 +1,14 @@
import { sqliteAdapter } from '@payloadcms/db-sqlite'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import {
BlocksFeature,
FixedToolbarFeature,
HeadingFeature,
HorizontalRuleFeature,
InlineToolbarFeature,
lexicalHTML,
} from '@payloadcms/richtext-lexical'
import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path'
import { buildConfig } from 'payload'
import sharp from 'sharp'
@@ -14,7 +17,7 @@ import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
import { seed, seedUser } from './seed.js'
import mailingPlugin from "../src/plugin.js"
import {sendEmail} from "@xtr-dev/payload-mailing"
import { sendEmail } from "../src/utils/helpers.js"
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -23,7 +26,36 @@ if (!process.env.ROOT_DIR) {
process.env.ROOT_DIR = dirname
}
export default buildConfig({
const buildConfigWithMemoryDB = async () => {
// Use in-memory MongoDB for development and testing
if (process.env.NODE_ENV === 'test' || process.env.USE_MEMORY_DB === 'true' || !process.env.DATABASE_URI) {
console.log('🚀 Starting MongoDB in-memory database...')
const memoryDB = await MongoMemoryReplSet.create({
replSet: {
count: 1, // Single instance for dev (faster startup)
dbName: process.env.NODE_ENV === 'test' ? 'payloadmemory' : 'payload-mailing-dev',
storageEngine: 'wiredTiger',
},
})
const uri = `${memoryDB.getUri()}&retryWrites=true`
process.env.DATABASE_URI = uri
console.log('✅ MongoDB in-memory database started')
console.log(`📊 Database URI: ${uri.replace(/mongodb:\/\/[^@]*@/, 'mongodb://***@')}`)
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('🛑 Stopping MongoDB in-memory database...')
await memoryDB.stop()
process.exit(0)
})
} else {
console.log(`🔗 Using external MongoDB: ${process.env.DATABASE_URI?.replace(/mongodb:\/\/[^@]*@/, 'mongodb://***@')}`)
}
return buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname),
@@ -53,19 +85,15 @@ export default buildConfig({
// Queue the welcome email using template slug
const emailId = await sendEmail(req.payload, {
template: {
slug: 'welcome-email',
variables: {
firstName: doc.firstName || doc.email?.split('@')?.[0],
siteName: 'PayloadCMS Mailing Demo',
createdAt: new Date().toISOString(),
isPremium: false,
dashboardUrl: 'http://localhost:3000/admin',
},
templateSlug: 'welcome-email',
to: doc.email,
variables: {
firstName: doc.firstName || doc.email?.split('@')?.[0],
siteName: 'PayloadCMS Mailing Demo',
createdAt: new Date().toISOString(),
isPremium: false,
dashboardUrl: 'http://localhost:3000/admin',
},
data: {
to: doc.email,
}
})
console.log('✅ Welcome email queued successfully. Email ID:', emailId)
@@ -92,36 +120,148 @@ export default buildConfig({
},
},
],
db: sqliteAdapter({
client: {
url: process.env.DATABASE_URI || 'file:./dev.db',
},
db: mongooseAdapter({
ensureIndexes: true,
url: process.env.DATABASE_URI || '',
}),
editor: lexicalEditor(),
email: testEmailAdapter,
onInit: async (payload) => {
await seed(payload)
},
jobs: {
jobsCollectionOverrides: c => {
if (c.defaultJobsCollection.admin) c.defaultJobsCollection.admin.hidden = false
return c.defaultJobsCollection
},
autoRun: [
{
cron: '*/1 * * * *', // every minute
limit: 10, // limit jobs to process each run
queue: 'default', // name of the queue
},
],
},
plugins: [
mailingPlugin({
defaultFrom: 'noreply@test.com',
initOrder: 'after',
transport: {
host: 'localhost',
port: 1025, // MailHog port for dev
secure: false,
auth: {
user: 'test',
pass: 'test',
},
},
retryAttempts: 3,
retryDelay: 60000, // 1 minute for dev
queue: 'default',
queue: 'email-queue',
// 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
// Comment out to use default lexical editor
@@ -139,6 +279,62 @@ export default buildConfig({
// etc.
],
}),
emailWrapper: (email) => {
// Example: wrap email content in a custom layout
const wrappedHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${email.subject}</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; }
.header { background: #007bff; color: white; padding: 20px; text-align: center; }
.content { padding: 30px; }
.footer { background: #f8f9fa; padding: 15px; text-align: center; font-size: 12px; color: #6c757d; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>My Company</h1>
</div>
<div class="content">
${email.html}
</div>
<div class="footer">
This email was sent from My Company. If you have questions, contact support@mycompany.com
</div>
</div>
</body>
</html>
`
const wrappedText = `
MY COMPANY
==========
${email.text || email.html?.replace(/<[^>]*>/g, '')}
---
This email was sent from My Company.
If you have questions, contact support@mycompany.com
`
return {
...email,
html: wrappedHtml,
text: wrappedText.trim(),
}
},
// Called after mailing plugin is fully initialized
onReady: async (payload) => {
await seedUser(payload)
},
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
@@ -147,3 +343,6 @@ export default buildConfig({
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
}
export default buildConfigWithMemoryDB()

View File

@@ -102,7 +102,7 @@ export const seed = async (payload: Payload) => {
format: 0,
mode: 'normal',
style: '',
text: 'Create beautiful emails with rich text formatting',
text: 'Use the emailWrapper hook to add custom layouts',
type: 'text',
version: 1,
},

View File

@@ -3,14 +3,26 @@
// Development startup script for PayloadCMS Mailing Plugin
// This ensures proper environment setup and provides helpful information
console.log('🚀 PayloadCMS Mailing Plugin - Development Mode')
console.log('=' .repeat(50))
// Set development environment
process.env.NODE_ENV = process.env.NODE_ENV || 'development'
// Set default SQLite database for development
// Enable in-memory MongoDB by default for development
if (!process.env.DATABASE_URI) {
process.env.DATABASE_URI = 'file:./dev.db'
process.env.USE_MEMORY_DB = 'true'
console.log('📦 Using in-memory MongoDB (no installation required)')
} else {
console.log(`🔗 Using external MongoDB: ${process.env.DATABASE_URI}`)
}
console.log('')
console.log('🔧 Starting development server...')
console.log('📧 Mailing plugin configured with test transport')
console.log('🎯 Test interface will be available at: /mailing-test')
console.log('')
// Import and start Next.js
import('next/dist/cli/next-dev.js')
.then(({ nextDev }) => {
@@ -23,9 +35,11 @@ import('next/dist/cli/next-dev.js')
// Handle graceful shutdown
process.on('SIGTERM', () => {
console.log('\n🛑 Shutting down development server...')
process.exit(0)
})
process.on('SIGINT', () => {
console.log('\n🛑 Shutting down development server...')
process.exit(0)
})

View File

@@ -1,113 +0,0 @@
// Test hook validation in the dev environment
import { getPayload } from 'payload'
import config from './payload.config.js'
async function testHookValidation() {
const payload = await getPayload({ config: await config })
console.log('\n🧪 Testing beforeSend hook validation...\n')
// Test 1: Create an email to process
const email = await payload.create({
collection: 'emails',
data: {
to: ['test@example.com'],
subject: 'Test Email for Validation',
html: '<p>Testing hook validation</p>',
text: 'Testing hook validation',
status: 'pending'
}
})
console.log('✅ Test email created:', email.id)
// Get the mailing service
const mailingService = (payload as any).mailing.service
// Test 2: Temporarily replace the config with a bad hook
const originalBeforeSend = mailingService.config.beforeSend
console.log('\n📝 Test: Hook that removes "from" field...')
mailingService.config.beforeSend = async (options: any, email: any) => {
delete options.from
return options
}
try {
await mailingService.processEmails()
console.log('❌ Should have thrown error for missing "from"')
} catch (error: any) {
if (error.message.includes('must not remove the "from" property')) {
console.log('✅ Correctly caught missing "from" field')
} else {
console.log('❌ Unexpected error:', error.message)
}
}
console.log('\n📝 Test: Hook that empties "to" array...')
mailingService.config.beforeSend = async (options: any, email: any) => {
options.to = []
return options
}
try {
await mailingService.processEmails()
console.log('❌ Should have thrown error for empty "to"')
} catch (error: any) {
if (error.message.includes('must not remove or empty the "to" property')) {
console.log('✅ Correctly caught empty "to" array')
} else {
console.log('❌ Unexpected error:', error.message)
}
}
console.log('\n📝 Test: Hook that removes "subject"...')
mailingService.config.beforeSend = async (options: any, email: any) => {
delete options.subject
return options
}
try {
await mailingService.processEmails()
console.log('❌ Should have thrown error for missing "subject"')
} catch (error: any) {
if (error.message.includes('must not remove the "subject" property')) {
console.log('✅ Correctly caught missing "subject" field')
} else {
console.log('❌ Unexpected error:', error.message)
}
}
console.log('\n📝 Test: Hook that removes both "html" and "text"...')
mailingService.config.beforeSend = async (options: any, email: any) => {
delete options.html
delete options.text
return options
}
try {
await mailingService.processEmails()
console.log('❌ Should have thrown error for missing content')
} catch (error: any) {
if (error.message.includes('must not remove both "html" and "text" properties')) {
console.log('✅ Correctly caught missing content fields')
} else {
console.log('❌ Unexpected error:', error.message)
}
}
// Restore original hook
mailingService.config.beforeSend = originalBeforeSend
console.log('\n✅ All validation tests completed!\n')
// Clean up
await payload.delete({
collection: 'emails',
id: email.id
})
process.exit(0)
}
testHookValidation().catch(console.error)

View File

@@ -1,10 +1,10 @@
// Simple test to verify plugin can be imported and initialized
import { mailingPlugin, sendEmail, renderTemplate } from '@xtr-dev/payload-mailing'
import { mailingPlugin, sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
console.log('✅ Plugin imports successfully')
console.log('✅ mailingPlugin:', typeof mailingPlugin)
console.log('✅ sendEmail:', typeof sendEmail)
console.log('✅ renderTemplate:', typeof renderTemplate)
console.log('✅ sendEmail:', typeof sendEmail)
console.log('✅ scheduleEmail:', typeof scheduleEmail)
// Test plugin configuration
try {

View File

@@ -19,13 +19,13 @@
"@payload-config": [
"./payload.config.ts"
],
"@xtr-dev/payload-mailing": [
"temp-project": [
"../src/index.ts"
],
"@xtr-dev/payload-mailing/client": [
"temp-project/client": [
"../src/exports/client.ts"
],
"@xtr-dev/payload-mailing/rsc": [
"temp-project/rsc": [
"../src/exports/rsc.ts"
]
},

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/payload-mailing",
"version": "0.4.21",
"version": "0.0.7",
"description": "Template-based email system with scheduling and job processing for PayloadCMS",
"type": "module",
"main": "dist/index.js",
@@ -23,6 +23,8 @@
"dev:generate-importmap": "npm run dev:payload generate:importmap",
"dev:generate-types": "npm run dev:payload generate:types",
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
"generate:importmap": "npm run dev:generate-importmap",
"generate:types": "npm run dev:generate-types",
"lint": "eslint",
"lint:fix": "eslint ./src --fix",
"prepublishOnly": "npm run clean && npm run build",
@@ -45,12 +47,9 @@
"payload": "^3.37.0"
},
"dependencies": {
"handlebars": "^4.7.8",
"nodemailer": "^6.9.8"
},
"optionalDependencies": {
"liquidjs": "^10.19.0",
"mustache": "^4.2.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@payloadcms/db-mongodb": "^3.37.0",

66
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
handlebars:
specifier: ^4.7.8
version: 4.7.8
nodemailer:
specifier: ^6.9.8
version: 6.10.1
@@ -114,13 +117,6 @@ importers:
vitest:
specifier: ^3.1.2
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.1)(sass@1.77.4)(tsx@4.20.3)
optionalDependencies:
liquidjs:
specifier: ^10.19.0
version: 10.21.1
mustache:
specifier: ^4.2.0
version: 4.2.0
packages:
@@ -2641,10 +2637,6 @@ packages:
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
commander@10.0.1:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -3548,6 +3540,11 @@ packages:
resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'}
hasBin: true
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -3925,11 +3922,6 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
liquidjs@10.21.1:
resolution: {integrity: sha512-NZXmCwv3RG5nire3fmIn9HsOyJX3vo+ptp0yaXUHAMzSNBhx74Hm+dAGJvscUA6lNqbLuYfXgNavRQ9UbUJhQQ==}
engines: {node: '>=14'}
hasBin: true
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@@ -4194,10 +4186,6 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mustache@4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -4214,6 +4202,9 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
new-find-package-json@2.0.0:
resolution: {integrity: sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==}
engines: {node: '>=12.22.0'}
@@ -5179,6 +5170,11 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uglify-js@3.19.3:
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
engines: {node: '>=0.8.0'}
hasBin: true
uint8array-extras@1.5.0:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
@@ -5381,6 +5377,9 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -8541,9 +8540,6 @@ snapshots:
colorette@2.0.20: {}
commander@10.0.1:
optional: true
commander@2.20.3: {}
commander@6.2.1: {}
@@ -9693,6 +9689,15 @@ snapshots:
graphql@16.11.0: {}
handlebars@4.7.8:
dependencies:
minimist: 1.2.8
neo-async: 2.6.2
source-map: 0.6.1
wordwrap: 1.0.0
optionalDependencies:
uglify-js: 3.19.3
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
@@ -10039,11 +10044,6 @@ snapshots:
lines-and-columns@1.2.4: {}
liquidjs@10.21.1:
dependencies:
commander: 10.0.1
optional: true
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
@@ -10431,9 +10431,6 @@ snapshots:
ms@2.1.3: {}
mustache@4.2.0:
optional: true
nanoid@3.3.11: {}
napi-postinstall@0.3.3: {}
@@ -10442,6 +10439,8 @@ snapshots:
natural-compare@1.4.0: {}
neo-async@2.6.2: {}
new-find-package-json@2.0.0:
dependencies:
debug: 4.4.1
@@ -11572,6 +11571,9 @@ snapshots:
typescript@5.9.2: {}
uglify-js@3.19.3:
optional: true
uint8array-extras@1.5.0: {}
unbox-primitive@1.1.0:
@@ -11818,6 +11820,8 @@ snapshots:
word-wrap@1.2.5: {}
wordwrap@1.0.0: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0

View File

@@ -1,36 +1,13 @@
import type { CollectionConfig } from 'payload'
import { findExistingJobs, ensureEmailJob, updateEmailJobRelationship } from '../utils/jobScheduler.js'
import { createContextLogger } from '../utils/logger.js'
import { resolveID } from '../utils/helpers.js'
const Emails: CollectionConfig = {
slug: 'emails',
admin: {
useAsTitle: 'subject',
defaultColumns: ['subject', 'to', 'status', 'jobs', 'scheduledAt', 'sentAt'],
defaultColumns: ['subject', 'to', 'status', 'scheduledAt', 'sentAt'],
group: 'Mailing',
description: 'Email delivery and status tracking',
},
defaultPopulate: {
templateSlug: 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',
@@ -40,14 +17,6 @@ const Emails: CollectionConfig = {
description: 'Email template used (optional if custom content provided)',
},
},
{
name: 'templateSlug',
type: 'text',
admin: {
description: 'Slug of the email template (auto-populated from template relationship)',
readOnly: true,
},
},
{
name: 'to',
type: 'text',
@@ -80,13 +49,6 @@ const Emails: CollectionConfig = {
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',
@@ -195,98 +157,22 @@ const Emails: CollectionConfig = {
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 }) => {
const emailId = resolveID(id)
return {
'input.emailId': {
equals: emailId ? String(emailId) : '',
},
}
},
},
],
hooks: {
beforeChange: [
async ({ data, req }) => {
// Auto-populate templateSlug from template relationship
if (data.template) {
try {
const template = await req.payload.findByID({
collection: 'email-templates',
id: typeof data.template === 'string' ? data.template : data.template.id,
})
data.templateSlug = template.slug
} catch (error) {
// If template lookup fails, clear the slug
data.templateSlug = undefined
}
} else {
// Clear templateSlug if template is removed
data.templateSlug = undefined
}
return data
}
],
// 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'],
},
],
// indexes: [
// {
// fields: {
// status: 1,
// scheduledAt: 1,
// },
// },
// {
// fields: {
// priority: -1,
// createdAt: 1,
// },
// },
// ],
}
export default Emails

View File

@@ -11,27 +11,13 @@ export { MailingService } from './services/MailingService.js'
export { default as EmailTemplates, createEmailTemplatesCollection } from './collections/EmailTemplates.js'
export { default as Emails } from './collections/Emails.js'
// Jobs (includes the individual email processing job)
export { mailingJobs } from './jobs/index.js'
export type { ProcessEmailJobInput } from './jobs/processEmailJob.js'
// Main email sending function
export { sendEmail, type SendEmailOptions } from './sendEmail.js'
export { default as sendEmailDefault } from './sendEmail.js'
// Jobs are integrated into the plugin configuration
// Utility functions for developers
export {
getMailing,
renderTemplate,
sendEmail,
scheduleEmail,
processEmails,
retryFailedEmails,
parseAndValidateEmails,
sanitizeDisplayName,
sanitizeFromName,
} from './utils/helpers.js'
// Email processing utilities
export { processEmailById, processJobById, processAllEmails } from './utils/emailProcessor.js'
// Job scheduling utilities
export { findExistingJobs, ensureEmailJob, updateEmailJobRelationship } from './utils/jobScheduler.js'
} from './utils/helpers.js'

View File

@@ -1,11 +1,19 @@
import { processEmailJob } from './processEmailJob.js'
import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js'
import { MailingService } from '../services/MailingService.js'
/**
* All mailing-related jobs that get registered with Payload
*/
export const mailingJobs = [
processEmailJob,
]
export const createMailingJobs = (mailingService: MailingService): any[] => {
return [
{
slug: 'processEmails',
handler: async ({ job, req }: { job: any; req: any }) => {
return processEmailsJob(
job as { data: ProcessEmailsJobData },
{ req, mailingService }
)
},
interfaceName: 'ProcessEmailsJob',
},
]
}
// Re-export everything from individual job files
export * from './processEmailJob.js'
export * from './processEmailsJob.js'

View File

@@ -1,71 +0,0 @@
import type { PayloadRequest } from 'payload'
import { processEmailById } from '../utils/emailProcessor.js'
/**
* Data passed to the individual email processing job
*/
export interface ProcessEmailJobInput {
/**
* The ID of the email to process
*/
emailId: string | number
}
/**
* Job definition for processing a single email
*/
export const processEmailJob = {
slug: 'process-email',
label: 'Process Individual Email',
inputSchema: [
{
name: 'emailId',
type: 'text' as const,
required: true,
label: 'Email ID',
admin: {
description: 'The ID of the email to process and send'
}
}
],
outputSchema: [
{
name: 'success',
type: 'checkbox' as const
},
{
name: 'emailId',
type: 'text' as const
},
{
name: 'status',
type: 'text' as const
}
],
handler: async ({ input, req }: { input: ProcessEmailJobInput; req: PayloadRequest }) => {
const payload = (req as any).payload
const { emailId } = input
if (!emailId) {
throw new Error('Email ID is required for processing')
}
try {
// Process the individual email
await processEmailById(payload, String(emailId))
return {
output: {
success: true,
emailId: String(emailId),
status: 'sent',
message: `Email ${emailId} processed successfully`
}
}
} catch (error) {
throw new Error(`Failed to process email ${emailId}: ${String(error)}`)
}
}
}
export default processEmailJob

View File

@@ -0,0 +1,50 @@
import type { PayloadRequest } from 'payload'
import { MailingService } from '../services/MailingService.js'
export interface ProcessEmailsJobData {
type: 'process-emails' | 'retry-failed'
}
export const processEmailsJob = async (
job: { data: ProcessEmailsJobData },
context: { req: PayloadRequest; mailingService: MailingService }
) => {
const { mailingService } = context
const { type } = job.data
try {
if (type === 'process-emails') {
await mailingService.processEmails()
console.log('Email 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 scheduleEmailsJob = async (
payload: any,
queueName: string,
jobType: 'process-emails' | '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: 'processEmails',
input: { type: jobType },
waitUntil: delay ? new Date(Date.now() + delay) : undefined,
})
} catch (error) {
console.error(`Failed to schedule ${jobType} job:`, error)
}
}

View File

@@ -3,8 +3,6 @@ import { MailingPluginConfig, MailingContext } from './types/index.js'
import { MailingService } from './services/MailingService.js'
import { createEmailTemplatesCollection } from './collections/EmailTemplates.js'
import Emails from './collections/Emails.js'
import { mailingJobs } from './jobs/index.js'
export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
const queueName = pluginConfig.queue || 'default'
@@ -68,15 +66,10 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
}),
} satisfies CollectionConfig
// Filter out any existing collections with the same slugs to prevent duplicates
const existingCollections = (config.collections || []).filter(
(collection) => collection.slug !== templatesSlug && collection.slug !== emailsSlug
)
return {
...config,
collections: [
...existingCollections,
...(config.collections || []),
templatesCollection,
emailsCollection,
],
@@ -84,7 +77,36 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
...(config.jobs || {}),
tasks: [
...(config.jobs?.tasks || []),
...mailingJobs,
{
slug: 'process-email-queue',
handler: async ({ job, req }: { job: any; req: any }) => {
try {
const mailingService = new MailingService((req as any).payload, pluginConfig)
console.log('🔄 Processing email queue (pending + failed emails)...')
// Process pending emails first
await mailingService.processEmails()
// Then retry failed emails
await mailingService.retryFailedEmails()
return {
output: {
success: true,
message: 'Email queue processed successfully (pending and failed emails)'
}
}
} catch (error) {
console.error('❌ Error processing email queue:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
// Properly fail the job by throwing the error
throw new Error(`Email queue processing failed: ${errorMessage}`)
}
},
interfaceName: 'ProcessEmailQueueJob',
},
],
},
onInit: async (payload: any) => {
@@ -92,7 +114,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
await config.onInit(payload)
}
// Initialize mailing service with proper payload instance
// Initialize mailing service
const mailingService = new MailingService(payload, pluginConfig)
// Add mailing context to payload for developer access
@@ -106,6 +128,13 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
},
} as MailingContext
console.log('PayloadCMS Mailing Plugin initialized successfully')
// Call onReady callback if provided
if (pluginConfig.onReady) {
await pluginConfig.onReady(payload)
}
if (pluginConfig.initOrder !== 'after' && config.onInit) {
await config.onInit(payload)
}

View File

@@ -1,157 +0,0 @@
import { Payload } from 'payload'
import { getMailing, renderTemplateWithId, parseAndValidateEmails, sanitizeFromName } from './utils/helpers.js'
import { BaseEmailDocument } from './types/index.js'
import { processJobById } from './utils/emailProcessor.js'
import { createContextLogger } from './utils/logger.js'
import { pollForJobId } from './utils/jobPolling.js'
// Options for sending emails
export interface SendEmailOptions<T extends BaseEmailDocument = BaseEmailDocument> {
// Template-based email
template?: {
slug: string
variables?: Record<string, any>
}
// Direct email data
data?: Partial<T>
// Common options
collectionSlug?: string // defaults to 'emails'
processImmediately?: boolean // if true, creates job and processes it immediately
queue?: string // queue name for the job, defaults to mailing config queue
}
/**
* 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 <TEmail extends BaseEmailDocument = BaseEmailDocument>(
payload: Payload,
options: SendEmailOptions<TEmail>
): Promise<TEmail> => {
const mailingConfig = getMailing(payload)
const collectionSlug = options.collectionSlug || mailingConfig.collections.emails || 'emails'
let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail>
if (options.template) {
// Look up and render the template in a single operation to avoid duplicate lookups
const { html, text, subject, templateId } = await renderTemplateWithId(
payload,
options.template.slug,
options.template.variables || {}
)
emailData = {
...emailData,
template: templateId,
subject,
html,
text,
} as Partial<TEmail>
}
// Validate required fields
if (!emailData.to) {
throw new Error('Field "to" is required for sending emails')
}
if (options.template) {
if (!emailData.subject || !emailData.html) {
throw new Error(`Template rendering failed: template "${options.template.slug}" did not provide required subject and html content`)
}
} else {
if (!emailData.subject || !emailData.html) {
throw new Error('Fields "subject" and "html" are required when sending direct emails without a template')
}
}
if (emailData.to) {
emailData.to = parseAndValidateEmails(emailData.to as string | string[])
}
if (emailData.cc) {
emailData.cc = parseAndValidateEmails(emailData.cc as string | string[])
}
if (emailData.bcc) {
emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[])
}
if (emailData.replyTo) {
const validated = parseAndValidateEmails(emailData.replyTo as string | string[])
emailData.replyTo = validated && validated.length > 0 ? validated[0] : undefined
}
if (emailData.from) {
const validated = parseAndValidateEmails(emailData.from as string | string[])
emailData.from = validated && validated.length > 0 ? validated[0] : undefined
}
emailData.fromName = sanitizeFromName(emailData.fromName as string)
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()
}
const email = await payload.create({
collection: collectionSlug,
data: emailData
})
if (!email || typeof email !== 'object' || !email.id) {
throw new Error('Failed to create email: invalid response from database')
}
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 ID using configurable polling mechanism
const { jobId } = await pollForJobId({
payload,
collectionSlug,
emailId: email.id,
config: mailingConfig.jobPolling,
logger,
})
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 as TEmail
}
export default sendEmail

View File

@@ -1,63 +1,51 @@
import {CollectionSlug, EmailAdapter, Payload, SendEmailOptions} from 'payload'
import { Liquid } from 'liquidjs'
import {
MailingPluginConfig,
TemplateVariables,
import { Payload } from 'payload'
import Handlebars from 'handlebars'
import nodemailer, { Transporter } from 'nodemailer'
import {
MailingPluginConfig,
SendEmailOptions,
MailingService as IMailingService,
BaseEmailDocument, BaseEmailTemplateDocument
EmailTemplate,
QueuedEmail,
MailingTransportConfig,
EmailObject
} from '../types/index.js'
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
import { sanitizeDisplayName } from '../utils/helpers.js'
export class MailingService implements IMailingService {
public payload: Payload
private payload: Payload
private config: MailingPluginConfig
private transporter!: Transporter | any
private templatesCollection: string
private emailsCollection: string
private liquid: Liquid | null | false = null
constructor(payload: Payload, config: MailingPluginConfig) {
this.payload = payload
this.config = config
const templatesConfig = config.collections?.templates
this.templatesCollection = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates'
const emailsConfig = config.collections?.emails
this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails'
// Use Payload's configured email adapter
if (!this.payload.email) {
throw new Error('Payload email configuration is required. Please configure email in your Payload config.')
}
this.initializeTransporter()
this.registerHandlebarsHelpers()
}
private ensureInitialized(): void {
if (!this.payload || !this.payload.db) {
throw new Error('MailingService payload not properly initialized')
private initializeTransporter(): void {
if (this.config.transport) {
if ('sendMail' in this.config.transport) {
this.transporter = this.config.transport
} else {
this.transporter = nodemailer.createTransport(this.config.transport as MailingTransportConfig)
}
} else if (this.payload.email && 'sendMail' in this.payload.email) {
// Use Payload's configured mailer (cast to any to handle different adapter types)
this.transporter = this.payload.email as any
} else {
throw new Error('Email transport configuration is required either in plugin config or Payload config')
}
if (!this.payload.email) {
throw new Error('Email adapter not configured. Please ensure Payload has email configured.')
}
}
/**
* Sanitizes a display name for use in email headers to prevent header injection
* Uses the centralized sanitization utility with quote escaping for headers
*/
private sanitizeDisplayName(name: string): string {
return sanitizeDisplayName(name, true) // escapeQuotes = true for email headers
}
/**
* Formats an email address with optional display name
*/
private formatEmailAddress(email: string, displayName?: string | null): string {
if (displayName && displayName.trim()) {
const sanitizedName = this.sanitizeDisplayName(displayName)
return `"${sanitizedName}" <${email}>`
}
return email
}
private getDefaultFrom(): string {
@@ -66,92 +54,119 @@ export class MailingService implements IMailingService {
// Check if fromName exists, is not empty after trimming, and fromEmail exists
if (fromName && fromName.trim() && fromEmail) {
return this.formatEmailAddress(fromEmail, fromName)
// Escape quotes in the display name to prevent malformed headers
const escapedName = fromName.replace(/"/g, '\\"')
return `"${escapedName}" <${fromEmail}>`
}
return fromEmail || ''
}
private async ensureLiquidJSInitialized(): Promise<void> {
if (this.liquid !== null) return // Already initialized or failed
try {
const liquidModule = await import('liquidjs')
const { Liquid: LiquidEngine } = liquidModule
this.liquid = new LiquidEngine()
// Register custom filters (equivalent to Handlebars helpers)
if (this.liquid && typeof this.liquid !== 'boolean') {
this.liquid.registerFilter('formatDate', (date: any, 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()
})
this.liquid.registerFilter('formatCurrency', (amount: any, currency = 'USD') => {
if (typeof amount !== 'number') return amount
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(amount)
})
this.liquid.registerFilter('capitalize', (str: any) => {
if (typeof str !== 'string') return str
return str.charAt(0).toUpperCase() + str.slice(1)
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'
})
}
} catch (error) {
console.warn('LiquidJS not available. Falling back to simple variable replacement. Install liquidjs or use a different templateEngine.')
this.liquid = false // Mark as failed to avoid retries
}
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(this: any, 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 renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> {
this.ensureInitialized()
const template = await this.getTemplateBySlug(templateSlug)
async sendEmail(options: SendEmailOptions): Promise<string> {
const emailId = await this.scheduleEmail({
...options,
scheduledAt: new Date()
})
if (!template) {
throw new Error(`Email template not found: ${templateSlug}`)
}
return this.renderTemplateDocument(template, variables)
await this.processEmailItem(emailId)
return emailId
}
/**
* Render a template document (for when you already have the template loaded)
* This avoids duplicate template lookups
* @internal
*/
async renderTemplateDocument(template: BaseEmailTemplateDocument, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> {
this.ensureInitialized()
async scheduleEmail(options: SendEmailOptions): Promise<string> {
let html = options.html || ''
let text = options.text || ''
let subject = options.subject || ''
let templateId: string | undefined = undefined
const emailContent = await this.renderEmailTemplate(template, variables)
const subject = await this.renderTemplateString(template.subject || '', variables)
return {
html: emailContent.html,
text: emailContent.text,
subject
if (options.templateSlug) {
const template = await this.getTemplateBySlug(options.templateSlug)
if (template) {
templateId = template.id
const variables = options.variables || {}
const renderedContent = await this.renderEmailTemplate(template, variables)
html = renderedContent.html
text = renderedContent.text
subject = this.renderHandlebarsTemplate(template.subject, variables)
} else {
throw new Error(`Email template not found: ${options.templateSlug}`)
}
}
if (!subject && !options.subject) {
throw new Error('Email subject is required')
}
if (!html && !options.html) {
throw new Error('Email HTML content is required')
}
const queueData = {
template: templateId,
to: Array.isArray(options.to) ? options.to : [options.to],
cc: options.cc ? (Array.isArray(options.cc) ? options.cc : [options.cc]) : undefined,
bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : undefined,
from: options.from || this.getDefaultFrom(),
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.emailsCollection as any,
data: queueData,
})
return result.id as string
}
async processEmails(): Promise<void> {
this.ensureInitialized()
const currentTime = new Date().toISOString()
const { docs: pendingEmails } = await this.payload.find({
collection: this.emailsCollection as CollectionSlug,
collection: this.emailsCollection as any,
where: {
and: [
{
@@ -185,13 +200,12 @@ export class MailingService implements IMailingService {
}
async retryFailedEmails(): Promise<void> {
this.ensureInitialized()
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.emailsCollection as CollectionSlug,
collection: this.emailsCollection as any,
where: {
and: [
{
@@ -228,10 +242,10 @@ export class MailingService implements IMailingService {
}
}
async processEmailItem(emailId: string): Promise<void> {
private async processEmailItem(emailId: string): Promise<void> {
try {
await this.payload.update({
collection: this.emailsCollection as CollectionSlug,
collection: this.emailsCollection as any,
id: emailId,
data: {
status: 'processing',
@@ -240,21 +254,12 @@ export class MailingService implements IMailingService {
})
const email = await this.payload.findByID({
collection: this.emailsCollection as CollectionSlug,
collection: this.emailsCollection as any,
id: emailId,
depth: 1,
}) as BaseEmailDocument
}) as QueuedEmail
// Combine from and fromName for nodemailer using proper sanitization
let fromField: string
if (email.from) {
fromField = this.formatEmailAddress(email.from, email.fromName)
} else {
fromField = this.getDefaultFrom()
}
let mailOptions: SendEmailOptions = {
from: fromField,
let emailObject: EmailObject = {
from: email.from || this.getDefaultFrom(),
to: email.to,
cc: email.cc || undefined,
bcc: email.bcc || undefined,
@@ -262,50 +267,29 @@ export class MailingService implements IMailingService {
subject: email.subject,
html: email.html,
text: email.text || undefined,
variables: email.variables,
}
if (!mailOptions.from) {
throw new Error('Email from field is required')
}
if (!mailOptions.to || (Array.isArray(mailOptions.to) && mailOptions.to.length === 0)) {
throw new Error('Email to field is required')
}
if (!mailOptions.subject) {
throw new Error('Email subject is required')
}
if (!mailOptions.html && !mailOptions.text) {
throw new Error('Email content is required')
// Apply emailWrapper hook if configured
if (this.config.emailWrapper) {
emailObject = await this.config.emailWrapper(emailObject)
}
// Call beforeSend hook if configured
if (this.config.beforeSend) {
try {
mailOptions = await this.config.beforeSend(mailOptions, email)
// Validate required properties remain intact after hook execution
if (!mailOptions.from) {
throw new Error('beforeSend hook must not remove the "from" property')
}
if (!mailOptions.to || (Array.isArray(mailOptions.to) && mailOptions.to.length === 0)) {
throw new Error('beforeSend hook must not remove or empty the "to" property')
}
if (!mailOptions.subject) {
throw new Error('beforeSend hook must not remove the "subject" property')
}
if (!mailOptions.html && !mailOptions.text) {
throw new Error('beforeSend hook must not remove both "html" and "text" properties')
}
} catch (error) {
console.error('Error in beforeSend hook:', error)
throw new Error(`beforeSend hook failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
const mailOptions = {
from: emailObject.from,
to: emailObject.to,
cc: emailObject.cc || undefined,
bcc: emailObject.bcc || undefined,
replyTo: emailObject.replyTo || undefined,
subject: emailObject.subject,
html: emailObject.html,
text: emailObject.text || undefined,
}
// Send email using Payload's email adapter
await this.payload.email.sendEmail(mailOptions)
await this.transporter.sendMail(mailOptions)
await this.payload.update({
collection: this.emailsCollection as CollectionSlug,
collection: this.emailsCollection as any,
id: emailId,
data: {
status: 'sent',
@@ -319,7 +303,7 @@ export class MailingService implements IMailingService {
const maxAttempts = this.config.retryAttempts || 3
await this.payload.update({
collection: this.emailsCollection as CollectionSlug,
collection: this.emailsCollection as any,
id: emailId,
data: {
status: attempts >= maxAttempts ? 'failed' : 'pending',
@@ -336,14 +320,14 @@ export class MailingService implements IMailingService {
private async incrementAttempts(emailId: string): Promise<number> {
const email = await this.payload.findByID({
collection: this.emailsCollection as CollectionSlug,
collection: this.emailsCollection as any,
id: emailId,
})
}) as QueuedEmail
const newAttempts = ((email as any).attempts || 0) + 1
const newAttempts = (email.attempts || 0) + 1
await this.payload.update({
collection: this.emailsCollection as CollectionSlug,
collection: this.emailsCollection as any,
id: emailId,
data: {
attempts: newAttempts,
@@ -353,7 +337,7 @@ export class MailingService implements IMailingService {
return newAttempts
}
private async getTemplateBySlug(templateSlug: string): Promise<BaseEmailTemplateDocument | null> {
private async getTemplateBySlug(templateSlug: string): Promise<EmailTemplate | null> {
try {
const { docs } = await this.payload.find({
collection: this.templatesCollection as any,
@@ -364,73 +348,25 @@ export class MailingService implements IMailingService {
},
limit: 1,
})
return docs.length > 0 ? docs[0] as BaseEmailTemplateDocument : null
return docs.length > 0 ? docs[0] as EmailTemplate : null
} catch (error) {
console.error(`Template with slug '${templateSlug}' not found:`, error)
return null
}
}
private async renderTemplateString(template: string, variables: Record<string, any>): Promise<string> {
// Use custom template renderer if provided
if (this.config.templateRenderer) {
try {
return await this.config.templateRenderer(template, variables)
} catch (error) {
console.error('Custom template renderer error:', error)
return template
}
}
const engine = this.config.templateEngine || 'liquidjs'
// Use LiquidJS if configured
if (engine === 'liquidjs') {
try {
await this.ensureLiquidJSInitialized()
if (this.liquid) {
return await this.liquid.parseAndRender(template, variables)
}
} catch (error) {
console.error('LiquidJS template rendering error:', error)
}
}
// Use Mustache if configured
if (engine === 'mustache') {
try {
const mustacheResult = await this.renderWithMustache(template, variables)
if (mustacheResult !== null) {
return mustacheResult
}
} catch (error) {
console.warn('Mustache not available. Falling back to simple variable replacement. Install mustache package.')
}
}
// Fallback to simple variable replacement
return this.simpleVariableReplacement(template, variables)
}
private async renderWithMustache(template: string, variables: Record<string, any>): Promise<string | null> {
private renderHandlebarsTemplate(template: string, variables: Record<string, any>): string {
try {
const mustacheModule = await import('mustache')
const Mustache = mustacheModule.default || mustacheModule
return Mustache.render(template, variables)
const compiled = Handlebars.compile(template)
return compiled(variables)
} catch (error) {
return null
console.error('Handlebars template rendering error:', error)
return template
}
}
private simpleVariableReplacement(template: string, variables: Record<string, any>): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
const value = variables[key]
return value !== undefined ? String(value) : match
})
}
private async renderEmailTemplate(template: BaseEmailTemplateDocument, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
private async renderEmailTemplate(template: EmailTemplate, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
if (!template.content) {
return { html: '', text: '' }
}
@@ -439,11 +375,11 @@ export class MailingService implements IMailingService {
let html = serializeRichTextToHTML(template.content)
let text = serializeRichTextToText(template.content)
// Apply template variables to the rendered content
html = await this.renderTemplateString(html, variables)
text = await this.renderTemplateString(text, variables)
// Apply Handlebars variables to the rendered content
html = this.renderHandlebarsTemplate(html, variables)
text = this.renderHandlebarsTemplate(text, variables)
return { html, text }
}
}
}

View File

@@ -1,63 +1,20 @@
import {Payload, SendEmailOptions} from 'payload'
import { Payload } from 'payload'
import type { CollectionConfig, RichTextField } from 'payload'
import { Transporter } from 'nodemailer'
// Payload ID type (string or number)
export type PayloadID = string | number
// Payload relation type - can be populated (object with id) or unpopulated (just the ID)
export type PayloadRelation<T extends { id: PayloadID }> = T | PayloadID
// JSON value type that matches Payload's JSON field type
export type JSONValue = string | number | boolean | { [k: string]: unknown } | unknown[] | null | undefined
// Generic base interfaces that work with any ID type and null values
export interface BaseEmailDocument {
id: string | number
template?: any
templateSlug?: string | null
to: string[]
cc?: string[] | null
bcc?: string[] | null
from?: string | null
fromName?: string | null
replyTo?: string | null
export interface EmailObject {
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
from?: string
replyTo?: string
subject: string
html: string
text?: string | null
variables?: JSONValue
scheduledAt?: string | Date | null
sentAt?: string | Date | null
status?: 'pending' | 'processing' | 'sent' | 'failed' | null
attempts?: number | null
lastAttemptAt?: string | Date | null
error?: string | null
priority?: number | null
createdAt?: string | Date | null
updatedAt?: string | Date | null
text?: string
variables?: Record<string, any>
}
export interface BaseEmailTemplateDocument {
id: string | number
name: string
slug: string
subject?: string | null
content?: any
createdAt?: string | Date | null
updatedAt?: string | Date | null
}
export type TemplateRendererHook = (template: string, variables: Record<string, any>) => string | Promise<string>
export type TemplateEngine = 'liquidjs' | 'mustache' | 'simple'
export type BeforeSendHook = (options: SendEmailOptions, email: BaseEmailDocument) => SendEmailOptions | Promise<SendEmailOptions>
export interface JobPollingConfig {
maxAttempts?: number // Maximum number of polling attempts (default: 5)
initialDelay?: number // Initial delay in milliseconds (default: 25)
maxTotalTime?: number // Maximum total polling time in milliseconds (default: 3000)
maxBackoffDelay?: number // Maximum delay between attempts in milliseconds (default: 400)
}
export type EmailWrapperHook = (email: EmailObject) => EmailObject | Promise<EmailObject>
export interface MailingPluginConfig {
collections?: {
@@ -66,51 +23,80 @@ export interface MailingPluginConfig {
}
defaultFrom?: string
defaultFromName?: string
transport?: Transporter | MailingTransportConfig
queue?: string
retryAttempts?: number
retryDelay?: number
templateRenderer?: TemplateRendererHook
templateEngine?: TemplateEngine
emailWrapper?: EmailWrapperHook
richTextEditor?: RichTextField['editor']
beforeSend?: BeforeSendHook
onReady?: (payload: any) => Promise<void>
initOrder?: 'before' | 'after'
jobPolling?: JobPollingConfig
}
export interface QueuedEmail {
export interface MailingTransportConfig {
host: string
port: number
secure?: boolean
auth?: {
user: string
pass: string
}
}
export interface EmailTemplate {
id: string
template?: string | null
to: string[]
cc?: string[] | null
bcc?: string[] | null
from?: string | null
fromName?: string | null
replyTo?: string | null
name: string
slug: string
subject: string
html: string
text?: string | null
variables?: JSONValue
scheduledAt?: string | Date | null
sentAt?: string | Date | null
status: 'pending' | 'processing' | 'sent' | 'failed'
attempts: number
lastAttemptAt?: string | Date | null
error?: string | null
priority?: number | null
content: any // Lexical editor state
createdAt: string
updatedAt: string
}
// Simple helper type for template variables
export interface TemplateVariables {
[key: string]: any
export interface QueuedEmail {
id: string
template?: string
to: string[]
cc?: string[]
bcc?: 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 {
templateSlug?: 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>
processEmails(): Promise<void>
processEmailItem(emailId: string): Promise<void>
retryFailedEmails(): Promise<void>
renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }>
}
export interface MailingContext {

View File

@@ -1,7 +0,0 @@
declare module 'mustache' {
interface MustacheStatic {
render(template: string, view?: any, partials?: any, tags?: string[]): string
}
const mustache: MustacheStatic
export = mustache
}

View File

@@ -1,89 +0,0 @@
import type { Payload } from 'payload'
import { createContextLogger } from './logger.js'
/**
* Processes a single email by ID using the mailing service
* @param payload Payload instance
* @param emailId The ID of the email to process
* @returns Promise that resolves when email is processed
*/
export async function processEmailById(payload: Payload, emailId: string): Promise<void> {
// Get mailing context from payload
const mailingContext = (payload as any).mailing
if (!mailingContext) {
throw new Error(
'Mailing plugin not found on payload instance. ' +
'Ensure the mailingPlugin is properly configured in your Payload config plugins array.'
)
}
if (!mailingContext.service) {
throw new Error(
'Mailing service not available. ' +
'The plugin may not have completed initialization. ' +
'Check that email configuration is properly set up in your Payload config.'
)
}
// Process the specific email
await mailingContext.service.processEmailItem(emailId)
}
/**
* Processes a job immediately by finding and executing it
* @param payload Payload instance
* @param jobId The ID of the job to run immediately
* @returns Promise that resolves when job is processed
*/
export async function processJobById(payload: Payload, jobId: string): Promise<void> {
if (!payload.jobs) {
throw new Error('PayloadCMS jobs not configured - cannot process job immediately')
}
try {
// Run a specific job by its ID (using where clause to find the job)
const result = await payload.jobs.run({
where: {
id: {
equals: jobId
}
}
})
} catch (error) {
const logger = createContextLogger(payload, 'PROCESSOR')
logger.error(`Job ${jobId} execution failed:`, error)
throw new Error(`Failed to process job ${jobId}: ${String(error)}`)
}
}
/**
* Processes all pending and failed emails using the mailing service
* @param payload Payload instance
* @returns Promise that resolves when all emails are processed
*/
export async function processAllEmails(payload: Payload): Promise<void> {
// Get mailing context from payload
const mailingContext = (payload as any).mailing
if (!mailingContext) {
throw new Error(
'Mailing plugin not found on payload instance. ' +
'Ensure the mailingPlugin is properly configured in your Payload config plugins array.'
)
}
if (!mailingContext.service) {
throw new Error(
'Mailing service not available. ' +
'The plugin may not have completed initialization. ' +
'Check that email configuration is properly set up in your Payload config.'
)
}
// Process pending emails first
await mailingContext.service.processEmails()
// Then retry failed emails
await mailingContext.service.retryFailedEmails()
}

View File

@@ -1,121 +1,5 @@
import { Payload } from 'payload'
import { TemplateVariables, PayloadID, PayloadRelation } from '../types/index.js'
/**
* Parse and validate email addresses
* @internal
*/
export const parseAndValidateEmails = (emails: string | string[] | null | undefined): string[] | undefined => {
if (!emails || emails === null) return undefined
let emailList: string[]
if (Array.isArray(emails)) {
emailList = emails
} else {
emailList = emails.split(',').map(email => email.trim()).filter(Boolean)
}
// RFC 5322 compliant email validation
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
const invalidEmails = emailList.filter(email => {
// Check basic format
if (!emailRegex.test(email)) return true
// Check for common invalid patterns
if (email.includes('..') || email.startsWith('.') || email.endsWith('.')) return true
if (email.includes('@.') || email.includes('.@')) return true
// Check domain has at least one dot
const parts = email.split('@')
if (parts.length !== 2 || !parts[1].includes('.')) return true
return false
})
if (invalidEmails.length > 0) {
throw new Error(`Invalid email addresses: ${invalidEmails.join(', ')}`)
}
return emailList
}
/**
* Sanitize display names to prevent email header injection
* Removes newlines, carriage returns, and control characters
* @param displayName - The display name to sanitize
* @param escapeQuotes - Whether to escape quotes (for email headers)
* @returns Sanitized display name
*/
export const sanitizeDisplayName = (displayName: string, escapeQuotes = false): string => {
if (!displayName) return displayName
let sanitized = displayName
.trim()
// Remove/replace newlines and carriage returns to prevent header injection
.replace(/[\r\n]/g, ' ')
// Remove control characters (except space and printable characters)
.replace(/[\x00-\x1F\x7F-\x9F]/g, '')
// Escape quotes if needed (for email headers)
if (escapeQuotes) {
sanitized = sanitized.replace(/"/g, '\\"')
}
return sanitized
}
/**
* Sanitize and validate fromName for emails
* Wrapper around sanitizeDisplayName for consistent fromName handling
* @param fromName - The fromName to sanitize
* @returns Sanitized fromName or undefined if empty after sanitization
*/
export const sanitizeFromName = (fromName: string | null | undefined): string | undefined => {
if (!fromName) return undefined
const sanitized = sanitizeDisplayName(fromName, false)
return sanitized.length > 0 ? sanitized : undefined
}
/**
* Type guard to check if a Payload relation is populated (object) or unpopulated (ID)
*/
export const isPopulated = <T extends { id: PayloadID }>(
value: PayloadRelation<T> | null | undefined
): value is T => {
return value !== null && value !== undefined && typeof value === 'object' && 'id' in value
}
/**
* Resolves a Payload relation to just the ID
* Handles both populated (object with id) and unpopulated (string/number) values
*/
export const resolveID = <T extends { id: PayloadID }>(
value: PayloadRelation<T> | null | undefined
): PayloadID | undefined => {
if (value === null || value === undefined) return undefined
if (typeof value === 'string' || typeof value === 'number') {
return value
}
if (typeof value === 'object' && 'id' in value) {
return value.id
}
return undefined
}
/**
* Resolves an array of Payload relations to an array of IDs
* Handles mixed arrays of populated and unpopulated values
*/
export const resolveIDs = <T extends { id: PayloadID }>(
values: (PayloadRelation<T> | null | undefined)[] | null | undefined
): PayloadID[] => {
if (!values || !Array.isArray(values)) return []
return values
.map(value => resolveID(value))
.filter((id): id is PayloadID => id !== undefined)
}
import { SendEmailOptions } from '../types/index.js'
export const getMailing = (payload: Payload) => {
const mailing = (payload as any).mailing
@@ -125,56 +9,14 @@ export const getMailing = (payload: Payload) => {
return mailing
}
export const renderTemplate = async (payload: Payload, templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> => {
export const sendEmail = async (payload: Payload, options: SendEmailOptions): Promise<string> => {
const mailing = getMailing(payload)
return mailing.service.renderTemplate(templateSlug, variables)
return mailing.service.sendEmail(options)
}
/**
* Render a template and return both rendered content and template ID
* This is used by sendEmail to avoid duplicate template lookups
* @internal
*/
export const renderTemplateWithId = async (
payload: Payload,
templateSlug: string,
variables: TemplateVariables
): Promise<{ html: string; text: string; subject: string; templateId: PayloadID }> => {
export const scheduleEmail = async (payload: Payload, options: SendEmailOptions): Promise<string> => {
const mailing = getMailing(payload)
const templatesCollection = mailing.config.collections?.templates || 'email-templates'
// Runtime validation: Ensure the collection exists in Payload
if (!payload.collections[templatesCollection]) {
throw new Error(
`Templates collection '${templatesCollection}' not found. ` +
`Available collections: ${Object.keys(payload.collections).join(', ')}`
)
}
// Look up the template document once
const { docs: templateDocs } = await payload.find({
collection: templatesCollection as any,
where: {
slug: {
equals: templateSlug,
},
},
limit: 1,
})
if (!templateDocs || templateDocs.length === 0) {
throw new Error(`Template not found: ${templateSlug}`)
}
const templateDoc = templateDocs[0]
// Render using the document directly to avoid duplicate lookup
const rendered = await mailing.service.renderTemplateDocument(templateDoc, variables)
return {
...rendered,
templateId: templateDoc.id,
}
return mailing.service.scheduleEmail(options)
}
export const processEmails = async (payload: Payload): Promise<void> => {

View File

@@ -1,115 +0,0 @@
import { Payload } from 'payload'
import { JobPollingConfig } from '../types/index.js'
export interface PollForJobIdOptions {
payload: Payload
collectionSlug: string
emailId: string | number
config?: JobPollingConfig
logger?: {
debug: (message: string, ...args: any[]) => void
info: (message: string, ...args: any[]) => void
warn: (message: string, ...args: any[]) => void
error: (message: string, ...args: any[]) => void
}
}
export interface PollForJobIdResult {
jobId: string
attempts: number
elapsedTime: number
}
// Default job polling configuration values
const DEFAULT_JOB_POLLING_CONFIG: Required<JobPollingConfig> = {
maxAttempts: 5,
initialDelay: 25,
maxTotalTime: 3000,
maxBackoffDelay: 400,
}
/**
* Polls for a job ID associated with an email document using exponential backoff.
* This utility handles the complexity of waiting for auto-scheduled jobs to be created.
*
* The polling mechanism uses exponential backoff with configurable parameters:
* - Starts with an initial delay and doubles on each retry
* - Caps individual delays at maxBackoffDelay
* - Enforces a maximum total polling time
*
* @param options - Polling options including payload, collection, email ID, and config
* @returns Promise resolving to job ID and timing information
* @throws Error if job is not found within the configured limits
*/
export const pollForJobId = async (options: PollForJobIdOptions): Promise<PollForJobIdResult> => {
const { payload, collectionSlug, emailId, logger } = options
// Merge user config with defaults
const config: Required<JobPollingConfig> = {
...DEFAULT_JOB_POLLING_CONFIG,
...options.config,
}
const { maxAttempts, initialDelay, maxTotalTime, maxBackoffDelay } = config
const startTime = Date.now()
let jobId: string | undefined
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const elapsedTime = Date.now() - startTime
// Check if we've exceeded the maximum total polling time
if (elapsedTime > maxTotalTime) {
throw new Error(
`Job polling timed out after ${maxTotalTime}ms for email ${emailId}. ` +
`The auto-scheduling may have failed or is taking longer than expected.`
)
}
// Calculate exponential backoff delay, capped at maxBackoffDelay
const delay = Math.min(initialDelay * Math.pow(2, attempt), maxBackoffDelay)
// Wait before checking (skip on first attempt)
if (attempt > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}
// Fetch the email document to check for associated jobs
const emailWithJobs = await payload.findByID({
collection: collectionSlug,
id: emailId,
})
// Check if jobs array exists and has entries
if (emailWithJobs.jobs && emailWithJobs.jobs.length > 0) {
const firstJob = Array.isArray(emailWithJobs.jobs) ? emailWithJobs.jobs[0] : emailWithJobs.jobs
jobId = typeof firstJob === 'string' ? firstJob : String(firstJob.id || firstJob)
return {
jobId,
attempts: attempt + 1,
elapsedTime: Date.now() - startTime,
}
}
// Log progress for attempts after the second try
if (attempt >= 2 && logger) {
logger.debug(`Waiting for job creation for email ${emailId}, attempt ${attempt + 1}/${maxAttempts}`)
}
}
// If we reach here, job was not found
const elapsedTime = Date.now() - startTime
const timeoutMsg = elapsedTime >= maxTotalTime
const errorType = timeoutMsg ? 'POLLING_TIMEOUT' : 'JOB_NOT_FOUND'
const baseMessage = timeoutMsg
? `Job polling timed out after ${maxTotalTime}ms for email ${emailId}`
: `No processing job found for email ${emailId} after ${maxAttempts} attempts (${elapsedTime}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('${emailId}').`
)
}

View File

@@ -1,152 +0,0 @@
import type { Payload } from 'payload'
import { createContextLogger } from './logger.js'
/**
* Finds existing processing jobs for an email
*/
export async function findExistingJobs(
payload: Payload,
emailId: string | number
): Promise<{ docs: any[], totalDocs: number }> {
return await payload.find({
collection: 'payload-jobs',
where: {
'input.emailId': {
equals: String(emailId),
},
task: {
equals: 'process-email',
},
},
limit: 10,
})
}
/**
* Ensures a processing job exists for an email
* Creates one if it doesn't exist, or returns existing job IDs
*
* This function is idempotent and safe for concurrent calls:
* - Uses atomic check-and-create pattern with retry logic
* - Multiple concurrent calls will only create one job
* - Database-level uniqueness prevents duplicate jobs
* - Race conditions are handled with exponential backoff retry
*/
export async function ensureEmailJob(
payload: Payload,
emailId: string | number,
options?: {
scheduledAt?: string | Date
queueName?: string
}
): Promise<{ jobIds: (string | number)[], created: boolean }> {
if (!payload.jobs) {
throw new Error('PayloadCMS jobs not configured - cannot create email job')
}
const normalizedEmailId = String(emailId)
const mailingContext = (payload as any).mailing
const queueName = options?.queueName || mailingContext?.config?.queue || 'default'
const logger = createContextLogger(payload, 'JOB_SCHEDULER')
// First, optimistically try to create the job
// If it fails due to uniqueness constraint, then check for existing jobs
// This approach minimizes the race condition window
try {
// Attempt to create job - rely on database constraints for duplicate prevention
const job = await payload.jobs.queue({
queue: queueName,
task: 'process-email',
input: {
emailId: normalizedEmailId
},
waitUntil: options?.scheduledAt ? new Date(options.scheduledAt) : undefined
})
logger.info(`Auto-scheduled processing job ${job.id} for email ${normalizedEmailId}`)
return {
jobIds: [job.id],
created: true
}
} catch (createError) {
// Job creation failed - likely due to duplicate constraint or system issue
// Check if duplicate jobs exist (handles race condition where another process created job)
const existingJobs = await findExistingJobs(payload, normalizedEmailId)
if (existingJobs.totalDocs > 0) {
// Found existing jobs - return them (race condition handled successfully)
logger.debug(`Using existing jobs for email ${normalizedEmailId}: ${existingJobs.docs.map(j => j.id).join(', ')}`)
return {
jobIds: existingJobs.docs.map(job => job.id),
created: false
}
}
// No existing jobs found - this is a genuine error
// Enhanced error context for better debugging
const errorMessage = String(createError)
const isLikelyUniqueConstraint = errorMessage.toLowerCase().includes('duplicate') ||
errorMessage.toLowerCase().includes('unique') ||
errorMessage.toLowerCase().includes('constraint')
if (isLikelyUniqueConstraint) {
// This should not happen if our check above worked, but provide a clear error
logger.warn(`Unique constraint violation but no existing jobs found for email ${normalizedEmailId}`)
throw new Error(
`Database uniqueness constraint violation for email ${normalizedEmailId}, but no existing jobs found. ` +
`This indicates a potential data consistency issue. Original error: ${errorMessage}`
)
}
// Non-constraint related error
logger.error(`Job creation error for email ${normalizedEmailId}: ${errorMessage}`)
throw new Error(`Failed to create job for email ${normalizedEmailId}: ${errorMessage}`)
}
}
/**
* Updates an email document to include job IDs in the relationship field
*/
export async function updateEmailJobRelationship(
payload: Payload,
emailId: string | number,
jobIds: (string | number)[],
collectionSlug: string = 'emails'
): Promise<void> {
try {
const normalizedEmailId = String(emailId)
const normalizedJobIds = jobIds.map(id => String(id))
// Get current jobs to avoid overwriting
const currentEmail = await payload.findByID({
collection: collectionSlug,
id: normalizedEmailId,
})
// Extract IDs from job objects or use the value directly if it's already an ID
// Jobs can be populated (objects with id field) or just IDs (strings/numbers)
const currentJobs = (currentEmail.jobs || []).map((job: any) =>
typeof job === 'object' && job !== null && job.id ? String(job.id) : String(job)
)
const allJobs = [...new Set([...currentJobs, ...normalizedJobIds])] // Deduplicate with normalized strings
await payload.update({
collection: collectionSlug,
id: normalizedEmailId,
data: {
jobs: allJobs
}
})
} catch (error) {
const normalizedEmailId = String(emailId)
const logger = createContextLogger(payload, 'JOB_SCHEDULER')
logger.error(`Failed to update email ${normalizedEmailId} with job relationship:`, error)
throw error
}
}

View File

@@ -1,48 +0,0 @@
import type { Payload } from 'payload'
let pluginLogger: any = null
/**
* Get or create the plugin logger instance
* Uses PAYLOAD_MAILING_LOG_LEVEL environment variable to configure log level
* Defaults to 'info' if not set
*/
export function getPluginLogger(payload: Payload) {
if (!pluginLogger && payload.logger) {
const logLevel = process.env.PAYLOAD_MAILING_LOG_LEVEL || 'info'
pluginLogger = payload.logger.child({
level: logLevel,
plugin: '@xtr-dev/payload-mailing'
})
// Log the configured log level on first initialization
pluginLogger.info(`Logger initialized with level: ${logLevel}`)
}
// Fallback to console if logger not available (shouldn't happen in normal operation)
if (!pluginLogger) {
return {
debug: (...args: any[]) => console.log('[MAILING DEBUG]', ...args),
info: (...args: any[]) => console.log('[MAILING INFO]', ...args),
warn: (...args: any[]) => console.warn('[MAILING WARN]', ...args),
error: (...args: any[]) => console.error('[MAILING ERROR]', ...args),
}
}
return pluginLogger
}
/**
* Create a context-specific logger for a particular operation
*/
export function createContextLogger(payload: Payload, context: string) {
const logger = getPluginLogger(payload)
return {
debug: (message: string, ...args: any[]) => logger.debug(`[${context}] ${message}`, ...args),
info: (message: string, ...args: any[]) => logger.info(`[${context}] ${message}`, ...args),
warn: (message: string, ...args: any[]) => logger.warn(`[${context}] ${message}`, ...args),
error: (message: string, ...args: any[]) => logger.error(`[${context}] ${message}`, ...args),
}
}