Compare commits

...

39 Commits

Author SHA1 Message Date
Bas
685875d1b9 Merge pull request #28 from xtr-dev/dev
Dev
2025-09-13 23:11:16 +02:00
79044b7bc3 Remove unused BaseEmail imports
- Remove BaseEmail import from sendEmail.ts (no longer used after type refactoring)
- Remove BaseEmail import from sendEmailTask.ts (no longer used after type refactoring)
- BaseEmail types are still used in MailingService.ts for proper type casting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:10:20 +02:00
e7304fe1a2 Improve type safety, error handling, and code maintainability
- Simplify sendEmail generic constraints for better type safety
- Add validation before type assertions in sendEmail
- Preserve error stack traces in sendEmailTask error handling
- Extract field copying logic into reusable helper function
- Improve code documentation and separation of concerns

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:06:02 +02:00
790eedfee7 Bump package version to 0.1.12 in package.json. 2025-09-13 23:01:06 +02:00
9520ec5ed1 Refactor email types for enhanced consistency and type safety
- Replace `EmailTemplate` with `BaseEmailTemplate` for stricter type validation.
- Update `sendEmail` and `sendEmailTask` to utilize refined `BaseEmail` structure.
- Simplify type definitions in `MailingService` and related modules.
2025-09-13 23:00:41 +02:00
Bas
768b70a003 Merge pull request #27 from xtr-dev/dev
Align `sendEmail` and `sendEmailTask` with updated `BaseEmail` typing
2025-09-13 22:49:05 +02:00
e91ab7e54e Bump package version to 0.1.11 in package.json. 2025-09-13 22:48:55 +02:00
06f9c2cb5b Align sendEmail and sendEmailTask with updated BaseEmail typing
- Refactor `sendEmail` to return extended type with `id` for better type inference.
- Update `sendEmailTask` to use `BaseEmail` instead of `Email`.
- Add `outputSchema` in `sendEmailTask` for consistent output structure.
2025-09-13 22:46:30 +02:00
Bas
21b22a033a Merge pull request #26 from xtr-dev/dev
Refactor `sendEmail` to improve type safety and align with `BaseEmail…
2025-09-13 22:41:28 +02:00
6ad90874cf Refactor sendEmail to improve type safety and align with BaseEmail interface
- Replace `Email` with `BaseEmail` for stricter type validation.
- Update `SendEmailOptions` and `sendEmail` typing for improved extensibility.
2025-09-13 22:39:28 +02:00
Bas
03f1f62fbf Merge pull request #25 from xtr-dev/dev
Remove `emailWrapper` hook and all associated references.
2025-09-13 22:34:48 +02:00
e55e4197d3 Bump package version to 0.1.9 in package.json. 2025-09-13 22:32:19 +02:00
2e6feccf54 Remove emailWrapper hook and all associated references.
- Simplified email sending logic by dropping custom layout wrapping.
- Updated service, config, types, and readme to remove `emailWrapper` usage.
- Retained focus on core email functionality while ensuring consistent formatting.
2025-09-13 22:31:05 +02:00
Bas
e38b63d814 Merge pull request #24 from xtr-dev/dev
Dev
2025-09-13 22:00:51 +02:00
31721dc110 Add comment to clarify purpose of payload.config.ts 2025-09-13 22:00:29 +02:00
6e4f754306 Fix critical type safety and validation issues
Issue 2 - Type Safety:
- Remove dangerous 'as any' casts in sendEmail function
- Use proper typing for payload.create() calls
- Maintain type safety throughout email creation process

Issue 3 - Email Validation:
- Implement RFC 5322 compliant email regex
- Add comprehensive validation for common invalid patterns
- Check for consecutive dots, invalid domain formats
- Prevent emails like 'test@.com' and 'test@domain.'

Issue 4 - Error Message Logic:
- Add contextual error messages for template vs direct email modes
- Distinguish between template rendering failures and missing direct email content
- Provide clearer guidance to developers on what went wrong

Additional fixes:
- Update imports to use generated Email type instead of BaseEmailData
- Maintain compatibility with updated sendEmail interface

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 21:57:02 +02:00
45559804b0 Refactor email handling for improved type safety and extensibility
- Replace `BaseEmailData` with `Email` type for stricter type validation
- Update `sendEmail` API to support new typed structure and template integration
- Migrate to `@xtr-dev/payload-mailing` for enhanced email sending capabilities
- Remove unnecessary null checks and redundant code in email scheduling logic
- Regenerate PayloadCMS types for aligning with revised schema changes
- Update dev scripts and imports for seamless compatibility with the new email module
2025-09-13 21:51:52 +02:00
934b7c2de7 Fix ES module __dirname error in payload config
Resolves: ReferenceError: __dirname is not defined in ES module scope
- Import fileURLToPath from 'url' module
- Create __filename and __dirname using ES module pattern
- Maintains compatibility with TypeScript output file path resolution

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 21:50:05 +02:00
Bas
c78a8c2480 Merge pull request #23 from xtr-dev/dev
Fix TypeScript compatibility with PayloadCMS generated types
2025-09-13 21:10:09 +02:00
a27481c818 Bump package version to 0.1.7 in package.json. 2025-09-13 21:07:22 +02:00
b342f32d97 Simplify null checks in sendEmail validation logic 2025-09-13 21:06:54 +02:00
e1800f5a6e Fix TypeScript compatibility with PayloadCMS generated types
Resolves: TS2344: Type Email does not satisfy the constraint BaseEmailData
- Add null support to BaseEmailData interface for all optional fields
- Update parseAndValidateEmails to handle null values
- Update sendEmail validation to properly check for null values
- Maintain compatibility with PayloadCMS generated types that include null

Generated Email types like cc?: string[] | null | undefined now work correctly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 21:03:21 +02:00
Bas
0c4d894f51 Merge pull request #22 from xtr-dev/dev
Move sendEmail to dedicated file for better visibility
2025-09-13 20:58:13 +02:00
1af54c6573 Bump package version to 0.1.6 in package.json. 2025-09-13 20:55:00 +02:00
24f1f4c5a4 Fix broken development routes and imports
Critical fixes:
- Update dev/app/api/test-email/route.ts to use new sendEmail API instead of deprecated sendEmail/scheduleEmail
- Fix dev/test-plugin.mjs imports to remove scheduleEmail reference
- Update dev/README.md examples to use new sendEmail pattern
- Replace templateId with template.slug throughout dev examples
- Add support for direct HTML emails in test route

The development routes now work correctly with v0.1.5 API changes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:53:54 +02:00
de41f4ecb2 Move sendEmail to dedicated file for better visibility
- Extract sendEmail function to src/sendEmail.ts as primary module
- Export BaseEmailData and SendEmailOptions interfaces alongside
- Update all imports to use new location
- Add sendEmailDefault export for CommonJS compatibility
- Export parseAndValidateEmails for external utility use
- Updated README to highlight sendEmail as primary export

Breaking change: BaseEmailData and SendEmailOptions now imported from main module, not utils/helpers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:46:38 +02:00
Bas
6d4e020133 Merge pull request #21 from xtr-dev/dev
Dev
2025-09-13 20:39:44 +02:00
25838bcba4 Bump package version to 0.1.5 in package.json. 2025-09-13 20:37:20 +02:00
dfa833fa5e Eliminate code duplication between helpers and jobs
- Extract parseAndValidateEmails() as shared utility function
- Refactor sendEmailJob to use sendEmail helper internally
- Remove 100+ lines of duplicated validation and processing logic
- Maintain single source of truth for email handling logic
- Cleaner, more maintainable codebase with DRY principles

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:36:08 +02:00
cb5ce2e720 Add type-safe sendEmail helper with generics
- New sendEmail<T>() helper that extends BaseEmailData for full type safety
- Supports both template-based and direct HTML emails
- Automatic email validation and address parsing
- Merges template output with custom data fields
- Full TypeScript autocomplete for custom Email collection fields
- Updated README with comprehensive examples and API reference
- Exports BaseEmailData and SendEmailOptions types for external use

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:30:55 +02:00
f8b7dd8f4c Remove WIP comments from README 2025-09-13 20:23:53 +02:00
Bas
b3de54b953 Merge pull request #20 from xtr-dev/dev
Simplify job system architecture
2025-09-13 20:16:10 +02:00
186c340d96 Bump package version to 0.1.4 in package.json. 2025-09-13 20:14:59 +02:00
08b4d49019 Simplify job system architecture
- Replace createMailingJobs() function with static mailingJobs array
- Remove complex initialization dependencies and function wrappers
- Jobs now get MailingService from payload context instead of factory injection
- Fix PayloadCMS task handler return types to use proper {output: {}} format
- Eliminate potential initialization race conditions
- Cleaner, more straightforward job registration process

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:12:14 +02:00
Bas
ed058c0721 Merge pull request #19 from xtr-dev/dev
Dev
2025-09-13 19:23:08 +02:00
6db27093d1 Fix critical bugs and improve type safety
- Fix hard-coded collection name in sendEmailTask - now uses configurable collection name
- Add type validation for task input with proper error handling
- Add email format validation with regex to prevent invalid email addresses
- Fix potential memory leak in plugin initialization by properly initializing MailingService
- Add runtime validation for required fields
- Improve error messages and validation feedback

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 19:15:55 +02:00
43557c9a03 Consolidate and simplify email job system
- Replace inline plugin task with jobs directory system
- Move sendTemplateEmailTask to jobs/sendEmailTask.ts and integrate with createMailingJobs()
- Simplify processEmailsJob to always process both pending and failed emails in one task
- Remove separate 'retry-failed' task type - retry logic now runs automatically
- Update MailingService to support lazy initialization for job context
- Update exports to include consolidated job system

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 19:10:32 +02:00
2deefc8eaa FEATURE: Add PayloadCMS task for queuing template emails
- Add sendTemplateEmailTask with comprehensive input schema
- Support template rendering, email parsing, and scheduling
- Include TypeScript interface SendTemplateEmailInput for type safety
- Add task to exports for easy import and usage
- Support custom email collection fields via extensible input
- Add comprehensive documentation with usage examples

Users can now:
 Import and add task to their Payload jobs configuration
 Queue emails programmatically via payload.jobs.queue()
 Use admin panel form interface for manual email queuing
 Get full TypeScript support with proper input types
 Extend with custom fields from their email collection

Example usage:
```typescript
import { sendTemplateEmailTask } from '@xtr-dev/payload-mailing'

