Compare commits

...

66 Commits

Author SHA1 Message Date
Bas
d82b3f2276 Merge pull request #45 from xtr-dev/dev
Fix TypeScript compilation error in MailingService
2025-09-14 20:03:42 +02:00
08f017abed Bump version to 0.4.5 2025-09-14 20:03:34 +02:00
af9c5a1e1b Fix TypeScript compilation error in MailingService
- Replace unsafe BaseEmail type cast with proper type handling
- Fix error TS2352: Conversion of type 'JsonObject & TypeWithID' to type 'BaseEmail'
- Use targeted (email as any).attempts instead of full object cast
- Maintains functionality while resolving type safety issues

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 20:02:08 +02:00
Bas
05f4cd0d7c Merge pull request #44 from xtr-dev/dev
Dev
2025-09-14 20:00:46 +02:00
22190f38fd Fix critical type safety and validation issues
- Replace unsafe (payload as any).mailing with proper type checking
- Add validation for required fields (to, templateSlug/subject+html)
- Return proper 400 status codes for invalid requests
- Improve type safety without breaking existing functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 19:56:19 +02:00
1ba770942d Bump version to 0.4.4 2025-09-14 19:53:53 +02:00
7f73fa5efc Add SQLite database files to gitignore
- Remove dev.db and dev/dev.db from git tracking
- Update .gitignore to exclude SQLite development databases
- Prevent local database files from being committed

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 19:49:28 +02:00
8993d20526 Migrate dev server from MongoDB to SQLite
- Replace mongooseAdapter with sqliteAdapter in payload config
- Update database configuration to use file:./dev.db
- Remove MongoDB memory database helper and references
- Simplify start script by removing verbose logging and MongoDB messaging
- Fix email processing with immediate send support and proper queue handling
- Restructure app with route groups for frontend/admin separation
- Add dashboard and test pages for email management
- Update API routes for improved email processing and testing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 19:48:45 +02:00
Bas
bba223410d Merge pull request #43 from xtr-dev/dev
Remove verbose initialization logs
2025-09-14 18:36:19 +02:00
0d295603ef Update development documentation for silent plugin initialization
- Remove reference to removed 'PayloadCMS Mailing Plugin initialized successfully' log
- Add note explaining that plugin initializes silently on success
- Clarify that absence of errors indicates successful initialization
- Keep documentation aligned with actual plugin behavior
- Bump version to 0.4.3
2025-09-14 18:33:34 +02:00
bd1842d45c Remove verbose initialization logs
- Remove 'PayloadCMS Mailing Plugin initialized successfully' log
- Remove 'Scheduled initial email processing job in queue' log
- Keep error logging for failed job scheduling
- Reduce console noise during plugin initialization
- Bump version to 0.4.2
2025-09-14 18:18:20 +02:00
Bas
a40d87c63c Merge pull request #42 from xtr-dev/dev
BREAKING CHANGE: Remove sendEmailWorkflow, add immediate processing t…
2025-09-14 18:07:19 +02:00
ccd8ef35c3 Fix error handling and improve error messages
- Fix inconsistent error handling in sendEmailTask by re-throwing original Error instances
- Preserve stack traces and error context instead of creating new Error wrappers
- Improve generic error messages in emailProcessor utilities with specific details
- Add actionable guidance for common configuration issues
- Help developers understand what went wrong and how to fix it
- Bump version to 0.4.1
2025-09-14 18:00:23 +02:00
a12d4c1bee BREAKING CHANGE: Remove sendEmailWorkflow, add immediate processing to sendEmailTask
- Remove entire workflows directory and sendEmailWorkflow
- Factor out email processing logic into reusable utilities (emailProcessor.ts)
- Add processImmediately option to sendEmailTask input schema
- Update sendEmailTask to process emails immediately when requested
- Update processEmailsTask to use shared processing utilities
- Remove workflow-related exports and plugin configuration
- Simplify documentation to focus on unified task approach
- Export new email processing utilities (processEmailById, processAllEmails)
- Bump version to 0.4.0 (breaking change - workflows removed)

Migration: Use sendEmailTask with processImmediately: true instead of sendEmailWorkflow
2025-09-14 17:53:29 +02:00
Bas
fde8eb538d Merge pull request #41 from xtr-dev/dev
Dev
2025-09-14 17:47:19 +02:00
845b379da3 Fix error handling and improve naming consistency
- Use native error chaining in workflow (Error constructor with cause option)
- Fix job scheduling to use 'task' instead of 'workflow' property
- Rename processEmailsJob.ts to processEmailsTask.ts for consistency
- Update all imports and references while maintaining backward compatibility
- Add processEmailsTask export with processEmailsJob alias
- Bump version to 0.3.1
2025-09-14 17:34:53 +02:00
dd205dba41 Make workflow documentation more concise
- Simplified workflow section to focus on key advantage
- Removed verbose comparison table and features list
- Kept essential usage example with processImmediately option
- More readable and focused on the main differentiator
2025-09-14 17:21:57 +02:00
a6564e2a29 Add sendEmail workflow with immediate processing option
- Create sendEmailWorkflow as a Payload workflow alternative to task
- Add processImmediately option (disabled by default) to send emails immediately
- Expose processEmailItem method in MailingService for individual email processing
- Add comprehensive input schema with conditional fields
- Update plugin to register both tasks and workflows
- Add detailed documentation comparing tasks vs workflows
- Includes status tracking and error handling
- Bump version to 0.3.0 (new feature)
2025-09-14 17:20:21 +02:00
8f200da449 Refactor and clean up job organization
- Properly encapsulate processEmailsJob in its own file with handler and definition
- Clean up index.ts to remove duplicate code and just export job definitions
- Add comprehensive JSDoc comments for better documentation
- Separate job handler logic from job definition for clarity
- Fix job scheduling to use correct field names
- Bump version to 0.2.1
2025-09-14 17:16:01 +02:00
Bas
ff94d72d49 Merge pull request #40 from xtr-dev/dev
BREAKING CHANGE: Remove custom transport support, use Payload's email…
2025-09-14 17:02:50 +02:00
ddee7d5a76 BREAKING CHANGE: Remove custom transport support, use Payload's email config
- Removed custom transport configuration from plugin
- Plugin now requires Payload email to be configured
- Simplified setup by relying on Payload's email adapter
- Updated README with new configuration requirements
- Bump version to 0.2.0 (breaking change)