// Add to Payload config
export default buildConfig({
  jobs: { tasks: [sendTemplateEmailTask] }
})

// Queue from code
await payload.jobs.queue({
  task: 'send-template-email',
  input: {
    templateSlug: 'welcome',
    to: ['user@example.com'],
    variables: { name: 'John' }
  }
})
```

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:51:46 +02:00
12952ad41c Add pre-release warning to README
- Highlight active development status (v
2025-09-13 18:41:28 +02:00
20 changed files with 1210 additions and 620 deletions

468
README.md
View File

@@ -2,7 +2,7 @@
📧 **Template-based email system with scheduling and job processing for PayloadCMS** 📧 **Template-based email system with scheduling and job processing for PayloadCMS**
**Simplified API**: Starting from v0.1.0, this plugin uses a simplified API that leverages PayloadCMS collections directly for better type safety and flexibility. ⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
## Features ## Features
@@ -56,53 +56,55 @@ export default buildConfig({
}) })
``` ```
### 2. Send emails using Payload collections ### 2. Send emails with type-safe helper
```typescript ```typescript
import { renderTemplate } from '@xtr-dev/payload-mailing' // sendEmail is a primary export for easy access
import { sendEmail } from '@xtr-dev/payload-mailing'
import { Email } from './payload-types' // Your generated types
// Option 1: Using templates with variables // Option 1: Using templates with full type safety
const { html, text, subject } = await renderTemplate(payload, 'welcome-email', { const email = await sendEmail<Email>(payload, {
firstName: 'John', template: {
welcomeUrl: 'https://yoursite.com/welcome' slug: 'welcome-email',
}) variables: {
firstName: 'John',
// Create email using Payload's collection API (full type safety!) welcomeUrl: 'https://yoursite.com/welcome'
const email = await payload.create({ }
collection: 'emails', },
data: { data: {
to: ['user@example.com'], to: 'user@example.com',
subject,
html,
text,
// Schedule for later (optional) // Schedule for later (optional)
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
// Add any custom fields you've defined
priority: 1, priority: 1,
customField: 'your-value', // Your custom collection fields work! // Your custom fields are type-safe!
customField: 'your-value',
} }
}) })
// Option 2: Direct HTML email (no template needed) // Option 2: Direct HTML email (no template)
const directEmail = await payload.create({ const directEmail = await sendEmail<Email>(payload, {
collection: 'emails',
data: { data: {
to: ['user@example.com'], to: ['user@example.com', 'another@example.com'],
subject: 'Welcome!', subject: 'Welcome!',
html: '<h1>Welcome John!</h1><p>Thanks for joining!</p>', html: '<h1>Welcome John!</h1><p>Thanks for joining!</p>',
text: 'Welcome John! Thanks for joining!', text: 'Welcome John! Thanks for joining!',
// All your custom fields work with TypeScript autocomplete!
customField: 'value',
}
})
// Option 3: Use payload.create() directly for full control
const manualEmail = await payload.create({
collection: 'emails',
data: {
to: ['user@example.com'],
subject: 'Hello',
html: '<p>Hello World</p>',
} }
}) })
``` ```
## Why This Approach is Better
-**Full Type Safety**: Use your generated Payload types
-**No Learning Curve**: Just use `payload.create()` like any collection
-**Maximum Flexibility**: Add any custom fields to your email collection
-**Payload Integration**: Leverage validation, hooks, access control
-**Consistent API**: One way to create data in Payload
## Configuration ## Configuration
### Plugin Options ### Plugin Options
@@ -137,10 +139,6 @@ mailingPlugin({
retryDelay: 300000, // 5 minutes (default) retryDelay: 300000, // 5 minutes (default)
// Advanced options // Advanced options
emailWrapper: (email) => ({ // optional layout wrapper
...email,
html: `<html><body>${email.html}</body></html>`
}),
richTextEditor: lexicalEditor(), // optional custom editor richTextEditor: lexicalEditor(), // optional custom editor
onReady: async (payload) => { // optional initialization hook onReady: async (payload) => { // optional initialization hook
console.log('Mailing plugin ready!') console.log('Mailing plugin ready!')
@@ -225,7 +223,7 @@ Thanks for joining {{siteName}}. We're excited to have you!
**What you can do:** **What you can do:**
• Create beautiful emails with rich text formatting • Create beautiful emails with rich text formatting
• Use the emailWrapper hook to add custom layouts
• Queue and schedule emails effortlessly • Queue and schedule emails effortlessly
Your account was created on {{formatDate createdAt "long"}}. Your account was created on {{formatDate createdAt "long"}}.
@@ -236,282 +234,6 @@ The {{siteName}} Team
## Advanced Features ## Advanced Features
### Custom HTML Layouts with Email Wrapper Hook
The `emailWrapper` hook allows you to apply consistent HTML layouts and styling to all emails sent through the plugin. This is perfect for adding company branding, headers, footers, and responsive styling.
#### Basic Email Wrapper
```typescript
mailingPlugin({
// ... other config
emailWrapper: (email) => {
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: #f4f4f4; }
.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; line-height: 1.6; }
.footer { background: #f8f9fa; padding: 15px; text-align: center; color: #6c757d; font-size: 14px; }
/* Responsive styles */
@media only screen and (max-width: 600px) {
.container { margin: 0 10px; }
.content { padding: 20px; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>My Company</h1>
</div>
<div class="content">
${email.html}
</div>
<div class="footer">
© 2024 My Company. All rights reserved.<br>
<a href="#" style="color: #007bff;">Unsubscribe</a> |
<a href="#" style="color: #007bff;">Contact Support</a>
</div>
</div>
</body>
</html>
`
return {
...email,
html: wrappedHtml,
text: `MY COMPANY\n\n${email.text}\n\n© 2024 My Company\nUnsubscribe: [link] | Contact Support: [link]`
}
}
})
```
#### Advanced Email Wrapper with Dynamic Content
```typescript
mailingPlugin({
// ... other config
emailWrapper: (email) => {
// You can access email properties and customize based on content
const isTransactional = email.subject?.includes('Receipt') || email.subject?.includes('Confirmation');
const headerColor = isTransactional ? '#28a745' : '#007bff';
const headerText = isTransactional ? 'Order Confirmation' : 'My Company';
const wrappedHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>${email.subject}</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style>
/* Reset styles */
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
/* Base styles */
body {
margin: 0 !important;
padding: 0 !important;
background-color: #f4f4f4;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.email-header {
background: linear-gradient(135deg, ${headerColor}, ${headerColor}dd);
color: white;
padding: 30px 20px;
text-align: center;
}
.email-content {
padding: 30px;
color: #333333;
line-height: 1.6;
}
.email-footer {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
color: #6c757d;
font-size: 14px;
border-top: 1px solid #e9ecef;
}
.email-footer a {
color: ${headerColor};
text-decoration: none;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0 10px !important;
}
.email-header, .email-content {
padding: 20px !important;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h1 style="margin: 0; font-size: 24px;">${headerText}</h1>
</div>
<div class="email-content">
${email.html}
</div>
<div class="email-footer">
<p style="margin: 0 0 10px;">© ${new Date().getFullYear()} My Company. All rights reserved.</p>
<p style="margin: 0;">
<a href="mailto:support@mycompany.com">Contact Support</a> |
<a href="#">Privacy Policy</a> |
<a href="#">Unsubscribe</a>
</p>
</div>
</div>
</body>
</html>
`
// Also enhance the plain text version
const wrappedText = `
${headerText.toUpperCase()}
${'='.repeat(headerText.length)}
${email.text || email.html?.replace(/<[^>]*>/g, '')}
---
© ${new Date().getFullYear()} My Company. All rights reserved.
Contact Support: support@mycompany.com
Privacy Policy: [link]
Unsubscribe: [link]
`.trim();
return {
...email,
html: wrappedHtml,
text: wrappedText
}
}
})
```
#### External CSS and Assets
You can also reference external stylesheets and assets:
```typescript
mailingPlugin({
emailWrapper: (email) => {
const wrappedHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${email.subject}</title>
<!-- External CSS -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
/* Your custom styles here */
</style>
</head>
<body>
<div style="max-width: 600px; margin: 0 auto;">
<img src="https://mycompany.com/email-header.png" alt="My Company" style="width: 100%; height: auto;">
<div style="padding: 20px;">
${email.html}
</div>
<img src="https://mycompany.com/email-footer.png" alt="Footer" style="width: 100%; height: auto;">
</div>
</body>
</html>
`;
return { ...email, html: wrappedHtml };
}
})
```
#### Template-Specific Layouts
You can customize layouts based on email templates:
```typescript
mailingPlugin({
emailWrapper: (email, context) => {
// Access template information if available
const templateSlug = context?.templateSlug;
let layoutClass = 'default-layout';
let headerColor = '#007bff';
if (templateSlug === 'welcome-email') {
layoutClass = 'welcome-layout';
headerColor = '#28a745';
} else if (templateSlug === 'invoice-email') {
layoutClass = 'invoice-layout';
headerColor = '#dc3545';
}
const wrappedHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${email.subject}</title>
<style>
.${layoutClass} { /* template-specific styles */ }
.header { background-color: ${headerColor}; }
</style>
</head>
<body>
<div class="${layoutClass}">
${email.html}
</div>
</body>
</html>
`;
return { ...email, html: wrappedHtml };
}
})
```
The `emailWrapper` hook receives the email object with `html`, `text`, and `subject` properties, and should return the modified email object with your custom layout applied.
### Custom Rich Text Editor ### Custom Rich Text Editor
Override the rich text editor used for templates: Override the rich text editor used for templates:
@@ -598,6 +320,81 @@ await processEmails(payload)
await retryFailedEmails(payload) await retryFailedEmails(payload)
``` ```
## PayloadCMS Task Integration
The plugin provides a ready-to-use PayloadCMS task for queuing template emails:
### 1. Add the task to your Payload config
```typescript
import { buildConfig } from 'payload/config'
import { sendTemplateEmailTask } from '@xtr-dev/payload-mailing'
export default buildConfig({
// ... your config
jobs: {
tasks: [
sendTemplateEmailTask,
// ... your other tasks
]
}
})
```
### 2. Queue emails from your code
```typescript
import type { SendTemplateEmailInput } from '@xtr-dev/payload-mailing'
// Queue a template email
const result = await payload.jobs.queue({
task: 'send-template-email',
input: {
templateSlug: 'welcome-email',
to: ['user@example.com'],
cc: ['manager@example.com'],
variables: {
firstName: 'John',
activationUrl: 'https://example.com/activate/123'
},
priority: 1,
// Add any custom fields from your email collection
customField: 'value'
} as SendTemplateEmailInput
})
// Queue a scheduled email
await payload.jobs.queue({
task: 'send-template-email',
input: {
templateSlug: 'reminder-email',
to: ['user@example.com'],
variables: { eventName: 'Product Launch' },
scheduledAt: new Date('2024-01-15T10:00:00Z').toISOString(),
priority: 3
}
})
```
### 3. Use in admin panel workflows
The task can also be triggered from the Payload admin panel with a user-friendly form interface that includes:
- Template slug selection
- Email recipients (to, cc, bcc)
- Template variables as JSON
- Optional scheduling
- Priority setting
- Any custom fields you've added to your email collection
### Task Benefits
-**Easy Integration**: Just add to your tasks array
-**Type Safety**: Full TypeScript support with `SendTemplateEmailInput`
-**Admin UI**: Ready-to-use form interface
-**Flexible**: Supports all your custom email collection fields
-**Error Handling**: Comprehensive error reporting
-**Queue Management**: Leverage Payload's job queue system
## Job Processing ## Job Processing
The plugin automatically adds a unified email processing job to PayloadCMS: The plugin automatically adds a unified email processing job to PayloadCMS:
@@ -773,6 +570,55 @@ import {
} from '@xtr-dev/payload-mailing' } from '@xtr-dev/payload-mailing'
``` ```
## API Reference
### `sendEmail<T>(payload, options)`
Type-safe email sending with automatic template rendering and validation.
```typescript
import { sendEmail } from '@xtr-dev/payload-mailing'
import { Email } from './payload-types'
const email = await sendEmail<Email>(payload, {
template: {
slug: 'template-slug',
variables: { /* template variables */ }
},
data: {
to: 'user@example.com',
// Your custom fields are type-safe here!
}
})
```
**Type Parameters:**
- `T extends BaseEmailData` - Your generated Email type for full type safety
**Options:**
- `template.slug` - Template slug to render
- `template.variables` - Variables to pass to template
- `data` - Email data (merged with template output)
- `collectionSlug` - Custom collection name (defaults to 'emails')
### `renderTemplate(payload, slug, variables)`
Render an email template without sending.
```typescript
const { html, text, subject } = await renderTemplate(
payload,
'welcome-email',
{ name: 'John' }
)
```
### Helper Functions
- `getMailing(payload)` - Get mailing context
- `processEmails(payload)` - Manually trigger email processing
- `retryFailedEmails(payload)` - Manually retry failed emails
## Migration Guide (v0.0.x → v0.1.0) ## Migration Guide (v0.0.x → v0.1.0)
**🚨 BREAKING CHANGES**: The API has been simplified to use Payload collections directly. **🚨 BREAKING CHANGES**: The API has been simplified to use Payload collections directly.

View File

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

View File

@@ -1,37 +1,50 @@
import { getPayload } from 'payload' import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing' import { sendEmail } from '@xtr-dev/payload-mailing'
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
const body = await request.json() const body = await request.json()
const { type = 'send', templateSlug, to, variables, scheduledAt } = body const { type = 'send', templateSlug, to, variables, scheduledAt, subject, html, text } = body
let result // Use the new sendEmail API
if (type === 'send') { const emailOptions: any = {
// Send immediately data: {
result = await sendEmail(payload, {
templateSlug,
to, to,
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: 'Invalid type. Use "send" or "schedule"' }, { status: 400 })
} }
// 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
}
} else {
return Response.json({
error: 'Either templateSlug or subject+html must be provided'
}, { status: 400 })
}
// Add scheduling if needed
if (type === 'schedule' || scheduledAt) {
emailOptions.data.scheduledAt = scheduledAt ? new Date(scheduledAt) : new Date(Date.now() + 60000)
}
const result = await sendEmail(payload, emailOptions)
return Response.json({ return Response.json({
success: true, success: true,
emailId: result, emailId: result.id,
message: type === 'send' ? 'Email sent successfully' : 'Email scheduled successfully', message: scheduledAt ? 'Email scheduled successfully' : 'Email queued successfully',
}) })
} catch (error) { } catch (error) {
console.error('Test email error:', error) console.error('Test email error:', error)

View File

@@ -100,7 +100,8 @@ export interface Config {
}; };
jobs: { jobs: {
tasks: { tasks: {
'process-email-queue': ProcessEmailQueueJob; processEmails: ProcessEmailsJob;
'send-email': TaskSendEmail;
inline: { inline: {
input: unknown; input: unknown;
output: unknown; output: unknown;
@@ -232,21 +233,17 @@ export interface Email {
*/ */
template?: (string | null) | EmailTemplate; template?: (string | null) | EmailTemplate;
/** /**
* Template slug used for this email * Recipient email addresses
*/ */
templateSlug?: string | null; to: string[];
/** /**
* Recipient email address(es), comma-separated * CC email addresses
*/ */
to: string; cc?: string[] | null;
/** /**
* CC email address(es), comma-separated * BCC email addresses
*/ */
cc?: string | null; bcc?: string[] | null;
/**
* BCC email address(es), comma-separated
*/
bcc?: string | null;
/** /**
* Sender email address (optional, uses default if not provided) * Sender email address (optional, uses default if not provided)
*/ */
@@ -362,7 +359,7 @@ export interface PayloadJob {
| { | {
executedAt: string; executedAt: string;
completedAt: string; completedAt: string;
taskSlug: 'inline' | 'process-email-queue'; taskSlug: 'inline' | 'processEmails' | 'send-email';
taskID: string; taskID: string;
input?: input?:
| { | {
@@ -395,7 +392,7 @@ export interface PayloadJob {
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
taskSlug?: ('inline' | 'process-email-queue') | null; taskSlug?: ('inline' | 'processEmails' | 'send-email') | null;
queue?: string | null; queue?: string | null;
waitUntil?: string | null; waitUntil?: string | null;
processing?: boolean | null; processing?: boolean | null;
@@ -542,7 +539,6 @@ export interface EmailTemplatesSelect<T extends boolean = true> {
*/ */
export interface EmailsSelect<T extends boolean = true> { export interface EmailsSelect<T extends boolean = true> {
template?: T; template?: T;
templateSlug?: T;
to?: T; to?: T;
cc?: T; cc?: T;
bcc?: T; bcc?: T;
@@ -627,12 +623,69 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ProcessEmailQueueJob". * via the `definition` "ProcessEmailsJob".
*/ */
export interface ProcessEmailQueueJob { export interface ProcessEmailsJob {
input?: unknown; input?: unknown;
output?: unknown; output?: unknown;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TaskSend-email".
*/
export interface TaskSendEmail {
input: {
/**
* 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 date/time to schedule email for future delivery
*/
scheduledAt?: string | null;
/**
* Email priority (1 = highest, 10 = lowest)
*/
priority?: number | null;
};
output?: unknown;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth". * via the `definition` "auth".

View File

@@ -1,12 +1,10 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb' import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical' import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { import {
BlocksFeature,
FixedToolbarFeature, FixedToolbarFeature,
HeadingFeature, HeadingFeature,
HorizontalRuleFeature, HorizontalRuleFeature,
InlineToolbarFeature, InlineToolbarFeature,
lexicalHTML,
} from '@payloadcms/richtext-lexical' } from '@payloadcms/richtext-lexical'
import { MongoMemoryReplSet } from 'mongodb-memory-server' import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path' import path from 'path'
@@ -17,7 +15,7 @@ import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter.js' import { testEmailAdapter } from './helpers/testEmailAdapter.js'
import { seed, seedUser } from './seed.js' import { seed, seedUser } from './seed.js'
import mailingPlugin from "../src/plugin.js" import mailingPlugin from "../src/plugin.js"
import { sendEmail } from "../src/utils/helpers.js" import {sendEmail} from "@xtr-dev/payload-mailing"
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
@@ -85,15 +83,19 @@ const buildConfigWithMemoryDB = async () => {
// Queue the welcome email using template slug // Queue the welcome email using template slug
const emailId = await sendEmail(req.payload, { const emailId = await sendEmail(req.payload, {
templateSlug: 'welcome-email', template: {
to: doc.email, slug: 'welcome-email',
variables: { variables: {
firstName: doc.firstName || doc.email?.split('@')?.[0], firstName: doc.firstName || doc.email?.split('@')?.[0],
siteName: 'PayloadCMS Mailing Demo', siteName: 'PayloadCMS Mailing Demo',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
isPremium: false, isPremium: false,
dashboardUrl: 'http://localhost:3000/admin', dashboardUrl: 'http://localhost:3000/admin',
},
}, },
data: {
to: doc.email,
}
}) })
console.log('✅ Welcome email queued successfully. Email ID:', emailId) console.log('✅ Welcome email queued successfully. Email ID:', emailId)
@@ -280,56 +282,6 @@ const buildConfigWithMemoryDB = async () => {
], ],
}), }),
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 // Called after mailing plugin is fully initialized
onReady: async (payload) => { onReady: async (payload) => {

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-mailing", "name": "@xtr-dev/payload-mailing",
"version": "0.1.2", "version": "0.1.12",
"description": "Template-based email system with scheduling and job processing for PayloadCMS", "description": "Template-based email system with scheduling and job processing for PayloadCMS",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -23,8 +23,9 @@
"dev:generate-importmap": "npm run dev:payload generate:importmap", "dev:generate-importmap": "npm run dev:payload generate:importmap",
"dev:generate-types": "npm run dev:payload generate:types", "dev:generate-types": "npm run dev:payload generate:types",
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload", "dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
"generate:importmap": "npm run dev:generate-importmap", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"generate:types": "npm run dev:generate-types", "generate:importmap": "npm run payload generate:importmap",
"generate:types": "npm run payload generate:types",
"lint": "eslint", "lint": "eslint",
"lint:fix": "eslint ./src --fix", "lint:fix": "eslint ./src --fix",
"prepublishOnly": "npm run clean && npm run build", "prepublishOnly": "npm run clean && npm run build",

31
payload.config.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* This config is only used to generate types.
*/
import { BaseDatabaseAdapter, buildConfig, Payload} from 'payload'
import Emails from "./src/collections/Emails.js"
import {createEmailTemplatesCollection} from "./src/collections/EmailTemplates.js"
import path from "path"
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export default buildConfig({
collections: [
Emails,
createEmailTemplatesCollection()
],
db: {
allowIDOnCreate: undefined,
defaultIDType: 'number',
init: function (args: { payload: Payload; }): BaseDatabaseAdapter {
throw new Error('Function not implemented.');
},
name: undefined
},
secret: '',
typescript: {
outputFile: path.resolve(__dirname, 'src/payload-types.ts'),
}
});

View File

@@ -11,7 +11,13 @@ export { MailingService } from './services/MailingService.js'
export { default as EmailTemplates, createEmailTemplatesCollection } from './collections/EmailTemplates.js' export { default as EmailTemplates, createEmailTemplatesCollection } from './collections/EmailTemplates.js'
export { default as Emails } from './collections/Emails.js' export { default as Emails } from './collections/Emails.js'
// Jobs are integrated into the plugin configuration // Jobs (includes the send email task)
export { mailingJobs, sendEmailJob } from './jobs/index.js'
export type { SendEmailTaskInput } from './jobs/sendEmailTask.js'
// Main email sending function
export { sendEmail, type SendEmailOptions } from './sendEmail.js'
export { default as sendEmailDefault } from './sendEmail.js'
// Utility functions for developers // Utility functions for developers
export { export {
@@ -19,4 +25,5 @@ export {
renderTemplate, renderTemplate,
processEmails, processEmails,
retryFailedEmails, retryFailedEmails,
parseAndValidateEmails,
} from './utils/helpers.js' } from './utils/helpers.js'

View File

@@ -1,19 +1,35 @@
import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js' import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js'
import { sendEmailJob } from './sendEmailTask.js'
import { MailingService } from '../services/MailingService.js' import { MailingService } from '../services/MailingService.js'
export const createMailingJobs = (mailingService: MailingService): any[] => { export const mailingJobs = [
return [ {
{ slug: 'processEmails',
slug: 'processEmails', handler: async ({ job, req }: { job: any; req: any }) => {
handler: async ({ job, req }: { job: any; req: any }) => { // Get mailing context from payload
return processEmailsJob( const payload = (req as any).payload
job as { data: ProcessEmailsJobData }, const mailingContext = payload.mailing
{ req, mailingService } if (!mailingContext) {
) throw new Error('Mailing plugin not properly initialized')
}, }
interfaceName: 'ProcessEmailsJob',
},
]
}
export * from './processEmailsJob.js' // Use the existing mailing service from context
await processEmailsJob(
job as { data: ProcessEmailsJobData },
{ req, mailingService: mailingContext.service }
)
return {
output: {
success: true,
message: 'Email queue processing completed successfully'
}
}
},
interfaceName: 'ProcessEmailsJob',
},
sendEmailJob,
]
export * from './processEmailsJob.js'
export * from './sendEmailTask.js'

View File

@@ -2,7 +2,7 @@ import type { PayloadRequest } from 'payload'
import { MailingService } from '../services/MailingService.js' import { MailingService } from '../services/MailingService.js'
export interface ProcessEmailsJobData { export interface ProcessEmailsJobData {
type: 'process-emails' | 'retry-failed' // No type needed - always processes both pending and failed emails
} }
export const processEmailsJob = async ( export const processEmailsJob = async (
@@ -10,18 +10,19 @@ export const processEmailsJob = async (
context: { req: PayloadRequest; mailingService: MailingService } context: { req: PayloadRequest; mailingService: MailingService }
) => { ) => {
const { mailingService } = context const { mailingService } = context
const { type } = job.data
try { try {
if (type === 'process-emails') { console.log('🔄 Processing email queue (pending + failed emails)...')
await mailingService.processEmails()
console.log('Email processing completed successfully') // Process pending emails first
} else if (type === 'retry-failed') { await mailingService.processEmails()
await mailingService.retryFailedEmails()
console.log('Failed email retry completed successfully') // Then retry failed emails
} await mailingService.retryFailedEmails()
console.log('✅ Email queue processing completed successfully (pending and failed emails)')
} catch (error) { } catch (error) {
console.error(`${type} job failed:`, error) console.error('❌ Email queue processing failed:', error)
throw error throw error
} }
} }
@@ -29,7 +30,6 @@ export const processEmailsJob = async (
export const scheduleEmailsJob = async ( export const scheduleEmailsJob = async (
payload: any, payload: any,
queueName: string, queueName: string,
jobType: 'process-emails' | 'retry-failed',
delay?: number delay?: number
) => { ) => {
if (!payload.jobs) { if (!payload.jobs) {
@@ -41,10 +41,10 @@ export const scheduleEmailsJob = async (
await payload.jobs.queue({ await payload.jobs.queue({
queue: queueName, queue: queueName,
task: 'processEmails', task: 'processEmails',
input: { type: jobType }, input: {},
waitUntil: delay ? new Date(Date.now() + delay) : undefined, waitUntil: delay ? new Date(Date.now() + delay) : undefined,
}) })
} catch (error) { } catch (error) {
console.error(`Failed to schedule ${jobType} job:`, error) console.error('Failed to schedule email processing job:', error)
} }
} }

196
src/jobs/sendEmailTask.ts Normal file
View File

@@ -0,0 +1,196 @@
import { sendEmail } from '../sendEmail.js'
import {Email, EmailTemplate} from '../payload-types.js'
export interface SendEmailTaskInput {
// Template mode fields
templateSlug?: string
variables?: Record<string, any>
// Direct email mode fields
subject?: string
html?: string
text?: string
// Common fields
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
scheduledAt?: string // ISO date string
priority?: number
// Allow any additional fields that users might have in their email collection
[key: string]: any
}
/**
* Transforms task input into sendEmail options by separating template and data fields
*/
function transformTaskInputToSendEmailOptions(taskInput: SendEmailTaskInput) {
const sendEmailOptions: any = {
data: {}
}
// If using template mode, set template options
if (taskInput.templateSlug) {
sendEmailOptions.template = {
slug: taskInput.templateSlug,
variables: taskInput.variables || {}
}
}
// Standard email fields that should be copied to data
const standardFields = ['to', 'cc', 'bcc', 'subject', 'html', 'text', 'scheduledAt', 'priority']
// Template-specific fields that should not be copied to data
const templateFields = ['templateSlug', 'variables']
// Copy standard fields to data
standardFields.forEach(field => {
if (taskInput[field] !== undefined) {
sendEmailOptions.data[field] = taskInput[field]
}
})
// Copy any additional custom fields that aren't template or standard fields
Object.keys(taskInput).forEach(key => {
if (!templateFields.includes(key) && !standardFields.includes(key)) {
sendEmailOptions.data[key] = taskInput[key]
}
})
return sendEmailOptions
}
export const sendEmailJob = {
slug: 'send-email',
label: 'Send Email',
inputSchema: [
{
name: 'templateSlug',
type: 'text' as const,
label: 'Template Slug',
admin: {
description: 'Use a template (leave empty for direct email)',
condition: (data: any) => !data.subject && !data.html
}
},
{
name: 'variables',
type: 'json' as const,
label: 'Template Variables',
admin: {
description: 'JSON object with variables for template rendering',
condition: (data: any) => Boolean(data.templateSlug)
}
},
{
name: 'subject',
type: 'text' as const,
label: 'Subject',
admin: {
description: 'Email subject (required if not using template)',
condition: (data: any) => !data.templateSlug
}
},
{
name: 'html',
type: 'textarea' as const,
label: 'HTML Content',
admin: {
description: 'HTML email content (required if not using template)',
condition: (data: any) => !data.templateSlug
}
},
{
name: 'text',
type: 'textarea' as const,
label: 'Text Content',
admin: {
description: 'Plain text email content (optional)',
condition: (data: any) => !data.templateSlug
}
},
{
name: 'to',
type: 'text' as const,
required: true,
label: 'To (Email Recipients)',
admin: {
description: 'Comma-separated list of email addresses'
}
},
{
name: 'cc',
type: 'text' as const,
label: 'CC (Carbon Copy)',
admin: {
description: 'Optional comma-separated list of CC email addresses'
}
},
{
name: 'bcc',
type: 'text' as const,
label: 'BCC (Blind Carbon Copy)',
admin: {
description: 'Optional comma-separated list of BCC email addresses'
}
},
{
name: 'scheduledAt',
type: 'date' as const,
label: 'Schedule For',
admin: {
description: 'Optional date/time to schedule email for future delivery'
}
},
{
name: 'priority',
type: 'number' as const,
label: 'Priority',
min: 1,
max: 10,
defaultValue: 5,
admin: {
description: 'Email priority (1 = highest, 10 = lowest)'
}
}
],
outputSchema: [
{
name: 'id',
type: 'text' as const
}
],
handler: async ({ input, payload }: any) => {
// Cast input to our expected type
const taskInput = input as SendEmailTaskInput
try {
// Transform task input into sendEmail options using helper function
const sendEmailOptions = transformTaskInputToSendEmailOptions(taskInput)
// Use the sendEmail helper to create the email
const email = await sendEmail<Email>(payload, sendEmailOptions)
return {
output: {
success: true,
id: email.id,
}
}
} catch (error) {
if (error instanceof Error) {
// Preserve original error and stack trace
const wrappedError = new Error(`Failed to queue email: ${error.message}`)
wrappedError.stack = error.stack
wrappedError.cause = error
throw wrappedError
} else {
throw new Error(`Failed to queue email: ${String(error)}`)
}
}
}
}
export default sendEmailJob

431
src/payload-types.ts Normal file
View File

@@ -0,0 +1,431 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
emails: Email;
'email-templates': EmailTemplate;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
emails: EmailsSelect<false> | EmailsSelect<true>;
'email-templates': EmailTemplatesSelect<false> | EmailTemplatesSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* Email delivery and status tracking
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "emails".
*/
export interface Email {
id: number;
/**
* Email template used (optional if custom content provided)
*/
template?: (number | null) | EmailTemplate;
/**
* Recipient email addresses
*/
to: string[];
/**
* CC email addresses
*/
cc?: string[] | null;
/**
* BCC email addresses
*/
bcc?: string[] | null;
/**
* Sender email address (optional, uses default if not provided)
*/
from?: string | null;
/**
* Reply-to email address
*/
replyTo?: string | null;
/**
* Email subject line
*/
subject: string;
/**
* Rendered HTML content of the email
*/
html: string;
/**
* Plain text version of the email
*/
text?: string | null;
/**
* Template variables used to render this email
*/
variables?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* When this email should be sent (leave empty for immediate)
*/
scheduledAt?: string | null;
/**
* When this email was actually sent
*/
sentAt?: string | null;
/**
* Current status of this email
*/
status: 'pending' | 'processing' | 'sent' | 'failed';
/**
* Number of send attempts made
*/
attempts?: number | null;
/**
* When the last send attempt was made
*/
lastAttemptAt?: string | null;
/**
* Last error message if send failed
*/
error?: string | null;
/**
* Email priority (1=highest, 10=lowest)
*/
priority?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "email-templates".
*/
export interface EmailTemplate {
id: number;
/**
* A descriptive name for this email template
*/
name: string;
/**
* Unique identifier for this template (e.g., "welcome-email", "password-reset")
*/
slug: string;
/**
* Email subject line. You can use Handlebars variables like {{firstName}} or {{siteName}}.
*/
subject: string;
/**
* Email content with rich text formatting. Supports Handlebars variables like {{firstName}} and helpers like {{formatDate createdAt "long"}}. Content is converted to HTML and plain text automatically.
*/
content: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'emails';
value: number | Email;
} | null)
| ({
relationTo: 'email-templates';
value: number | EmailTemplate;
} | null)
| ({
relationTo: 'users';
value: number | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "emails_select".
*/
export interface EmailsSelect<T extends boolean = true> {
template?: T;
to?: T;
cc?: T;
bcc?: T;
from?: T;
replyTo?: T;
subject?: T;
html?: T;
text?: T;
variables?: T;
scheduledAt?: T;
sentAt?: T;
status?: T;
attempts?: T;
lastAttemptAt?: T;
error?: T;
priority?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "email-templates_select".
*/
export interface EmailTemplatesSelect<T extends boolean = true> {
name?: T;
slug?: T;
subject?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

View File

@@ -3,53 +3,8 @@ import { MailingPluginConfig, MailingContext } from './types/index.js'
import { MailingService } from './services/MailingService.js' import { MailingService } from './services/MailingService.js'
import { createEmailTemplatesCollection } from './collections/EmailTemplates.js' import { createEmailTemplatesCollection } from './collections/EmailTemplates.js'
import Emails from './collections/Emails.js' import Emails from './collections/Emails.js'
import { mailingJobs, scheduleEmailsJob } from './jobs/index.js'
// Helper function to schedule the email processing job
async function scheduleEmailProcessingJob(payload: any, queueName: string, delayMs: number = 60000): Promise<boolean> {
if (!queueName || typeof queueName !== 'string') {
throw new Error('Invalid queueName: must be a non-empty string')
}
const jobSlug = 'process-email-queue'
// Check if there's already a scheduled job for this task
const existingJobs = await payload.find({
collection: 'payload-jobs',
where: {
and: [
{
taskSlug: {
equals: jobSlug,
},
},
{
hasCompleted: {
equals: false,
},
},
],
},
limit: 1,
})
// If no existing job, schedule a new one
if (existingJobs.docs.length === 0) {
await payload.create({
collection: 'payload-jobs',
data: {
taskSlug: jobSlug,
input: {},
queue: queueName,
waitUntil: new Date(Date.now() + delayMs),
},
})
console.log(`🔄 Scheduled email processing job in queue: ${queueName}`)
return true
} else {
console.log(`✅ Email processing job already scheduled in queue: ${queueName}`)
return false
}
}
export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => { export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
const queueName = pluginConfig.queue || 'default' const queueName = pluginConfig.queue || 'default'
@@ -59,6 +14,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
throw new Error('Invalid queue configuration: queue must be a non-empty string') throw new Error('Invalid queue configuration: queue must be a non-empty string')
} }
// Handle templates collection configuration // Handle templates collection configuration
const templatesConfig = pluginConfig.collections?.templates const templatesConfig = pluginConfig.collections?.templates
const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates' const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates'
@@ -129,61 +85,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
...(config.jobs || {}), ...(config.jobs || {}),
tasks: [ tasks: [
...(config.jobs?.tasks || []), ...(config.jobs?.tasks || []),
{ ...mailingJobs,
slug: 'process-email-queue',
handler: async ({ job, req }: { job: any; req: any }) => {
const payload = (req as any).payload
let jobResult = null
try {
const mailingService = new MailingService(payload, pluginConfig)
console.log('🔄 Processing email queue (pending + failed emails)...')
// Process pending emails first
await mailingService.processEmails()
// Then retry failed emails
await mailingService.retryFailedEmails()
jobResult = {
output: {
success: true,
message: 'Email queue processed successfully (pending and failed emails)'
}
}
console.log('✅ Email queue processing completed successfully')
} catch (error) {
console.error('❌ Error processing email queue:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
jobResult = new Error(`Email queue processing failed: ${errorMessage}`)
}
// Always reschedule the next job (success or failure) using duplicate prevention
let rescheduled = false
try {
rescheduled = await scheduleEmailProcessingJob(payload, queueName, 300000) // Reschedule in 5 minutes
if (rescheduled) {
console.log(`🔄 Rescheduled next email processing job in ${queueName} queue`)
}
} catch (rescheduleError) {
console.error('❌ Failed to reschedule email processing job:', rescheduleError)
// If rescheduling fails, we should warn but not fail the current job
// since the email processing itself may have succeeded
console.warn('⚠️ Email processing completed but next job could not be scheduled')
}
// Return the original result or throw the error
if (jobResult instanceof Error) {
throw jobResult
}
return jobResult
},
interfaceName: 'ProcessEmailQueueJob',
},
], ],
}, },
onInit: async (payload: any) => { onInit: async (payload: any) => {
@@ -191,7 +93,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
await config.onInit(payload) await config.onInit(payload)
} }
// Initialize mailing service // Initialize mailing service with proper payload instance
const mailingService = new MailingService(payload, pluginConfig) const mailingService = new MailingService(payload, pluginConfig)
// Add mailing context to payload for developer access // Add mailing context to payload for developer access
@@ -207,9 +109,10 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
console.log('PayloadCMS Mailing Plugin initialized successfully') console.log('PayloadCMS Mailing Plugin initialized successfully')
// Schedule the email processing job if not already scheduled // Schedule the initial email processing job
try { try {
await scheduleEmailProcessingJob(payload, queueName) await scheduleEmailsJob(payload, queueName, 60000) // Schedule in 1 minute
console.log(`🔄 Scheduled initial email processing job in queue: ${queueName}`)
} catch (error) { } catch (error) {
console.error('Failed to schedule email processing job:', error) console.error('Failed to schedule email processing job:', error)
} }

107
src/sendEmail.ts Normal file
View File

@@ -0,0 +1,107 @@
import { Payload } from 'payload'
import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js'
import {Email, EmailTemplate} from "./payload-types.js"
// Options for sending emails
export interface SendEmailOptions<T extends Email = Email> {
// Template-based email
template?: {
slug: string
variables?: Record<string, any>
}
// Direct email data
data?: Partial<T>
// Common options
collectionSlug?: string // defaults to 'emails'
}
/**
* 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 Email = Email>(
payload: Payload,
options: SendEmailOptions<TEmail>
): Promise<TEmail> => {
const mailing = getMailing(payload)
const collectionSlug = options.collectionSlug || mailing.collections.emails || 'emails'
let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail>
// If using a template, render it first
if (options.template) {
const { html, text, subject } = await renderTemplate(
payload,
options.template.slug,
options.template.variables || {}
)
// Template values take precedence over data values
emailData = {
...emailData,
subject,
html,
text,
} as Partial<TEmail>
}
// Validate required fields
if (!emailData.to) {
throw new Error('Field "to" is required for sending emails')
}
// Validate required fields based on whether template was used
if (options.template) {
// When using template, subject and html should have been set by renderTemplate
if (!emailData.subject || !emailData.html) {
throw new Error(`Template rendering failed: template "${options.template.slug}" did not provide required subject and html content`)
}
} else {
// When not using template, user must provide subject and html directly
if (!emailData.subject || !emailData.html) {
throw new Error('Fields "subject" and "html" are required when sending direct emails without a template')
}
}
// Process email addresses using shared validation (handle null values)
if (emailData.to) {
emailData.to = parseAndValidateEmails(emailData.to 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[])
}
// Create the email in the collection with proper typing
const email = await payload.create({
collection: collectionSlug,
data: emailData
})
// Validate that the created email has the expected structure
if (!email || typeof email !== 'object' || !email.id) {
throw new Error('Failed to create email: invalid response from database')
}
return email as TEmail
}
export default sendEmail

View File

@@ -5,20 +5,19 @@ import {
MailingPluginConfig, MailingPluginConfig,
TemplateVariables, TemplateVariables,
MailingService as IMailingService, MailingService as IMailingService,
EmailTemplate,
QueuedEmail,
MailingTransportConfig, MailingTransportConfig,
EmailObject BaseEmail, BaseEmailTemplate
} from '../types/index.js' } from '../types/index.js'
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js' import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
export class MailingService implements IMailingService { export class MailingService implements IMailingService {
private payload: Payload public payload: Payload
private config: MailingPluginConfig private config: MailingPluginConfig
private transporter!: Transporter | any private transporter!: Transporter | any
private templatesCollection: string private templatesCollection: string
private emailsCollection: string private emailsCollection: string
private liquid: Liquid | null | false = null private liquid: Liquid | null | false = null
private transporterInitialized = false
constructor(payload: Payload, config: MailingPluginConfig) { constructor(payload: Payload, config: MailingPluginConfig) {
this.payload = payload this.payload = payload
@@ -30,10 +29,15 @@ export class MailingService implements IMailingService {
const emailsConfig = config.collections?.emails const emailsConfig = config.collections?.emails
this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails' this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails'
this.initializeTransporter() // Only initialize transporter if payload is properly set
if (payload && payload.db) {
this.initializeTransporter()
}
} }
private initializeTransporter(): void { private initializeTransporter(): void {
if (this.transporterInitialized) return
if (this.config.transport) { if (this.config.transport) {
if ('sendMail' in this.config.transport) { if ('sendMail' in this.config.transport) {
this.transporter = this.config.transport this.transporter = this.config.transport
@@ -46,6 +50,17 @@ export class MailingService implements IMailingService {
} else { } else {
throw new Error('Email transport configuration is required either in plugin config or Payload config') throw new Error('Email transport configuration is required either in plugin config or Payload config')
} }
this.transporterInitialized = true
}
private ensureInitialized(): void {
if (!this.payload || !this.payload.db) {
throw new Error('MailingService payload not properly initialized')
}
if (!this.transporterInitialized) {
this.initializeTransporter()
}
} }
private getDefaultFrom(): string { private getDefaultFrom(): string {
@@ -108,6 +123,7 @@ export class MailingService implements IMailingService {
} }
async renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> { async renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> {
this.ensureInitialized()
const template = await this.getTemplateBySlug(templateSlug) const template = await this.getTemplateBySlug(templateSlug)
if (!template) { if (!template) {
@@ -125,6 +141,7 @@ export class MailingService implements IMailingService {
} }
async processEmails(): Promise<void> { async processEmails(): Promise<void> {
this.ensureInitialized()
const currentTime = new Date().toISOString() const currentTime = new Date().toISOString()
const { docs: pendingEmails } = await this.payload.find({ const { docs: pendingEmails } = await this.payload.find({
@@ -162,6 +179,7 @@ export class MailingService implements IMailingService {
} }
async retryFailedEmails(): Promise<void> { async retryFailedEmails(): Promise<void> {
this.ensureInitialized()
const maxAttempts = this.config.retryAttempts || 3 const maxAttempts = this.config.retryAttempts || 3
const retryDelay = this.config.retryDelay || 300000 // 5 minutes const retryDelay = this.config.retryDelay || 300000 // 5 minutes
const retryTime = new Date(Date.now() - retryDelay).toISOString() const retryTime = new Date(Date.now() - retryDelay).toISOString()
@@ -218,10 +236,10 @@ export class MailingService implements IMailingService {
const email = await this.payload.findByID({ const email = await this.payload.findByID({
collection: this.emailsCollection as any, collection: this.emailsCollection as any,
id: emailId, id: emailId,
}) as QueuedEmail }) as BaseEmail
let emailObject: EmailObject = { const mailOptions = {
from: email.from || this.getDefaultFrom(), from: email.from,
to: email.to, to: email.to,
cc: email.cc || undefined, cc: email.cc || undefined,
bcc: email.bcc || undefined, bcc: email.bcc || undefined,
@@ -229,23 +247,6 @@ export class MailingService implements IMailingService {
subject: email.subject, subject: email.subject,
html: email.html, html: email.html,
text: email.text || undefined, text: email.text || undefined,
variables: email.variables,
}
// Apply emailWrapper hook if configured
if (this.config.emailWrapper) {
emailObject = await this.config.emailWrapper(emailObject)
}
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,
} }
await this.transporter.sendMail(mailOptions) await this.transporter.sendMail(mailOptions)
@@ -284,7 +285,7 @@ export class MailingService implements IMailingService {
const email = await this.payload.findByID({ const email = await this.payload.findByID({
collection: this.emailsCollection as any, collection: this.emailsCollection as any,
id: emailId, id: emailId,
}) as QueuedEmail }) as BaseEmail
const newAttempts = (email.attempts || 0) + 1 const newAttempts = (email.attempts || 0) + 1
@@ -299,7 +300,7 @@ export class MailingService implements IMailingService {
return newAttempts return newAttempts
} }
private async getTemplateBySlug(templateSlug: string): Promise<EmailTemplate | null> { private async getTemplateBySlug(templateSlug: string): Promise<BaseEmailTemplate | null> {
try { try {
const { docs } = await this.payload.find({ const { docs } = await this.payload.find({
collection: this.templatesCollection as any, collection: this.templatesCollection as any,
@@ -311,7 +312,7 @@ export class MailingService implements IMailingService {
limit: 1, limit: 1,
}) })
return docs.length > 0 ? docs[0] as EmailTemplate : null return docs.length > 0 ? docs[0] as BaseEmailTemplate : null
} catch (error) { } catch (error) {
console.error(`Template with slug '${templateSlug}' not found:`, error) console.error(`Template with slug '${templateSlug}' not found:`, error)
return null return null
@@ -376,7 +377,7 @@ export class MailingService implements IMailingService {
}) })
} }
private async renderEmailTemplate(template: EmailTemplate, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> { private async renderEmailTemplate(template: BaseEmailTemplate, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
if (!template.content) { if (!template.content) {
return { html: '', text: '' } return { html: '', text: '' }
} }

View File

@@ -1,20 +1,11 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import type { CollectionConfig, RichTextField, TypedCollection } from 'payload' import type { CollectionConfig, RichTextField } from 'payload'
import { Transporter } from 'nodemailer' import { Transporter } from 'nodemailer'
import {Email, EmailTemplate} from "../payload-types.js"
export interface EmailObject { export type BaseEmail<TEmail extends Email = Email, TEmailTemplate extends EmailTemplate = EmailTemplate> = Omit<TEmail, 'id' | 'template'> & {template: Omit<TEmailTemplate, 'id'> | TEmailTemplate['id'] | undefined | null}
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
from?: string
replyTo?: string
subject: string
html: string
text?: string
variables?: Record<string, any>
}
export type EmailWrapperHook = (email: EmailObject) => EmailObject | Promise<EmailObject> export type BaseEmailTemplate<TEmailTemplate extends EmailTemplate = EmailTemplate> = Omit<TEmailTemplate, 'id'>
export type TemplateRendererHook = (template: string, variables: Record<string, any>) => string | Promise<string> export type TemplateRendererHook = (template: string, variables: Record<string, any>) => string | Promise<string>
@@ -31,7 +22,6 @@ export interface MailingPluginConfig {
queue?: string queue?: string
retryAttempts?: number retryAttempts?: number
retryDelay?: number retryDelay?: number
emailWrapper?: EmailWrapperHook
templateRenderer?: TemplateRendererHook templateRenderer?: TemplateRendererHook
templateEngine?: TemplateEngine templateEngine?: TemplateEngine
richTextEditor?: RichTextField['editor'] richTextEditor?: RichTextField['editor']
@@ -49,16 +39,6 @@ export interface MailingTransportConfig {
} }
} }
export interface EmailTemplate {
id: string
name: string
slug: string
subject: string
content: any // Lexical editor state
createdAt: string
updatedAt: string
}
export interface QueuedEmail { export interface QueuedEmail {
id: string id: string

View File

@@ -1,6 +1,41 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import { TemplateVariables } from '../types/index.js' import { TemplateVariables } 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
}
export const getMailing = (payload: Payload) => { export const getMailing = (payload: Payload) => {
const mailing = (payload as any).mailing const mailing = (payload as any).mailing
if (!mailing) { if (!mailing) {