Users must now configure email in their Payload config using an email adapter
like @payloadcms/email-nodemailer instead of configuring transport in the plugin.
2025-09-14 16:57:30 +02:00
Bas
0083e8e1fa Merge pull request #39 from xtr-dev/dev
Remove redundant queueName validation and debug log, bump version to …
2025-09-14 16:29:13 +02:00
63a7eef8d8 Remove redundant queueName validation and debug log, bump version to 0.1.24 2025-09-14 16:28:24 +02:00
Bas
6cf055178b Merge pull request #38 from xtr-dev/dev
Add debug log for email transporter configuration and bump version to…
2025-09-14 16:15:41 +02:00
aa978090fa Add debug log for email transporter configuration and bump version to 0.1.23 2025-09-14 16:14:56 +02:00
Bas
556d910e30 Merge pull request #37 from xtr-dev/dev
Remove conditional transporter initialization and bump version to 0.1.22
2025-09-14 13:53:32 +02:00
b4bad70634 Remove conditional transporter initialization and bump version to 0.1.22 2025-09-14 13:52:49 +02:00
Bas
efdfaf5889 Merge pull request #36 from xtr-dev/dev
Add beforeSend hook for email customization
2025-09-14 12:37:38 +02:00
ea7d8dfdd5 Add validation for beforeSend hook to ensure required properties remain intact
- Validate that 'from' field is not removed
- Validate that 'to' field is not removed or emptied
- Validate that 'subject' field is not removed
- Validate that at least 'html' or 'text' content exists
- Throw clear error messages if validation fails
- Bump version to 0.1.21
2025-09-14 12:27:43 +02:00
0d6d07de85 Add beforeSend hook for email customization
- Add BeforeSendHook type and BeforeSendMailOptions interface
- Implement hook execution in MailingService before sending emails
- Hook allows adding attachments, headers, and modifying email options
- Add comprehensive documentation with examples
- Bump version to 0.1.20
2025-09-14 12:19:52 +02:00
Bas
f12ac8172e Merge pull request #35 from xtr-dev/dev
Fix model overwrite error when plugin is initialized multiple times
2025-09-14 10:24:58 +02:00
347cd33e13 Fix model overwrite error when plugin is initialized multiple times
- Filter out existing collections with same slugs before adding plugin collections
- Prevents 'Cannot overwrite model once compiled' errors in Next.js apps
- Fixes issue during hot reload and multiple getPayload() calls
- Bump version to 0.1.19
2025-09-14 10:22:34 +02:00
Bas
672ab3236a Merge pull request #34 from xtr-dev/dev
Add fromName field support to emails collection
2025-09-14 00:10:22 +02:00
c7db65980a Fix security vulnerabilities in fromName field handling
- Add sanitizeDisplayName() method to prevent header injection attacks
- Remove newlines, carriage returns, and control characters from display names
- Fix quote escaping inconsistency between getDefaultFrom() and processEmailItem()
- Create formatEmailAddress() helper method for consistent email formatting
- Add fromName sanitization in sendEmail() function for input validation
- Prevent malformed email headers and potential security issues

Security improvements:
- Header injection prevention (removes \r\n and control characters)
- Consistent quote escaping across all display name usage
- Proper sanitization at both input and output stages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 00:07:53 +02:00
624dc12471 Bump package version to 0.1.18 in package.json. 2025-09-14 00:06:14 +02:00
e20ebe27bf Add fromName field support to emails collection
- Add fromName field to Emails collection schema for sender display name
- Update BaseEmailDocument and QueuedEmail interfaces to include fromName
- Add SendEmailTaskInput support for fromName field in job tasks
- Update MailingService to combine fromName and from into proper "Name <email>" format
- Add fromName, from, and replyTo fields to job input schema for admin UI
- Update field copying logic to handle new sender-related fields

Users can now specify a display name for emails (e.g., "John Doe <john@example.com>").

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 00:03:04 +02:00
Bas
7f04275d39 Merge pull request #33 from xtr-dev/dev
Dev
2025-09-13 23:53:56 +02:00
20afe30e88 Fix scheduledAt type in SendEmailTaskInput and add Date normalization
- Update SendEmailTaskInput.scheduledAt to support string | Date types
- Add Date object normalization to ISO strings in sendEmail processing
- Ensure consistent database storage format for all timestamp fields
- Convert Date objects to ISO strings before database operations

Resolves remaining "Type Date is not assignable to type string" error
for scheduledAt field in job task input.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:53:25 +02:00
02b3fecadf Bump package version to 0.1.17 in package.json. 2025-09-13 23:52:53 +02:00
Bas
ea87f14308 Merge pull request #32 from xtr-dev/dev
Dev
2025-09-13 23:48:28 +02:00
6886027727 Bump package version to 0.1.16 in package.json. 2025-09-13 23:45:39 +02:00
965569be06 Add Date type support for timestamp fields
- Update scheduledAt, sentAt, lastAttemptAt, createdAt, updatedAt fields to support Date | string | null
- Support both Date objects and ISO string formats for all timestamp fields
- Update BaseEmailDocument, BaseEmailTemplateDocument, and QueuedEmail interfaces consistently
- Update documentation to reflect Date object compatibility

Fixes type constraint error where customer timestamp fields use Date objects
but plugin interfaces only supported string formats.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:44:57 +02:00
Bas
ff788c1ecf Merge pull request #31 from xtr-dev/dev
Fix variables field type to support all JSON-compatible values
2025-09-13 23:41:43 +02:00
c12438aaa2 Bump package version to 0.1.15 in package.json. 2025-09-13 23:40:31 +02:00
4dcbc1446a Fix variables field type to support all JSON-compatible values
- Replace restrictive Record<string, any> with flexible JSONValue type for variables field
- Add JSONValue type alias that matches Payload's JSON field type specification
- Support string, number, boolean, objects, arrays, null, and undefined for variables
- Update both BaseEmailDocument and QueuedEmail interfaces consistently
- Update documentation to reflect JSONValue support

Fixes type constraint error where customer Email.variables field type
(string | number | boolean | {...} | unknown[] | null | undefined)
was not assignable to Record<string, any>.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:38:46 +02:00
Bas
72f3d7f66d Merge pull request #30 from xtr-dev/dev
Add null value support to BaseEmailDocument interface
2025-09-13 23:35:25 +02:00
ecc0b0a73e Fix type inconsistencies and missing null checks
- Update QueuedEmail interface to include `| null` for optional fields to match BaseEmailDocument
- Add missing null checks for replyTo and from fields in sendEmail processing
- Add proper email validation for replyTo and from fields (single email addresses)
- Ensure type consistency across all email-related interfaces

Fixes potential type conflicts between QueuedEmail and BaseEmailDocument,
and ensures all nullable email fields are properly validated.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:32:44 +02:00
a959673fc1 Bump package version to 0.1.14 in package.json. 2025-09-13 23:31:23 +02:00
8809db6aff Add null value support to BaseEmailDocument interface
- Update BaseEmailDocument to support `| null` for optional fields (cc, bcc, from, replyTo, text, etc.)
- Update BaseEmailTemplateDocument to support `| null` for optional fields
- Add explicit null checks in sendEmail processing to handle null values properly
- Update CUSTOM-TYPES.md documentation to reflect null value compatibility

Fixes type constraint error where customer Email types had `cc?: string[] | null`
but BaseEmailDocument only supported `cc?: string[]`.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:27:53 +02:00
Bas
5905f732de Merge pull request #29 from xtr-dev/dev
Support custom ID types (string/number) for improved compatibility
2025-09-13 23:24:55 +02:00
4c495a72b0 Remove duplicate BaseEmailDocument definition
- Remove duplicate BaseEmailDocument interface from sendEmail.ts
- Import BaseEmailDocument from types/index.ts instead
- Update sendEmailTask.ts to import from types/index.ts
- Maintain single source of truth for BaseEmailDocument type definition

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:22:23 +02:00
8518c716e8 Bump package version to 0.1.13 in package.json. 2025-09-13 23:21:33 +02:00
570190be01 Support custom ID types (string/number) for improved compatibility
- Replace hardcoded payload-types imports with generic BaseEmailDocument interface
- Update sendEmail and sendEmailTask to work with both string and number IDs
- Refactor MailingService to use generic document types instead of specific ones
- Add BaseEmailDocument and BaseEmailTemplateDocument interfaces supporting id: string | number
- Export BaseEmailDocument for users to extend with their custom fields
- Fix TypeScript compilation error in template subject handling
- Add CUSTOM-TYPES.md documentation for users with different ID types

Fixes compatibility issue where plugin required number IDs but user projects used string IDs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:18:37 +02:00
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
29 changed files with 1240 additions and 1191 deletions

2
.gitignore vendored
View File

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

View File

@@ -126,9 +126,10 @@ When you start the dev server, look for these messages:
🎯 Test interface will be available at: /mailing-test 🎯 Test interface will be available at: /mailing-test
✅ Example email templates created successfully ✅ 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 ## Troubleshooting
### Server won't start ### Server won't start

401
README.md
View File

@@ -28,26 +28,31 @@ npm install @xtr-dev/payload-mailing
## Quick Start ## Quick Start
### 1. Add the plugin to your Payload config ### 1. Configure email in your Payload config and add the plugin
```typescript ```typescript
import { buildConfig } from 'payload/config' import { buildConfig } from 'payload/config'
import { mailingPlugin } from '@xtr-dev/payload-mailing' import { mailingPlugin } from '@xtr-dev/payload-mailing'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
export default buildConfig({ export default buildConfig({
// ... your config // ... your config
email: nodemailerAdapter({
defaultFromAddress: 'noreply@yoursite.com',
defaultFromName: 'Your Site',
transport: {
host: 'smtp.gmail.com',
port: 587,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
},
}),
plugins: [ plugins: [
mailingPlugin({ mailingPlugin({
defaultFrom: 'noreply@yoursite.com', defaultFrom: 'noreply@yoursite.com',
transport: { defaultFromName: 'Your Site Name',
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
},
retryAttempts: 3, retryAttempts: 3,
retryDelay: 300000, // 5 minutes retryDelay: 300000, // 5 minutes
queue: 'email-queue', // optional queue: 'email-queue', // optional
@@ -119,13 +124,6 @@ mailingPlugin({
return yourCustomEngine.render(template, variables) return yourCustomEngine.render(template, variables)
}, },
// Email transport
transport: {
host: 'smtp.gmail.com',
port: 587,
auth: { user: '...', pass: '...' }
},
// Collection names (optional) // Collection names (optional)
collections: { collections: {
templates: 'email-templates', // default templates: 'email-templates', // default
@@ -139,13 +137,21 @@ 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!')
},
// beforeSend hook - modify emails before sending
beforeSend: async (options, email) => {
// Add attachments, modify headers, etc.
options.attachments = [
{ filename: 'invoice.pdf', content: pdfBuffer }
]
options.headers = {
'X-Campaign-ID': email.campaignId
}
return options
} }
}) })
``` ```
@@ -227,7 +233,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"}}.
@@ -238,282 +244,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:
@@ -535,6 +265,56 @@ mailingPlugin({
}) })
``` ```
### beforeSend Hook
Modify emails before they are sent to add attachments, custom headers, or make other changes:
```typescript
mailingPlugin({
// ... other config
beforeSend: async (options, email) => {
// Add attachments dynamically
if (email.invoiceId) {
const invoice = await generateInvoicePDF(email.invoiceId)
options.attachments = [
{
filename: `invoice-${email.invoiceId}.pdf`,
content: invoice.buffer,
contentType: 'application/pdf'
}
]
}
// Add custom headers
options.headers = {
'X-Campaign-ID': email.campaignId,
'X-Customer-ID': email.customerId,
'X-Priority': email.priority === 1 ? 'High' : 'Normal'
}
// Modify recipients based on conditions
if (process.env.NODE_ENV === 'development') {
// Redirect all emails to test address in dev
options.to = ['test@example.com']
options.subject = `[TEST] ${options.subject}`
}
// Add BCC for compliance
if (email.requiresAudit) {
options.bcc = ['audit@company.com']
}
return options
}
})
```
The `beforeSend` hook receives:
- `options`: The nodemailer mail options that will be sent
- `email`: The full email document from the database
You must return the modified options object.
### Initialization Hooks ### Initialization Hooks
Control plugin initialization order and add post-initialization logic: Control plugin initialization order and add post-initialization logic:
@@ -600,9 +380,9 @@ await processEmails(payload)
await retryFailedEmails(payload) await retryFailedEmails(payload)
``` ```
## PayloadCMS Task Integration ## PayloadCMS Integration
The plugin provides a ready-to-use PayloadCMS task for queuing template emails: The plugin provides PayloadCMS tasks for email processing:
### 1. Add the task to your Payload config ### 1. Add the task to your Payload config
@@ -675,6 +455,27 @@ The task can also be triggered from the Payload admin panel with a user-friendly
-**Error Handling**: Comprehensive error reporting -**Error Handling**: Comprehensive error reporting
-**Queue Management**: Leverage Payload's job queue system -**Queue Management**: Leverage Payload's job queue system
### Immediate Processing
The send email task now supports immediate processing. Enable the `processImmediately` option to send emails instantly:
```typescript
await payload.jobs.queue({
task: 'send-email',
input: {
processImmediately: true, // Send immediately (default: false)
templateSlug: 'welcome-email',
to: ['user@example.com'],
variables: { name: 'John' }
}
})
```
**Benefits**:
- No separate workflow needed
- Unified task interface
- Optional immediate processing when needed
## 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:

2
dev/.env.local Normal file
View File

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

View File

@@ -0,0 +1,310 @@
'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

@@ -0,0 +1,24 @@
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

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

View File

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

View File

@@ -1,6 +1,6 @@
import { getPayload } from 'payload' import { getPayload } from 'payload'
import config from '@payload-config' import config from '@payload-config'
import { sendEmail } from '@xtr-dev/payload-mailing' import { sendEmail, processEmailById } from '@xtr-dev/payload-mailing'
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@@ -8,6 +8,22 @@ export async function POST(request: Request) {
const body = await request.json() const body = await request.json()
const { type = 'send', templateSlug, to, variables, scheduledAt, subject, html, text } = body const { type = 'send', templateSlug, to, variables, scheduledAt, subject, html, text } = 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 // Use the new sendEmail API
const emailOptions: any = { const emailOptions: any = {
data: { data: {
@@ -41,10 +57,33 @@ export async function POST(request: Request) {
const result = await sendEmail(payload, emailOptions) const result = await sendEmail(payload, emailOptions)
// If it's "send now" (not scheduled), process the email immediately
if (type === 'send' && !scheduledAt) {
try {
await processEmailById(payload, String(result.id))
return Response.json({
success: true,
emailId: result.id,
message: 'Email sent successfully',
status: 'sent'
})
} catch (processError) {
// If immediate processing fails, return that it's queued
console.warn('Failed to process email immediately, left in queue:', processError)
return Response.json({
success: true,
emailId: result.id,
message: 'Email queued successfully (immediate processing failed)',
status: 'queued'
})
}
}
return Response.json({ return Response.json({
success: true, success: true,
emailId: result.id, emailId: result.id,
message: scheduledAt ? 'Email scheduled successfully' : 'Email queued successfully', message: scheduledAt ? 'Email scheduled successfully' : 'Email queued successfully',
status: scheduledAt ? 'scheduled' : 'queued'
}) })
} catch (error) { } catch (error) {
console.error('Test email error:', error) console.error('Test email error:', error)
@@ -82,8 +121,8 @@ export async function GET() {
total: totalDocs, total: totalDocs,
}, },
mailing: { mailing: {
pluginActive: !!(payload as any).mailing, pluginActive: 'mailing' in payload && !!payload.mailing,
service: !!(payload as any).mailing?.service, service: 'mailing' in payload && payload.mailing && 'service' in payload.mailing && !!payload.mailing.service,
}, },
}) })
} catch (error) { } catch (error) {

11
dev/app/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
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>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
}; };
db: { db: {
defaultIDType: string; defaultIDType: number;
}; };
globals: {}; globals: {};
globalsSelect: {}; globalsSelect: {};
@@ -100,7 +100,7 @@ export interface Config {
}; };
jobs: { jobs: {
tasks: { tasks: {
processEmails: ProcessEmailsJob; 'process-emails': ProcessEmailsTask;
'send-email': TaskSendEmail; 'send-email': TaskSendEmail;
inline: { inline: {
input: unknown; input: unknown;
@@ -133,7 +133,7 @@ export interface UserAuthOperations {
* via the `definition` "users". * via the `definition` "users".
*/ */
export interface User { export interface User {
id: string; id: number;
firstName?: string | null; firstName?: string | null;
lastName?: string | null; lastName?: string | null;
updatedAt: string; updatedAt: string;
@@ -159,7 +159,7 @@ export interface User {
* via the `definition` "posts". * via the `definition` "posts".
*/ */
export interface Post { export interface Post {
id: string; id: number;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -168,7 +168,7 @@ export interface Post {
* via the `definition` "media". * via the `definition` "media".
*/ */
export interface Media { export interface Media {
id: string; id: number;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
url?: string | null; url?: string | null;
@@ -186,7 +186,7 @@ export interface Media {
* via the `definition` "email-templates". * via the `definition` "email-templates".
*/ */
export interface EmailTemplate { export interface EmailTemplate {
id: string; id: number;
/** /**
* A descriptive name for this email template * A descriptive name for this email template
*/ */
@@ -227,11 +227,11 @@ export interface EmailTemplate {
* via the `definition` "emails". * via the `definition` "emails".
*/ */
export interface Email { export interface Email {
id: string; id: number;
/** /**
* Email template used (optional if custom content provided) * Email template used (optional if custom content provided)
*/ */
template?: (string | null) | EmailTemplate; template?: (number | null) | EmailTemplate;
/** /**
* Recipient email addresses * Recipient email addresses
*/ */
@@ -248,6 +248,10 @@ export interface Email {
* Sender email address (optional, uses default if not provided) * Sender email address (optional, uses default if not provided)
*/ */
from?: string | null; from?: string | null;
/**
* Sender display name (optional, e.g., "John Doe" for "John Doe <john@example.com>")
*/
fromName?: string | null;
/** /**
* Reply-to email address * Reply-to email address
*/ */
@@ -312,7 +316,7 @@ export interface Email {
* via the `definition` "payload-jobs". * via the `definition` "payload-jobs".
*/ */
export interface PayloadJob { export interface PayloadJob {
id: string; id: number;
/** /**
* Input data provided to the job * Input data provided to the job
*/ */
@@ -359,7 +363,7 @@ export interface PayloadJob {
| { | {
executedAt: string; executedAt: string;
completedAt: string; completedAt: string;
taskSlug: 'inline' | 'processEmails' | 'send-email'; taskSlug: 'inline' | 'process-emails' | 'send-email';
taskID: string; taskID: string;
input?: input?:
| { | {
@@ -392,7 +396,7 @@ export interface PayloadJob {
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
taskSlug?: ('inline' | 'processEmails' | 'send-email') | null; taskSlug?: ('inline' | 'process-emails' | 'send-email') | null;
queue?: string | null; queue?: string | null;
waitUntil?: string | null; waitUntil?: string | null;
processing?: boolean | null; processing?: boolean | null;
@@ -404,36 +408,36 @@ export interface PayloadJob {
* via the `definition` "payload-locked-documents". * via the `definition` "payload-locked-documents".
*/ */
export interface PayloadLockedDocument { export interface PayloadLockedDocument {
id: string; id: number;
document?: document?:
| ({ | ({
relationTo: 'users'; relationTo: 'users';
value: string | User; value: number | User;
} | null) } | null)
| ({ | ({
relationTo: 'posts'; relationTo: 'posts';
value: string | Post; value: number | Post;
} | null) } | null)
| ({ | ({
relationTo: 'media'; relationTo: 'media';
value: string | Media; value: number | Media;
} | null) } | null)
| ({ | ({
relationTo: 'email-templates'; relationTo: 'email-templates';
value: string | EmailTemplate; value: number | EmailTemplate;
} | null) } | null)
| ({ | ({
relationTo: 'emails'; relationTo: 'emails';
value: string | Email; value: number | Email;
} | null) } | null)
| ({ | ({
relationTo: 'payload-jobs'; relationTo: 'payload-jobs';
value: string | PayloadJob; value: number | PayloadJob;
} | null); } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: { user: {
relationTo: 'users'; relationTo: 'users';
value: string | User; value: number | User;
}; };
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -443,10 +447,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences". * via the `definition` "payload-preferences".
*/ */
export interface PayloadPreference { export interface PayloadPreference {
id: string; id: number;
user: { user: {
relationTo: 'users'; relationTo: 'users';
value: string | User; value: number | User;
}; };
key?: string | null; key?: string | null;
value?: value?:
@@ -466,7 +470,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations". * via the `definition` "payload-migrations".
*/ */
export interface PayloadMigration { export interface PayloadMigration {
id: string; id: number;
name?: string | null; name?: string | null;
batch?: number | null; batch?: number | null;
updatedAt: string; updatedAt: string;
@@ -543,6 +547,7 @@ export interface EmailsSelect<T extends boolean = true> {
cc?: T; cc?: T;
bcc?: T; bcc?: T;
from?: T; from?: T;
fromName?: T;
replyTo?: T; replyTo?: T;
subject?: T; subject?: T;
html?: T; html?: T;
@@ -623,9 +628,9 @@ 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` "ProcessEmailsJob". * via the `definition` "ProcessEmailsTask".
*/ */
export interface ProcessEmailsJob { export interface ProcessEmailsTask {
input?: unknown; input?: unknown;
output?: unknown; output?: unknown;
} }
@@ -635,6 +640,10 @@ export interface ProcessEmailsJob {
*/ */
export interface TaskSendEmail { export interface TaskSendEmail {
input: { 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) * Use a template (leave empty for direct email)
*/ */
@@ -675,6 +684,18 @@ export interface TaskSendEmail {
* Optional comma-separated list of BCC email addresses * Optional comma-separated list of BCC email addresses
*/ */
bcc?: string | null; 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 * Optional date/time to schedule email for future delivery
*/ */
@@ -684,7 +705,9 @@ export interface TaskSendEmail {
*/ */
priority?: number | null; priority?: number | null;
}; };
output?: unknown; output: {
id?: string | null;
};
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema

View File

@@ -1,4 +1,4 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb' import { sqliteAdapter } from '@payloadcms/db-sqlite'
import { lexicalEditor } from '@payloadcms/richtext-lexical' import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { import {
FixedToolbarFeature, FixedToolbarFeature,
@@ -6,7 +6,6 @@ import {
HorizontalRuleFeature, HorizontalRuleFeature,
InlineToolbarFeature, InlineToolbarFeature,
} from '@payloadcms/richtext-lexical' } from '@payloadcms/richtext-lexical'
import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path' import path from 'path'
import { buildConfig } from 'payload' import { buildConfig } from 'payload'
import sharp from 'sharp' import sharp from 'sharp'
@@ -24,36 +23,7 @@ if (!process.env.ROOT_DIR) {
process.env.ROOT_DIR = dirname process.env.ROOT_DIR = dirname
} }
const buildConfigWithMemoryDB = async () => { export default buildConfig({
// 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: { admin: {
importMap: { importMap: {
baseDir: path.resolve(dirname), baseDir: path.resolve(dirname),
@@ -122,31 +92,36 @@ const buildConfigWithMemoryDB = async () => {
}, },
}, },
], ],
db: mongooseAdapter({ db: sqliteAdapter({
ensureIndexes: true, client: {
url: process.env.DATABASE_URI || '', url: process.env.DATABASE_URI || 'file:./dev.db',
},
}), }),
editor: lexicalEditor(), editor: lexicalEditor(),
email: testEmailAdapter, email: testEmailAdapter,
onInit: async (payload) => { onInit: async (payload) => {
await seed(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: [ plugins: [
mailingPlugin({ mailingPlugin({
defaultFrom: 'noreply@test.com', defaultFrom: 'noreply@test.com',
initOrder: 'after', initOrder: 'after',
transport: {
host: 'localhost',
port: 1025, // MailHog port for dev
secure: false,
auth: {
user: 'test',
pass: 'test',
},
},
retryAttempts: 3, retryAttempts: 3,
retryDelay: 60000, // 1 minute for dev retryDelay: 60000, // 1 minute for dev
queue: 'email-queue', queue: 'default',
// Example: Collection overrides for customization // Example: Collection overrides for customization
// Uncomment and modify as needed for your use case // Uncomment and modify as needed for your use case
@@ -282,56 +257,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) => {
@@ -345,6 +270,3 @@ If you have questions, contact support@mycompany.com
outputFile: path.resolve(dirname, 'payload-types.ts'), outputFile: path.resolve(dirname, 'payload-types.ts'),
}, },
}) })
}
export default buildConfigWithMemoryDB()

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

@@ -3,26 +3,14 @@
// Development startup script for PayloadCMS Mailing Plugin // Development startup script for PayloadCMS Mailing Plugin
// This ensures proper environment setup and provides helpful information // This ensures proper environment setup and provides helpful information
console.log('🚀 PayloadCMS Mailing Plugin - Development Mode')
console.log('=' .repeat(50))
// Set development environment // Set development environment
process.env.NODE_ENV = process.env.NODE_ENV || 'development' process.env.NODE_ENV = process.env.NODE_ENV || 'development'
// Enable in-memory MongoDB by default for development // Set default SQLite database for development
if (!process.env.DATABASE_URI) { if (!process.env.DATABASE_URI) {
process.env.USE_MEMORY_DB = 'true' process.env.DATABASE_URI = 'file:./dev.db'
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 and start Next.js
import('next/dist/cli/next-dev.js') import('next/dist/cli/next-dev.js')
.then(({ nextDev }) => { .then(({ nextDev }) => {
@@ -35,11 +23,9 @@ import('next/dist/cli/next-dev.js')
// Handle graceful shutdown // Handle graceful shutdown
process.on('SIGTERM', () => { process.on('SIGTERM', () => {
console.log('\n🛑 Shutting down development server...')
process.exit(0) process.exit(0)
}) })
process.on('SIGINT', () => { process.on('SIGINT', () => {
console.log('\n🛑 Shutting down development server...')
process.exit(0) process.exit(0)
}) })

113
dev/test-hook-validation.ts Normal file
View File

@@ -0,0 +1,113 @@
// 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,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-mailing", "name": "@xtr-dev/payload-mailing",
"version": "0.1.8", "version": "0.4.5",
"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,9 +23,6 @@
"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",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"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",

View File

@@ -1,31 +0,0 @@
/**
* 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

@@ -49,6 +49,13 @@ const Emails: CollectionConfig = {
description: 'Sender email address (optional, uses default if not provided)', 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', name: 'replyTo',
type: 'text', type: 'text',

View File

@@ -27,3 +27,6 @@ export {
retryFailedEmails, retryFailedEmails,
parseAndValidateEmails, parseAndValidateEmails,
} from './utils/helpers.js' } from './utils/helpers.js'
// Email processing utilities
export { processEmailById, processAllEmails } from './utils/emailProcessor.js'

View File

@@ -1,35 +1,14 @@
import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js' import { processEmailsJob } from './processEmailsTask.js'
import { sendEmailJob } from './sendEmailTask.js' import { sendEmailJob } from './sendEmailTask.js'
import { MailingService } from '../services/MailingService.js'
/**
* All mailing-related jobs that get registered with Payload
*/
export const mailingJobs = [ export const mailingJobs = [
{ processEmailsJob,
slug: 'processEmails',
handler: async ({ job, req }: { job: any; req: any }) => {
// Get mailing context from payload
const payload = (req as any).payload
const mailingContext = payload.mailing
if (!mailingContext) {
throw new Error('Mailing plugin not properly initialized')
}
// 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, sendEmailJob,
] ]
export * from './processEmailsJob.js' // Re-export everything from individual job files
export * from './processEmailsTask.js'
export * from './sendEmailTask.js' export * from './sendEmailTask.js'

View File

@@ -1,50 +0,0 @@
import type { PayloadRequest } from 'payload'
import { MailingService } from '../services/MailingService.js'
export interface ProcessEmailsJobData {
// No type needed - always processes both pending and failed emails
}
export const processEmailsJob = async (
job: { data: ProcessEmailsJobData },
context: { req: PayloadRequest; mailingService: MailingService }
) => {
const { mailingService } = context
try {
console.log('🔄 Processing email queue (pending + failed emails)...')
// Process pending emails first
await mailingService.processEmails()
// Then retry failed emails
await mailingService.retryFailedEmails()
console.log('✅ Email queue processing completed successfully (pending and failed emails)')
} catch (error) {
console.error('❌ Email queue processing failed:', error)
throw error
}
}
export const scheduleEmailsJob = async (
payload: any,
queueName: string,
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: {},
waitUntil: delay ? new Date(Date.now() + delay) : undefined,
})
} catch (error) {
console.error('Failed to schedule email processing job:', error)
}
}

View File

@@ -0,0 +1,84 @@
import type { PayloadRequest, Payload } from 'payload'
import { processAllEmails } from '../utils/emailProcessor.js'
/**
* Data passed to the process emails task
*/
export interface ProcessEmailsTaskData {
// Currently no data needed - always processes both pending and failed emails
}
/**
* Handler function for processing emails
* Used internally by the task definition
*/
export const processEmailsTaskHandler = async (
job: { data: ProcessEmailsTaskData },
context: { req: PayloadRequest }
) => {
const { req } = context
const payload = (req as any).payload
// Use the shared email processing logic
await processAllEmails(payload)
}
/**
* Task definition for processing emails
* This is what gets registered with Payload's job system
*/
export const processEmailsTask = {
slug: 'process-emails',
handler: async ({ job, req }: { job: any; req: any }) => {
// Get mailing context from payload
const payload = (req as any).payload
const mailingContext = payload.mailing
if (!mailingContext) {
throw new Error('Mailing plugin not properly initialized')
}
// Use the task handler
await processEmailsTaskHandler(
job as { data: ProcessEmailsTaskData },
{ req }
)
return {
output: {
success: true,
message: 'Email queue processing completed successfully'
}
}
},
interfaceName: 'ProcessEmailsTask',
}
// For backward compatibility, export as processEmailsJob
export const processEmailsJob = processEmailsTask
/**
* Helper function to schedule an email processing job
* Used by the plugin during initialization and can be used by developers
*/
export const scheduleEmailsJob = async (
payload: Payload,
queueName: string,
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: 'process-emails',
input: {},
waitUntil: delay ? new Date(Date.now() + delay) : undefined,
} as any)
} catch (error) {
console.error('Failed to schedule email processing job:', error)
}
}

View File

@@ -1,5 +1,6 @@
import { sendEmail } from '../sendEmail.js' import { sendEmail } from '../sendEmail.js'
import { Email } from '../payload-types.js' import { BaseEmailDocument } from '../types/index.js'
import { processEmailById } from '../utils/emailProcessor.js'
export interface SendEmailTaskInput { export interface SendEmailTaskInput {
// Template mode fields // Template mode fields
@@ -15,17 +16,73 @@ export interface SendEmailTaskInput {
to: string | string[] to: string | string[]
cc?: string | string[] cc?: string | string[]
bcc?: string | string[] bcc?: string | string[]
scheduledAt?: string // ISO date string from?: string
fromName?: string
replyTo?: string
scheduledAt?: string | Date // ISO date string or Date object
priority?: number priority?: number
processImmediately?: boolean // If true, process the email immediately instead of waiting for the queue
// Allow any additional fields that users might have in their email collection // Allow any additional fields that users might have in their email collection
[key: string]: any [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', 'from', 'fromName', 'replyTo', 'subject', 'html', 'text', 'scheduledAt', 'priority']
// Fields that should not be copied to data
const excludedFields = ['templateSlug', 'variables', 'processImmediately']
// 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 excluded or standard fields
Object.keys(taskInput).forEach(key => {
if (!excludedFields.includes(key) && !standardFields.includes(key)) {
sendEmailOptions.data[key] = taskInput[key]
}
})
return sendEmailOptions
}
/**
* Job definition for sending emails
* Can be used through Payload's job queue system to send emails programmatically
*/
export const sendEmailJob = { export const sendEmailJob = {
slug: 'send-email', slug: 'send-email',
label: 'Send Email', label: 'Send Email',
inputSchema: [ inputSchema: [
{
name: 'processImmediately',
type: 'checkbox' as const,
label: 'Process Immediately',
defaultValue: false,
admin: {
description: 'Process and send the email immediately instead of waiting for the queue processor'
}
},
{ {
name: 'templateSlug', name: 'templateSlug',
type: 'text' as const, type: 'text' as const,
@@ -96,12 +153,37 @@ export const sendEmailJob = {
description: 'Optional comma-separated list of BCC email addresses' description: 'Optional comma-separated list of BCC email addresses'
} }
}, },
{
name: 'from',
type: 'text' as const,
label: 'From Email',
admin: {
description: 'Optional sender email address (uses default if not provided)'
}
},
{
name: 'fromName',
type: 'text' as const,
label: 'From Name',
admin: {
description: 'Optional sender display name (e.g., "John Doe")'
}
},
{
name: 'replyTo',
type: 'text' as const,
label: 'Reply To',
admin: {
description: 'Optional reply-to email address'
}
},
{ {
name: 'scheduledAt', name: 'scheduledAt',
type: 'date' as const, type: 'date' as const,
label: 'Schedule For', label: 'Schedule For',
admin: { admin: {
description: 'Optional date/time to schedule email for future delivery' description: 'Optional date/time to schedule email for future delivery',
condition: (data: any) => !data.processImmediately
} }
}, },
{ {
@@ -116,62 +198,57 @@ export const sendEmailJob = {
} }
} }
], ],
outputSchema: [
{
name: 'id',
type: 'text' as const
}
],
handler: async ({ input, payload }: any) => { handler: async ({ input, payload }: any) => {
// Cast input to our expected type // Cast input to our expected type
const taskInput = input as SendEmailTaskInput const taskInput = input as SendEmailTaskInput
const shouldProcessImmediately = taskInput.processImmediately || false
try { try {
// Prepare options for sendEmail based on task input // Transform task input into sendEmail options using helper function
const sendEmailOptions: any = { const sendEmailOptions = transformTaskInputToSendEmailOptions(taskInput)
data: {}
}
// If using template mode
if (taskInput.templateSlug) {
sendEmailOptions.template = {
slug: taskInput.templateSlug,
variables: taskInput.variables || {}
}
}
// Build data object from task input
const dataFields = ['to', 'cc', 'bcc', 'subject', 'html', 'text', 'scheduledAt', 'priority']
const additionalFields: string[] = []
// Copy standard fields
dataFields.forEach(field => {
if (taskInput[field] !== undefined) {
sendEmailOptions.data[field] = taskInput[field]
}
})
// Copy any additional custom fields
Object.keys(taskInput).forEach(key => {
if (!['templateSlug', 'variables', ...dataFields].includes(key)) {
sendEmailOptions.data[key] = taskInput[key]
additionalFields.push(key)
}
})
// Use the sendEmail helper to create the email // Use the sendEmail helper to create the email
const email = await sendEmail<Email>(payload, sendEmailOptions) const email = await sendEmail<BaseEmailDocument>(payload, sendEmailOptions)
// If processImmediately is true, process the email now
if (shouldProcessImmediately) {
console.log(`⚡ Processing email ${email.id} immediately...`)
await processEmailById(payload, String(email.id))
console.log(`✅ Email ${email.id} processed and sent immediately`)
return {
output: {
success: true,
id: email.id,
status: 'sent',
processedImmediately: true
}
}
}
return { return {
output: { output: {
success: true, success: true,
emailId: email.id, id: email.id,
message: `Email queued successfully with ID: ${email.id}`, status: 'queued',
mode: taskInput.templateSlug ? 'template' : 'direct', processedImmediately: false
templateSlug: taskInput.templateSlug || null,
subject: email.subject,
recipients: Array.isArray(email.to) ? email.to.length : 1,
scheduledAt: email.scheduledAt || null
} }
} }
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error' // Re-throw Error instances to preserve stack trace and error context
throw new Error(`Failed to queue email: ${errorMessage}`) if (error instanceof Error) {
throw error
} else {
// Only wrap non-Error values
throw new Error(`Failed to process email: ${String(error)}`)
}
} }
} }
} }

View File

@@ -1,431 +0,0 @@
/* 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

@@ -9,12 +9,6 @@ import { mailingJobs, scheduleEmailsJob } from './jobs/index.js'
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'
// Validate queueName
if (!queueName || typeof queueName !== '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'
@@ -74,10 +68,15 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
}), }),
} satisfies CollectionConfig } 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 { return {
...config, ...config,
collections: [ collections: [
...(config.collections || []), ...existingCollections,
templatesCollection, templatesCollection,
emailsCollection, emailsCollection,
], ],
@@ -107,12 +106,9 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
}, },
} as MailingContext } as MailingContext
console.log('PayloadCMS Mailing Plugin initialized successfully')
// Schedule the initial email processing job // Schedule the initial email processing job
try { try {
await scheduleEmailsJob(payload, queueName, 60000) // Schedule in 1 minute 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)
} }

View File

@@ -1,9 +1,9 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js' import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js'
import {Email} from "./payload-types.js" import { BaseEmailDocument } from './types/index.js'
// Options for sending emails // Options for sending emails
export interface SendEmailOptions<T extends Email = Email> { export interface SendEmailOptions<T extends BaseEmailDocument = BaseEmailDocument> {
// Template-based email // Template-based email
template?: { template?: {
slug: string slug: string
@@ -35,14 +35,14 @@ export interface SendEmailOptions<T extends Email = Email> {
* }) * })
* ``` * ```
*/ */
export const sendEmail = async <T extends Email = Email>( export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocument>(
payload: Payload, payload: Payload,
options: SendEmailOptions<T> options: SendEmailOptions<TEmail>
): Promise<T> => { ): Promise<TEmail> => {
const mailing = getMailing(payload) const mailing = getMailing(payload)
const collectionSlug = options.collectionSlug || mailing.collections.emails || 'emails' const collectionSlug = options.collectionSlug || mailing.collections.emails || 'emails'
let emailData: Partial<T> = { ...options.data } as Partial<T> let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail>
// If using a template, render it first // If using a template, render it first
if (options.template) { if (options.template) {
@@ -58,7 +58,7 @@ export const sendEmail = async <T extends Email = Email>(
subject, subject,
html, html,
text, text,
} as Partial<T> } as Partial<TEmail>
} }
// Validate required fields // Validate required fields
@@ -89,6 +89,44 @@ export const sendEmail = async <T extends Email = Email>(
if (emailData.bcc) { if (emailData.bcc) {
emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[]) emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[])
} }
if (emailData.replyTo) {
const validated = parseAndValidateEmails(emailData.replyTo as string | string[])
// replyTo should be a single email, so take the first one if array
emailData.replyTo = validated && validated.length > 0 ? validated[0] : undefined
}
if (emailData.from) {
const validated = parseAndValidateEmails(emailData.from as string | string[])
// from should be a single email, so take the first one if array
emailData.from = validated && validated.length > 0 ? validated[0] : undefined
}
// Sanitize fromName to prevent header injection
if (emailData.fromName) {
emailData.fromName = emailData.fromName
.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, '')
// Note: We don't escape quotes here as that's handled in MailingService
}
// Normalize Date objects to ISO strings for consistent database storage
if (emailData.scheduledAt instanceof Date) {
emailData.scheduledAt = emailData.scheduledAt.toISOString()
}
if (emailData.sentAt instanceof Date) {
emailData.sentAt = emailData.sentAt.toISOString()
}
if (emailData.lastAttemptAt instanceof Date) {
emailData.lastAttemptAt = emailData.lastAttemptAt.toISOString()
}
if (emailData.createdAt instanceof Date) {
emailData.createdAt = emailData.createdAt.toISOString()
}
if (emailData.updatedAt instanceof Date) {
emailData.updatedAt = emailData.updatedAt.toISOString()
}
// Create the email in the collection with proper typing // Create the email in the collection with proper typing
const email = await payload.create({ const email = await payload.create({
@@ -96,7 +134,12 @@ export const sendEmail = async <T extends Email = Email>(
data: emailData data: emailData
}) })
return email as T // 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 export default sendEmail

View File

@@ -1,25 +1,20 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import { Liquid } from 'liquidjs' import { Liquid } from 'liquidjs'
import nodemailer, { Transporter } from 'nodemailer'
import { import {
MailingPluginConfig, MailingPluginConfig,
TemplateVariables, TemplateVariables,
MailingService as IMailingService, MailingService as IMailingService,
EmailTemplate, BaseEmail, BaseEmailTemplate, BaseEmailDocument, BaseEmailTemplateDocument
QueuedEmail,
MailingTransportConfig,
EmailObject
} 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 {
public payload: Payload public payload: Payload
private config: MailingPluginConfig private config: MailingPluginConfig
private transporter!: Transporter | any private emailAdapter: 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
@@ -31,49 +26,55 @@ 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'
// Only initialize transporter if payload is properly set // Use Payload's configured email adapter
if (payload && payload.db) { if (!this.payload.email) {
this.initializeTransporter() throw new Error('Payload email configuration is required. Please configure email in your Payload config.')
} }
} this.emailAdapter = this.payload.email
private initializeTransporter(): void {
if (this.transporterInitialized) return
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')
}
this.transporterInitialized = true
} }
private ensureInitialized(): void { private ensureInitialized(): void {
if (!this.payload || !this.payload.db) { if (!this.payload || !this.payload.db) {
throw new Error('MailingService payload not properly initialized') throw new Error('MailingService payload not properly initialized')
} }
if (!this.transporterInitialized) { if (!this.emailAdapter) {
this.initializeTransporter() 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
* and ensure proper formatting
*/
private sanitizeDisplayName(name: string): string {
return name
.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 to prevent malformed headers
.replace(/"/g, '\\"')
}
/**
* 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 { private getDefaultFrom(): string {
const fromEmail = this.config.defaultFrom const fromEmail = this.config.defaultFrom
const fromName = this.config.defaultFromName const fromName = this.config.defaultFromName
// Check if fromName exists, is not empty after trimming, and fromEmail exists // Check if fromName exists, is not empty after trimming, and fromEmail exists
if (fromName && fromName.trim() && fromEmail) { if (fromName && fromName.trim() && fromEmail) {
// Escape quotes in the display name to prevent malformed headers return this.formatEmailAddress(fromEmail, fromName)
const escapedName = fromName.replace(/"/g, '\\"')
return `"${escapedName}" <${fromEmail}>`
} }
return fromEmail || '' return fromEmail || ''
@@ -133,7 +134,7 @@ export class MailingService implements IMailingService {
} }
const emailContent = await this.renderEmailTemplate(template, variables) const emailContent = await this.renderEmailTemplate(template, variables)
const subject = await this.renderTemplateString(template.subject, variables) const subject = await this.renderTemplateString(template.subject || '', variables)
return { return {
html: emailContent.html, html: emailContent.html,
@@ -224,7 +225,7 @@ export class MailingService implements IMailingService {
} }
} }
private async processEmailItem(emailId: string): Promise<void> { async processEmailItem(emailId: string): Promise<void> {
try { try {
await this.payload.update({ await this.payload.update({
collection: this.emailsCollection as any, collection: this.emailsCollection as any,
@@ -238,10 +239,18 @@ 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 BaseEmailDocument
let emailObject: EmailObject = { // Combine from and fromName for nodemailer using proper sanitization
from: email.from || this.getDefaultFrom(), let fromField: string
if (email.from) {
fromField = this.formatEmailAddress(email.from, email.fromName)
} else {
fromField = this.getDefaultFrom()
}
let mailOptions: any = {
from: fromField,
to: email.to, to: email.to,
cc: email.cc || undefined, cc: email.cc || undefined,
bcc: email.bcc || undefined, bcc: email.bcc || undefined,
@@ -249,26 +258,34 @@ 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 // Call beforeSend hook if configured
if (this.config.emailWrapper) { if (this.config.beforeSend) {
emailObject = await this.config.emailWrapper(emailObject) 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 = { // Send email using Payload's email adapter
from: emailObject.from, await this.emailAdapter.sendEmail(mailOptions)
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.payload.update({ await this.payload.update({
collection: this.emailsCollection as any, collection: this.emailsCollection as any,
@@ -304,9 +321,9 @@ 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 })
const newAttempts = (email.attempts || 0) + 1 const newAttempts = ((email as any).attempts || 0) + 1
await this.payload.update({ await this.payload.update({
collection: this.emailsCollection as any, collection: this.emailsCollection as any,
@@ -319,7 +336,7 @@ export class MailingService implements IMailingService {
return newAttempts return newAttempts
} }
private async getTemplateBySlug(templateSlug: string): Promise<EmailTemplate | null> { private async getTemplateBySlug(templateSlug: string): Promise<BaseEmailTemplateDocument | 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,
@@ -331,7 +348,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 BaseEmailTemplateDocument : 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
@@ -396,7 +413,7 @@ export class MailingService implements IMailingService {
}) })
} }
private async renderEmailTemplate(template: EmailTemplate, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> { private async renderEmailTemplate(template: BaseEmailTemplateDocument, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
if (!template.content) { if (!template.content) {
return { html: '', text: '' } return { html: '', text: '' }
} }

View File

@@ -1,25 +1,67 @@
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'
export interface EmailObject { // JSON value type that matches Payload's JSON field type
to: string | string[] export type JSONValue = string | number | boolean | { [k: string]: unknown } | unknown[] | null | undefined
cc?: string | string[]
bcc?: string | string[] // Generic base interfaces that work with any ID type and null values
from?: string export interface BaseEmailDocument {
replyTo?: string id: string | number
template?: any
to: string[]
cc?: string[] | null
bcc?: string[] | null
from?: string | null
fromName?: string | null
replyTo?: string | null
subject: string subject: string
html: string html: string
text?: string text?: string | null
variables?: Record<string, any> 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
} }
export type EmailWrapperHook = (email: EmailObject) => EmailObject | Promise<EmailObject> 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 BaseEmail<TEmail extends BaseEmailDocument = BaseEmailDocument, TEmailTemplate extends BaseEmailTemplateDocument = BaseEmailTemplateDocument> = Omit<TEmail, 'id' | 'template'> & {template: Omit<TEmailTemplate, 'id'> | TEmailTemplate['id'] | undefined | null}
export type BaseEmailTemplate<TEmailTemplate extends BaseEmailTemplateDocument = BaseEmailTemplateDocument> = 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>
export type TemplateEngine = 'liquidjs' | 'mustache' | 'simple' export type TemplateEngine = 'liquidjs' | 'mustache' | 'simple'
export interface BeforeSendMailOptions {
from: string
to: string[]
cc?: string[]
bcc?: string[]
replyTo?: string
subject: string
html: string
text?: string
attachments?: any[]
[key: string]: any
}
export type BeforeSendHook = (options: BeforeSendMailOptions, email: BaseEmailDocument) => BeforeSendMailOptions | Promise<BeforeSendMailOptions>
export interface MailingPluginConfig { export interface MailingPluginConfig {
collections?: { collections?: {
templates?: string | Partial<CollectionConfig> templates?: string | Partial<CollectionConfig>
@@ -27,58 +69,37 @@ export interface MailingPluginConfig {
} }
defaultFrom?: string defaultFrom?: string
defaultFromName?: string defaultFromName?: string
transport?: Transporter | MailingTransportConfig
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']
beforeSend?: BeforeSendHook
onReady?: (payload: any) => Promise<void> onReady?: (payload: any) => Promise<void>
initOrder?: 'before' | 'after' initOrder?: 'before' | 'after'
} }
export interface MailingTransportConfig {
host: string
port: number
secure?: boolean
auth?: {
user: string
pass: string
}
}
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
template?: string template?: string | null
to: string[] to: string[]
cc?: string[] cc?: string[] | null
bcc?: string[] bcc?: string[] | null
from?: string from?: string | null
replyTo?: string fromName?: string | null
replyTo?: string | null
subject: string subject: string
html: string html: string
text?: string text?: string | null
variables?: Record<string, any> variables?: JSONValue
scheduledAt?: string scheduledAt?: string | Date | null
sentAt?: string sentAt?: string | Date | null
status: 'pending' | 'processing' | 'sent' | 'failed' status: 'pending' | 'processing' | 'sent' | 'failed'
attempts: number attempts: number
lastAttemptAt?: string lastAttemptAt?: string | Date | null
error?: string error?: string | null
priority?: number priority?: number | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
@@ -90,6 +111,7 @@ export interface TemplateVariables {
export interface MailingService { export interface MailingService {
processEmails(): Promise<void> processEmails(): Promise<void>
processEmailItem(emailId: string): Promise<void>
retryFailedEmails(): Promise<void> retryFailedEmails(): Promise<void>
renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }>
} }

View File

@@ -0,0 +1,61 @@
import type { Payload } from 'payload'
/**
* 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 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()
}