Compare commits

..

51 Commits

Author SHA1 Message Date
Bas
e38b63d814 Merge pull request #24 from xtr-dev/dev
Dev
2025-09-13 22:00:51 +02:00
31721dc110 Add comment to clarify purpose of payload.config.ts 2025-09-13 22:00:29 +02:00
6e4f754306 Fix critical type safety and validation issues
Issue 2 - Type Safety:
- Remove dangerous 'as any' casts in sendEmail function
- Use proper typing for payload.create() calls
- Maintain type safety throughout email creation process

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:51:46 +02:00
12952ad41c Add pre-release warning to README
- Highlight active development status (v
2025-09-13 18:41:28 +02:00
Bas
273dea5a73 Merge pull request #18 from xtr-dev/dev
🎨 Fix README features section formatting
2025-09-13 18:40:43 +02:00
651a487921 Bump package version to 0.1.2 in package.json. 2025-09-13 18:40:32 +02:00
f2df4ce496 🎨 Fix README features section formatting
Add proper newlines between feature items for better readability.
2025-09-13 18:37:07 +02:00
Bas
c81ef7f8a8 Merge pull request #17 from xtr-dev/dev
🚀 BREAKING: Simplify API to use Payload collections directly
2025-09-13 18:36:11 +02:00
804a63647a 📚 DOCS: Update README for v0.1.0 API changes
- Remove all outdated API examples (sendEmail, scheduleEmail)
- Add comprehensive examples using new payload.create() approach
- Include template engine configuration options (LiquidJS, Mustache, custom)
- Add detailed migration guide from v0.0.x to v0.1.0
- Update feature list to highlight type safety and Payload integration
- Replace old API methods section with helper functions
- Add template syntax reference for all supported engines
- Update Recent Changes section with v0.1.0 breaking changes

The README now accurately reflects the simplified collection-based API
and provides clear migration paths for existing users.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:33:21 +02:00
135fddc6fa 🔥 REMOVE: Deprecate outdated documentation for simplified API and template engine
- Delete `simplified-api-guide.md` and `template-syntax-migration.md`
- Content now covered in more streamlined and up-to-date guides
- Declutter repository by removing redundant files

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:25:06 +02:00
74f565ab4e 🚀 BREAKING: Simplify API to use Payload collections directly
- Remove complex sendEmail/scheduleEmail methods and SendEmailOptions types
- Add simple renderTemplate() helper for template rendering
- Users now create emails using payload.create() with full type safety
- Leverage Payload's existing collection system instead of duplicating functionality
- Provide comprehensive migration guide and usage examples

BREAKING CHANGES:
- sendEmail() and scheduleEmail() methods removed
- SendEmailOptions type removed
- Use payload.create() with email collection instead
- Use renderTemplate() helper for template rendering

Benefits:
 Full TypeScript support with generated Payload types
 Use any custom fields in your email collection
 Leverage Payload's validation, hooks, and access control
 Simpler, more consistent API
 Less code to maintain

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:23:05 +02:00
Bas
cddcfb1e4c Merge pull request #16 from xtr-dev/dev
Replace Handlebars with flexible template engine system
2025-09-13 18:11:06 +02:00
cfc3ce5a7e IMPROVE: Clean up async initialization pattern
- Remove unnecessary initializeTemplateEngine() from constructor
- Rename initializeLiquidJS() to ensureLiquidJSInitialized() for clarity
- Make template engine loading truly lazy (only on first template render)
- Eliminate potential timing issues with constructor async calls
- Improve code clarity and maintainability

Now template engines are only loaded when actually needed for rendering.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:08:10 +02:00
5acf7d52f6 🚀 FINAL FIX: Standard dynamic imports with proper async handling
- Replace Function() constructor imports with standard dynamic imports
- Add proper state management for template engine loading (null | false | Liquid)
- Fix async initialization timing issues with lazy loading
- Add mustache type declarations for TypeScript compatibility
- Prevent retry attempts on failed module loads
- Ensure webpack/bundler compatibility across all environments

All webpack compatibility issues now fully resolved.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:02:40 +02:00
b854b17266 🚨 CRITICAL FIX: Replace require() with dynamic imports for webpack compatibility
- Replace require('liquidjs') and require('mustache') with dynamic imports
- Fix webpack compatibility issues and ES module support
- Make template engine initialization lazy and async
- Add proper error handling for optional dependencies
- Use Function('return import(...)') pattern to avoid TypeScript issues
- Maintain backward compatibility with existing configurations

This resolves critical webpack bundling issues in client applications.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 17:57:19 +02:00
dc3c4fdb44 Replace Handlebars with flexible template engine system
- Replace handlebars dependency with optional liquidjs and mustache
- Add templateEngine string configuration ('liquidjs', 'mustache', 'simple')
- Add custom templateRenderer hook for maximum flexibility
- Implement graceful fallbacks when optional dependencies unavailable
- Fix webpack compatibility issues with require.extensions
- Maintain backward compatibility with existing templates
- Add comprehensive template syntax documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 17:51:25 +02:00
Bas
80d32674a9 Merge pull request #15 from xtr-dev/dev
Add automatic job scheduling and rescheduling
2025-09-13 17:00:33 +02:00
243f7c96cf Bump package version to 0.0.8 in package.json. 2025-09-13 16:57:50 +02:00
159a99a1ec Fix race conditions and add validation for job scheduling
- Reuse duplicate prevention logic in rescheduling to prevent race conditions
- Add queueName validation in plugin initialization and helper function
- Enhanced scheduleEmailProcessingJob to return boolean and accept delay parameter
- Improve error handling: rescheduling failures warn but don't fail current job
- Prevent duplicate jobs when multiple handlers complete simultaneously

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:56:51 +02:00
860dd7e921 Add automatic job scheduling and rescheduling
- Add scheduleEmailProcessingJob helper to check and schedule jobs on init
- Only schedule if no existing uncompleted job exists
- Update job handler to always reschedule itself after completion (5min interval)
- Job reschedules regardless of success/failure for continuous processing
- Initial job starts 1 minute after init, subsequent jobs every 5 minutes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:51:59 +02:00
Bas
c7af628beb Merge pull request #14 from xtr-dev/dev
Add defaultFromName config option and bump to v0.0.7
2025-09-13 16:29:26 +02:00
fa54c5622c Improve email display name handling with proper escaping
- Add quote escaping in display names to prevent malformed email headers
- Handle empty string defaultFromName by checking trim()
- Prevent formatting when fromName is only whitespace
- Example: John "The Boss" Doe becomes "John \"The Boss\" Doe" <email>

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:26:53 +02:00
719b60b9ef Add defaultFromName config option and bump to v0.0.7
- Add defaultFromName to MailingPluginConfig interface
- Update MailingService to format from field with name when available
- Add getDefaultFrom() helper method for consistent formatting
- Format as "Name" <email@domain.com> when both name and email are provided
- Bump version to 0.0.7

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:23:39 +02:00
Bas
048fa33747 Merge pull request #13 from xtr-dev/dev
Fix TypeScript build error with payload email adapter
2025-09-13 16:18:41 +02:00
fa028ff540 Bump package version to 0.0.6 in package.json. 2025-09-13 16:18:04 +02:00
428a5f45cd Fix TypeScript build error with payload email adapter
- Update transporter type to handle different email adapter interfaces
- Add type casting for payload.email to resolve compatibility issues
- Build now completes successfully without TypeScript errors

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:11:35 +02:00
Bas
cb62874500 Merge pull request #12 from xtr-dev/dev
Make mailer transport config optional, use Payload config fallback
2025-09-13 16:04:26 +02:00
196aaeab9c Fix critical typo and improve type safety
- Fix typo: nodemailer.createTransporter -> createTransport
- Add type safety check for payload.email with sendMail validation
- Prevent runtime errors from invalid transporter objects

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 16:02:11 +02:00
a32d5688c4 Make mailer transport config optional, use Payload config fallback
- Updated MailingService to check for Payload's email transporter when plugin transport is not configured
- Enhanced error message to indicate transport is required in either plugin or Payload config
- Bump version to 0.0.5

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 15:56:25 +02:00
21 changed files with 1567 additions and 416 deletions

443
README.md
View File

@@ -6,12 +6,19 @@
## Features ## Features
**Template System**: Create reusable email templates with Handlebars syntax **Template System**: Create reusable email templates with LiquidJS, Mustache, or simple variables
**Outbox Scheduling**: Schedule emails for future delivery
**Job Integration**: Automatic processing via PayloadCMS jobs queue **Type Safety**: Full TypeScript support using your generated Payload types
**Retry Failed Sends**: Automatic retry mechanism for failed emails
**Template Variables**: Dynamic content with validation **Flexible Template Engines**: LiquidJS, Mustache, or bring your own template renderer
**Developer API**: Simple methods for sending emails programmatically
**Email Scheduling**: Schedule emails for future delivery using Payload collections
**Job Integration**: Automatic processing via PayloadCMS jobs queue
**Retry Failed Sends**: Automatic retry mechanism for failed emails
**Payload Native**: Uses Payload collections directly - no custom APIs to learn
## Installation ## Installation
@@ -49,29 +56,51 @@ export default buildConfig({
}) })
``` ```
### 2. Send emails in your code ### 2. Send emails with type-safe helper
```typescript ```typescript
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing' // sendEmail is a primary export for easy access
import { sendEmail } from '@xtr-dev/payload-mailing'
import { Email } from './payload-types' // Your generated types
// Send immediately using template slug // Option 1: Using templates with full type safety
const emailId = await sendEmail(payload, { const email = await sendEmail<Email>(payload, {
templateSlug: 'welcome-email', template: {
to: 'user@example.com', slug: 'welcome-email',
variables: { variables: {
firstName: 'John', firstName: 'John',
welcomeUrl: 'https://yoursite.com/welcome' welcomeUrl: 'https://yoursite.com/welcome'
}
},
data: {
to: 'user@example.com',
// Schedule for later (optional)
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
priority: 1,
// Your custom fields are type-safe!
customField: 'your-value',
} }
}) })
// Schedule for later // Option 2: Direct HTML email (no template)
const scheduledId = await scheduleEmail(payload, { const directEmail = await sendEmail<Email>(payload, {
templateSlug: 'reminder-email', data: {
to: 'user@example.com', to: ['user@example.com', 'another@example.com'],
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours subject: 'Welcome!',
variables: { html: '<h1>Welcome John!</h1><p>Thanks for joining!</p>',
eventName: 'Product Launch', text: 'Welcome John! Thanks for joining!',
eventDate: new Date('2024-01-15') // All your custom fields work with TypeScript autocomplete!
customField: 'value',
}
})
// Option 3: Use payload.create() directly for full control
const manualEmail = await payload.create({
collection: 'emails',
data: {
to: ['user@example.com'],
subject: 'Hello',
html: '<p>Hello World</p>',
} }
}) })
``` ```
@@ -81,21 +110,72 @@ const scheduledId = await scheduleEmail(payload, {
### Plugin Options ### Plugin Options
```typescript ```typescript
interface MailingPluginConfig { mailingPlugin({
collections?: { // Template engine (optional)
templates?: string // default: 'email-templates' templateEngine: 'liquidjs', // 'liquidjs' | 'mustache' | 'simple'
emails?: string // default: 'emails'
// Custom template renderer (optional)
templateRenderer: async (template: string, variables: Record<string, any>) => {
return yourCustomEngine.render(template, variables)
},
// Email transport
transport: {
host: 'smtp.gmail.com',
port: 587,
auth: { user: '...', pass: '...' }
},
// Collection names (optional)
collections: {
templates: 'email-templates', // default
emails: 'emails' // default
},
// Sending options
defaultFrom: 'noreply@yoursite.com',
defaultFromName: 'Your Site',
retryAttempts: 3, // default
retryDelay: 300000, // 5 minutes (default)
// Advanced options
emailWrapper: (email) => ({ // optional layout wrapper
...email,
html: `<html><body>${email.html}</body></html>`
}),
richTextEditor: lexicalEditor(), // optional custom editor
onReady: async (payload) => { // optional initialization hook
console.log('Mailing plugin ready!')
} }
defaultFrom?: string })
transport?: Transporter | MailingTransportConfig ```
queue?: string // default: 'default'
retryAttempts?: number // default: 3 ### Template Engine Options
retryDelay?: number // default: 300000 (5 minutes)
emailWrapper?: EmailWrapperHook // optional email layout wrapper Choose your preferred template engine:
richTextEditor?: RichTextField['editor'] // optional custom rich text editor
onReady?: (payload: any) => Promise<void> // optional callback after plugin initialization ```typescript
initOrder?: 'before' | 'after' // default: 'before' // LiquidJS (default) - Modern syntax with logic
} mailingPlugin({
templateEngine: 'liquidjs' // {% if user.isPremium %}Premium!{% endif %}
})
// Mustache - Logic-less templates
mailingPlugin({
templateEngine: 'mustache' // {{#user.isPremium}}Premium!{{/user.isPremium}}
})
// Simple variable replacement
mailingPlugin({
templateEngine: 'simple' // Just {{variable}} replacement
})
// Custom template renderer
mailingPlugin({
templateRenderer: async (template, variables) => {
return handlebars.compile(template)(variables) // Bring your own!
}
})
``` ```
### Transport Configuration ### Transport Configuration
@@ -473,73 +553,127 @@ mailingPlugin({
}) })
``` ```
## Handlebars Helpers ## Template Syntax Reference
The plugin includes several built-in helpers: Depending on your chosen template engine, you can use different syntax:
- `{{formatDate date 'short'}}` - Format dates (short, long, or default) ### LiquidJS (Default)
- `{{formatCurrency amount 'USD'}}` - Format currency - Variables: `{{ user.name }}`
- `{{capitalize string}}` - Capitalize first letter - Logic: `{% if user.isPremium %}Premium content{% endif %}`
- `{{#ifEquals value1 value2}}...{{/ifEquals}}` - Conditional equality - Loops: `{% for item in items %}{{ item.name }}{% endfor %}`
- Filters: `{{ amount | formatCurrency }}`, `{{ date | formatDate: "short" }}`
## API Methods ### Mustache
- Variables: `{{ user.name }}`
- Logic: `{{#user.isPremium}}Premium content{{/user.isPremium}}`
- Loops: `{{#items}}{{ name }}{{/items}}`
- No built-in filters (use variables with pre-formatted data)
### sendEmail(payload, options) ### Simple
- Variables only: `{{ user.name }}`, `{{ amount }}`, `{{ date }}`
Send an email immediately: ### Built-in Filters (LiquidJS only)
- `formatDate` - Format dates: `{{ createdAt | formatDate: "short" }}`
- `formatCurrency` - Format currency: `{{ amount | formatCurrency: "USD" }}`
- `capitalize` - Capitalize first letter: `{{ name | capitalize }}`
## Available Helper Functions
```typescript ```typescript
const emailId = await sendEmail(payload, { import {
templateSlug: 'order-confirmation', // optional - use template slug renderTemplate, // Render email templates with variables
to: ['customer@example.com'], // string or array of emails processEmails, // Process pending emails manually
cc: ['manager@example.com'], // optional - array of emails retryFailedEmails, // Retry failed emails
bcc: ['archive@example.com'], // optional - array of emails getMailing // Get mailing service instance
from: 'orders@yoursite.com', // optional, uses default } from '@xtr-dev/payload-mailing'
replyTo: 'support@yoursite.com', // optional
subject: 'Custom subject', // required if no template // Render a template
html: '<h1>Custom HTML</h1>', // required if no template const { html, text, subject } = await renderTemplate(payload, 'welcome', {
text: 'Custom text version', // optional name: 'John',
variables: { // template variables activationUrl: 'https://example.com/activate'
orderNumber: '12345',
customerName: 'John Doe'
},
priority: 1 // optional, 1-10 (1 = highest)
}) })
// Process emails manually
await processEmails(payload)
// Retry failed emails
await retryFailedEmails(payload)
``` ```
### scheduleEmail(payload, options) ## PayloadCMS Task Integration
Schedule an email for later delivery: The plugin provides a ready-to-use PayloadCMS task for queuing template emails:
### 1. Add the task to your Payload config
```typescript ```typescript
const emailId = await scheduleEmail(payload, { import { buildConfig } from 'payload/config'
templateSlug: 'newsletter', import { sendTemplateEmailTask } from '@xtr-dev/payload-mailing'
to: ['user1@example.com', 'user2@example.com'],
scheduledAt: new Date('2024-01-15T10:00:00Z'), export default buildConfig({
variables: { // ... your config
month: 'January', jobs: {
highlights: ['Feature A', 'Feature B'] tasks: [
sendTemplateEmailTask,
// ... your other tasks
]
} }
}) })
``` ```
### processEmails(payload) ### 2. Queue emails from your code
Manually process pending emails:
```typescript ```typescript
import { processEmails } from '@xtr-dev/payload-mailing' import type { SendTemplateEmailInput } from '@xtr-dev/payload-mailing'
await processEmails(payload)
// Queue a template email
const result = await payload.jobs.queue({
task: 'send-template-email',
input: {
templateSlug: 'welcome-email',
to: ['user@example.com'],
cc: ['manager@example.com'],
variables: {
firstName: 'John',
activationUrl: 'https://example.com/activate/123'
},
priority: 1,
// Add any custom fields from your email collection
customField: 'value'
} as SendTemplateEmailInput
})
// Queue a scheduled email
await payload.jobs.queue({
task: 'send-template-email',
input: {
templateSlug: 'reminder-email',
to: ['user@example.com'],
variables: { eventName: 'Product Launch' },
scheduledAt: new Date('2024-01-15T10:00:00Z').toISOString(),
priority: 3
}
})
``` ```
### retryFailedEmails(payload) ### 3. Use in admin panel workflows
Manually retry failed emails: The task can also be triggered from the Payload admin panel with a user-friendly form interface that includes:
- Template slug selection
- Email recipients (to, cc, bcc)
- Template variables as JSON
- Optional scheduling
- Priority setting
- Any custom fields you've added to your email collection
```typescript ### Task Benefits
import { retryFailedEmails } from '@xtr-dev/payload-mailing'
await retryFailedEmails(payload) -**Easy Integration**: Just add to your tasks array
``` -**Type Safety**: Full TypeScript support with `SendTemplateEmailInput`
-**Admin UI**: Ready-to-use form interface
-**Flexible**: Supports all your custom email collection fields
-**Error Handling**: Comprehensive error reporting
-**Queue Management**: Leverage Payload's job queue system
## Job Processing ## Job Processing
@@ -716,35 +850,134 @@ import {
} from '@xtr-dev/payload-mailing' } from '@xtr-dev/payload-mailing'
``` ```
## API Reference
### `sendEmail<T>(payload, options)`
Type-safe email sending with automatic template rendering and validation.
```typescript
import { sendEmail } from '@xtr-dev/payload-mailing'
import { Email } from './payload-types'
const email = await sendEmail<Email>(payload, {
template: {
slug: 'template-slug',
variables: { /* template variables */ }
},
data: {
to: 'user@example.com',
// Your custom fields are type-safe here!
}
})
```
**Type Parameters:**
- `T extends BaseEmailData` - Your generated Email type for full type safety
**Options:**
- `template.slug` - Template slug to render
- `template.variables` - Variables to pass to template
- `data` - Email data (merged with template output)
- `collectionSlug` - Custom collection name (defaults to 'emails')
### `renderTemplate(payload, slug, variables)`
Render an email template without sending.
```typescript
const { html, text, subject } = await renderTemplate(
payload,
'welcome-email',
{ name: 'John' }
)
```
### Helper Functions
- `getMailing(payload)` - Get mailing context
- `processEmails(payload)` - Manually trigger email processing
- `retryFailedEmails(payload)` - Manually retry failed emails
## Migration Guide (v0.0.x → v0.1.0)
**🚨 BREAKING CHANGES**: The API has been simplified to use Payload collections directly.
### Before (v0.0.x)
```typescript
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
// Old way
const emailId = await sendEmail(payload, {
templateSlug: 'welcome',
to: 'user@example.com',
variables: { name: 'John' }
})
const scheduledId = await scheduleEmail(payload, {
templateSlug: 'reminder',
to: 'user@example.com',
scheduledAt: new Date('2024-01-15T10:00:00Z'),
variables: { eventName: 'Launch' }
})
```
### After (v0.1.0+)
```typescript
import { renderTemplate } from '@xtr-dev/payload-mailing'
// New way - render template first
const { html, text, subject } = await renderTemplate(payload, 'welcome', {
name: 'John'
})
// Then create email using Payload collections (full type safety!)
const email = await payload.create({
collection: 'emails',
data: {
to: ['user@example.com'],
subject,
html,
text,
// For scheduling
scheduledAt: new Date('2024-01-15T10:00:00Z'),
// Add any custom fields from your collection
customField: 'value',
}
})
```
### Benefits of Migration
-**Full TypeScript support** with your generated Payload types
-**Use any custom fields** you add to your email collection
-**Leverage Payload's features**: validation, hooks, access control
-**One consistent API** - just use `payload.create()`
-**No wrapper methods** - direct access to Payload's power
## Recent Changes ## Recent Changes
### v0.0.x (Latest) ### v0.1.0 (Latest - Breaking Changes)
**🔄 Breaking Changes:** **🚀 Major API Simplification:**
- Removed email layouts system in favor of `emailWrapper` hook for better flexibility - **REMOVED**: `sendEmail()` and `scheduleEmail()` wrapper methods
- Email fields (`to`, `cc`, `bcc`) now use `hasMany: true` for proper array handling - **REMOVED**: `SendEmailOptions` custom types
- Templates now use slug-based lookup instead of ID-based for developer-friendly API - **ADDED**: Direct Payload collection usage with full type safety
- Email collection renamed from "outbox" to "emails" - **ADDED**: `renderTemplate()` helper for template rendering
- Unified job processing: single `process-email-queue` job handles both pending and failed emails - **ADDED**: Support for LiquidJS, Mustache, and custom template engines
- **IMPROVED**: Webpack compatibility with proper dynamic imports
**✨ New Features:** **Template Engine Enhancements:**
- Rich text editor with automatic HTML/text conversion - **NEW**: LiquidJS support (default) with modern syntax and logic
- Template slugs for easier template reference - **NEW**: Mustache support for logic-less templates
- `emailWrapper` hook for consistent email layouts - **NEW**: Custom template renderer hook for maximum flexibility
- Custom rich text editor configuration support - **NEW**: Simple variable replacement as fallback
- Initialization hooks (`onReady`, `initOrder`) for better plugin lifecycle control - **FIXED**: All webpack compatibility issues resolved
- Improved Handlebars variable interpolation with defensive programming
**🐛 Bug Fixes:** **Developer Experience:**
- Fixed text version uppercase conversion in headings - **IMPROVED**: Full TypeScript inference using generated Payload types
- Fixed Handlebars interpolation issues in text version - **IMPROVED**: Comprehensive migration guide and documentation
- Improved plugin initialization order to prevent timing issues - **IMPROVED**: Better error handling and async patterns
- **SIMPLIFIED**: Cleaner codebase with fewer abstractions
**💡 Improvements:**
- Better admin UI with proper array input controls
- More robust error handling and logging
- Enhanced TypeScript definitions
- Simplified template creation workflow
## License ## License

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

31
payload.config.ts Normal file
View File

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

66
pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,179 @@
import { sendEmail } from '../sendEmail.js'
import { Email } from '../payload-types.js'
export interface SendEmailTaskInput {
// Template mode fields
templateSlug?: string
variables?: Record<string, any>
// Direct email mode fields
subject?: string
html?: string
text?: string
// Common fields
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
scheduledAt?: string // ISO date string
priority?: number
// Allow any additional fields that users might have in their email collection
[key: string]: any
}
export const sendEmailJob = {
slug: 'send-email',
label: 'Send Email',
inputSchema: [
{
name: 'templateSlug',
type: 'text' as const,
label: 'Template Slug',
admin: {
description: 'Use a template (leave empty for direct email)',
condition: (data: any) => !data.subject && !data.html
}
},
{
name: 'variables',
type: 'json' as const,
label: 'Template Variables',
admin: {
description: 'JSON object with variables for template rendering',
condition: (data: any) => Boolean(data.templateSlug)
}
},
{
name: 'subject',
type: 'text' as const,
label: 'Subject',
admin: {
description: 'Email subject (required if not using template)',
condition: (data: any) => !data.templateSlug
}
},
{
name: 'html',
type: 'textarea' as const,
label: 'HTML Content',
admin: {
description: 'HTML email content (required if not using template)',
condition: (data: any) => !data.templateSlug
}
},
{
name: 'text',
type: 'textarea' as const,
label: 'Text Content',
admin: {
description: 'Plain text email content (optional)',
condition: (data: any) => !data.templateSlug
}
},
{
name: 'to',
type: 'text' as const,
required: true,
label: 'To (Email Recipients)',
admin: {
description: 'Comma-separated list of email addresses'
}
},
{
name: 'cc',
type: 'text' as const,
label: 'CC (Carbon Copy)',
admin: {
description: 'Optional comma-separated list of CC email addresses'
}
},
{
name: 'bcc',
type: 'text' as const,
label: 'BCC (Blind Carbon Copy)',
admin: {
description: 'Optional comma-separated list of BCC email addresses'
}
},
{
name: 'scheduledAt',
type: 'date' as const,
label: 'Schedule For',
admin: {
description: 'Optional date/time to schedule email for future delivery'
}
},
{
name: 'priority',
type: 'number' as const,
label: 'Priority',
min: 1,
max: 10,
defaultValue: 5,
admin: {
description: 'Email priority (1 = highest, 10 = lowest)'
}
}
],
handler: async ({ input, payload }: any) => {
// Cast input to our expected type
const taskInput = input as SendEmailTaskInput
try {
// Prepare options for sendEmail based on task input
const sendEmailOptions: any = {
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
const email = await sendEmail<Email>(payload, sendEmailOptions)
return {
output: {
success: true,
emailId: email.id,
message: `Email queued successfully with ID: ${email.id}`,
mode: taskInput.templateSlug ? 'template' : 'direct',
templateSlug: taskInput.templateSlug || null,
subject: email.subject,
recipients: Array.isArray(email.to) ? email.to.length : 1,
scheduledAt: email.scheduledAt || null
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
throw new Error(`Failed to queue email: ${errorMessage}`)
}
}
}
export default sendEmailJob

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

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

View File

@@ -3,10 +3,18 @@ import { MailingPluginConfig, MailingContext } from './types/index.js'
import { MailingService } from './services/MailingService.js' import { MailingService } from './services/MailingService.js'
import { createEmailTemplatesCollection } from './collections/EmailTemplates.js' import { createEmailTemplatesCollection } from './collections/EmailTemplates.js'
import Emails from './collections/Emails.js' import Emails from './collections/Emails.js'
import { mailingJobs, scheduleEmailsJob } from './jobs/index.js'
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'
@@ -77,36 +85,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
...(config.jobs || {}), ...(config.jobs || {}),
tasks: [ tasks: [
...(config.jobs?.tasks || []), ...(config.jobs?.tasks || []),
{ ...mailingJobs,
slug: 'process-email-queue',
handler: async ({ job, req }: { job: any; req: any }) => {
try {
const mailingService = new MailingService((req as any).payload, pluginConfig)
console.log('🔄 Processing email queue (pending + failed emails)...')
// Process pending emails first
await mailingService.processEmails()
// Then retry failed emails
await mailingService.retryFailedEmails()
return {
output: {
success: true,
message: 'Email queue processed successfully (pending and failed emails)'
}
}
} catch (error) {
console.error('❌ Error processing email queue:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
// Properly fail the job by throwing the error
throw new Error(`Email queue processing failed: ${errorMessage}`)
}
},
interfaceName: 'ProcessEmailQueueJob',
},
], ],
}, },
onInit: async (payload: any) => { onInit: async (payload: any) => {
@@ -114,7 +93,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
await config.onInit(payload) await config.onInit(payload)
} }
// Initialize mailing service // Initialize mailing service with proper payload instance
const mailingService = new MailingService(payload, pluginConfig) const mailingService = new MailingService(payload, pluginConfig)
// Add mailing context to payload for developer access // Add mailing context to payload for developer access
@@ -130,6 +109,14 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
console.log('PayloadCMS Mailing Plugin initialized successfully') console.log('PayloadCMS Mailing Plugin initialized successfully')
// Schedule the initial email processing job
try {
await scheduleEmailsJob(payload, queueName, 60000) // Schedule in 1 minute
console.log(`🔄 Scheduled initial email processing job in queue: ${queueName}`)
} catch (error) {
console.error('Failed to schedule email processing job:', error)
}
// Call onReady callback if provided // Call onReady callback if provided
if (pluginConfig.onReady) { if (pluginConfig.onReady) {
await pluginConfig.onReady(payload) await pluginConfig.onReady(payload)

102
src/sendEmail.ts Normal file
View File

@@ -0,0 +1,102 @@
import { Payload } from 'payload'
import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js'
import {Email} from "./payload-types.js"
// Options for sending emails
export interface SendEmailOptions<T extends Email = Email> {
// Template-based email
template?: {
slug: string
variables?: Record<string, any>
}
// Direct email data
data?: Partial<T>
// Common options
collectionSlug?: string // defaults to 'emails'
}
/**
* Send an email with full type safety
*
* @example
* ```typescript
* // With your generated Email type
* import { Email } from './payload-types'
*
* const email = await sendEmail<Email>(payload, {
* template: {
* slug: 'welcome',
* variables: { name: 'John' }
* },
* data: {
* to: 'user@example.com',
* customField: 'value' // Your custom fields are type-safe!
* }
* })
* ```
*/
export const sendEmail = async <T extends Email = Email>(
payload: Payload,
options: SendEmailOptions<T>
): Promise<T> => {
const mailing = getMailing(payload)
const collectionSlug = options.collectionSlug || mailing.collections.emails || 'emails'
let emailData: Partial<T> = { ...options.data } as Partial<T>
// If using a template, render it first
if (options.template) {
const { html, text, subject } = await renderTemplate(
payload,
options.template.slug,
options.template.variables || {}
)
// Template values take precedence over data values
emailData = {
...emailData,
subject,
html,
text,
} as Partial<T>
}
// Validate required fields
if (!emailData.to) {
throw new Error('Field "to" is required for sending emails')
}
// Validate required fields based on whether template was used
if (options.template) {
// When using template, subject and html should have been set by renderTemplate
if (!emailData.subject || !emailData.html) {
throw new Error(`Template rendering failed: template "${options.template.slug}" did not provide required subject and html content`)
}
} else {
// When not using template, user must provide subject and html directly
if (!emailData.subject || !emailData.html) {
throw new Error('Fields "subject" and "html" are required when sending direct emails without a template')
}
}
// Process email addresses using shared validation (handle null values)
if (emailData.to) {
emailData.to = parseAndValidateEmails(emailData.to as string | string[])
}
if (emailData.cc) {
emailData.cc = parseAndValidateEmails(emailData.cc as string | string[])
}
if (emailData.bcc) {
emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[])
}
// Create the email in the collection with proper typing
const email = await payload.create({
collection: collectionSlug,
data: emailData
})
return email as T
}
export default sendEmail

View File

@@ -1,9 +1,9 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import Handlebars from 'handlebars' import { Liquid } from 'liquidjs'
import nodemailer, { Transporter } from 'nodemailer' import nodemailer, { Transporter } from 'nodemailer'
import { import {
MailingPluginConfig, MailingPluginConfig,
SendEmailOptions, TemplateVariables,
MailingService as IMailingService, MailingService as IMailingService,
EmailTemplate, EmailTemplate,
QueuedEmail, QueuedEmail,
@@ -13,141 +13,139 @@ import {
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js' import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
export class MailingService implements IMailingService { export class MailingService implements IMailingService {
private payload: Payload public payload: Payload
private config: MailingPluginConfig private config: MailingPluginConfig
private transporter!: Transporter private transporter!: Transporter | any
private templatesCollection: string private templatesCollection: string
private emailsCollection: string private emailsCollection: string
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
this.config = config this.config = config
const templatesConfig = config.collections?.templates const templatesConfig = config.collections?.templates
this.templatesCollection = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates' this.templatesCollection = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates'
const emailsConfig = config.collections?.emails const emailsConfig = config.collections?.emails
this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails' this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails'
this.initializeTransporter() // Only initialize transporter if payload is properly set
this.registerHandlebarsHelpers() if (payload && payload.db) {
this.initializeTransporter()
}
} }
private initializeTransporter(): void { private initializeTransporter(): void {
if (this.transporterInitialized) return
if (this.config.transport) { if (this.config.transport) {
if ('sendMail' in this.config.transport) { if ('sendMail' in this.config.transport) {
this.transporter = this.config.transport this.transporter = this.config.transport
} else { } else {
this.transporter = nodemailer.createTransport(this.config.transport as MailingTransportConfig) 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 { } else {
throw new Error('Email transport configuration is required') throw new Error('Email transport configuration is required either in plugin config or Payload config')
}
this.transporterInitialized = true
}
private ensureInitialized(): void {
if (!this.payload || !this.payload.db) {
throw new Error('MailingService payload not properly initialized')
}
if (!this.transporterInitialized) {
this.initializeTransporter()
} }
} }
private registerHandlebarsHelpers(): void { private getDefaultFrom(): string {
Handlebars.registerHelper('formatDate', (date: Date, format?: string) => { const fromEmail = this.config.defaultFrom
if (!date) return '' const fromName = this.config.defaultFromName
const d = new Date(date)
if (format === 'short') { // Check if fromName exists, is not empty after trimming, and fromEmail exists
return d.toLocaleDateString() if (fromName && fromName.trim() && fromEmail) {
} // Escape quotes in the display name to prevent malformed headers
if (format === 'long') { const escapedName = fromName.replace(/"/g, '\\"')
return d.toLocaleDateString('en-US', { return `"${escapedName}" <${fromEmail}>`
year: 'numeric', }
month: 'long',
day: 'numeric' return fromEmail || ''
}
private async ensureLiquidJSInitialized(): Promise<void> {
if (this.liquid !== null) return // Already initialized or failed
try {
const liquidModule = await import('liquidjs')
const { Liquid: LiquidEngine } = liquidModule
this.liquid = new LiquidEngine()
// Register custom filters (equivalent to Handlebars helpers)
if (this.liquid && typeof this.liquid !== 'boolean') {
this.liquid.registerFilter('formatDate', (date: any, format?: string) => {
if (!date) return ''
const d = new Date(date)
if (format === 'short') {
return d.toLocaleDateString()
}
if (format === 'long') {
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
return d.toLocaleString()
})
this.liquid.registerFilter('formatCurrency', (amount: any, currency = 'USD') => {
if (typeof amount !== 'number') return amount
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(amount)
})
this.liquid.registerFilter('capitalize', (str: any) => {
if (typeof str !== 'string') return str
return str.charAt(0).toUpperCase() + str.slice(1)
}) })
} }
return d.toLocaleString() } catch (error) {
}) console.warn('LiquidJS not available. Falling back to simple variable replacement. Install liquidjs or use a different templateEngine.')
this.liquid = false // Mark as failed to avoid retries
Handlebars.registerHelper('formatCurrency', (amount: number, currency = 'USD') => { }
if (typeof amount !== 'number') return amount
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency
}).format(amount)
})
Handlebars.registerHelper('ifEquals', function(this: any, arg1: any, arg2: any, options: any) {
return (arg1 === arg2) ? options.fn(this) : options.inverse(this)
})
Handlebars.registerHelper('capitalize', (str: string) => {
if (typeof str !== 'string') return str
return str.charAt(0).toUpperCase() + str.slice(1)
})
} }
async sendEmail(options: SendEmailOptions): Promise<string> { async renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> {
const emailId = await this.scheduleEmail({ this.ensureInitialized()
...options, const template = await this.getTemplateBySlug(templateSlug)
scheduledAt: new Date()
})
await this.processEmailItem(emailId) if (!template) {
throw new Error(`Email template not found: ${templateSlug}`)
return emailId
}
async scheduleEmail(options: SendEmailOptions): Promise<string> {
let html = options.html || ''
let text = options.text || ''
let subject = options.subject || ''
let templateId: string | undefined = undefined
if (options.templateSlug) {
const template = await this.getTemplateBySlug(options.templateSlug)
if (template) {
templateId = template.id
const variables = options.variables || {}
const renderedContent = await this.renderEmailTemplate(template, variables)
html = renderedContent.html
text = renderedContent.text
subject = this.renderHandlebarsTemplate(template.subject, variables)
} else {
throw new Error(`Email template not found: ${options.templateSlug}`)
}
} }
if (!subject && !options.subject) { const emailContent = await this.renderEmailTemplate(template, variables)
throw new Error('Email subject is required') const subject = await this.renderTemplateString(template.subject, variables)
return {
html: emailContent.html,
text: emailContent.text,
subject
} }
if (!html && !options.html) {
throw new Error('Email HTML content is required')
}
const queueData = {
template: templateId,
to: Array.isArray(options.to) ? options.to : [options.to],
cc: options.cc ? (Array.isArray(options.cc) ? options.cc : [options.cc]) : undefined,
bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : undefined,
from: options.from || this.config.defaultFrom,
replyTo: options.replyTo,
subject: subject || options.subject,
html,
text,
variables: options.variables,
scheduledAt: options.scheduledAt?.toISOString(),
status: 'pending' as const,
attempts: 0,
priority: options.priority || 5,
}
const result = await this.payload.create({
collection: this.emailsCollection as any,
data: queueData,
})
return result.id as string
} }
async processEmails(): Promise<void> { async processEmails(): Promise<void> {
this.ensureInitialized()
const currentTime = new Date().toISOString() const currentTime = new Date().toISOString()
const { docs: pendingEmails } = await this.payload.find({ const { docs: pendingEmails } = await this.payload.find({
collection: this.emailsCollection as any, collection: this.emailsCollection as any,
where: { where: {
@@ -183,6 +181,7 @@ export class MailingService implements IMailingService {
} }
async retryFailedEmails(): Promise<void> { async retryFailedEmails(): Promise<void> {
this.ensureInitialized()
const maxAttempts = this.config.retryAttempts || 3 const maxAttempts = this.config.retryAttempts || 3
const retryDelay = this.config.retryDelay || 300000 // 5 minutes const retryDelay = this.config.retryDelay || 300000 // 5 minutes
const retryTime = new Date(Date.now() - retryDelay).toISOString() const retryTime = new Date(Date.now() - retryDelay).toISOString()
@@ -242,7 +241,7 @@ export class MailingService implements IMailingService {
}) as QueuedEmail }) as QueuedEmail
let emailObject: EmailObject = { let emailObject: EmailObject = {
from: email.from || this.config.defaultFrom, from: email.from || this.getDefaultFrom(),
to: email.to, to: email.to,
cc: email.cc || undefined, cc: email.cc || undefined,
bcc: email.bcc || undefined, bcc: email.bcc || undefined,
@@ -259,7 +258,7 @@ export class MailingService implements IMailingService {
} }
const mailOptions = { const mailOptions = {
from: emailObject.from || this.config.defaultFrom, from: emailObject.from,
to: emailObject.to, to: emailObject.to,
cc: emailObject.cc || undefined, cc: emailObject.cc || undefined,
bcc: emailObject.bcc || undefined, bcc: emailObject.bcc || undefined,
@@ -331,7 +330,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 EmailTemplate : null
} catch (error) { } catch (error) {
console.error(`Template with slug '${templateSlug}' not found:`, error) console.error(`Template with slug '${templateSlug}' not found:`, error)
@@ -339,14 +338,62 @@ export class MailingService implements IMailingService {
} }
} }
private renderHandlebarsTemplate(template: string, variables: Record<string, any>): string { private async renderTemplateString(template: string, variables: Record<string, any>): Promise<string> {
try { // Use custom template renderer if provided
const compiled = Handlebars.compile(template) if (this.config.templateRenderer) {
return compiled(variables) try {
} catch (error) { return await this.config.templateRenderer(template, variables)
console.error('Handlebars template rendering error:', error) } catch (error) {
return template console.error('Custom template renderer error:', error)
return template
}
} }
const engine = this.config.templateEngine || 'liquidjs'
// Use LiquidJS if configured
if (engine === 'liquidjs') {
try {
await this.ensureLiquidJSInitialized()
if (this.liquid && typeof this.liquid !== 'boolean') {
return await this.liquid.parseAndRender(template, variables)
}
} catch (error) {
console.error('LiquidJS template rendering error:', error)
}
}
// Use Mustache if configured
if (engine === 'mustache') {
try {
const mustacheResult = await this.renderWithMustache(template, variables)
if (mustacheResult !== null) {
return mustacheResult
}
} catch (error) {
console.warn('Mustache not available. Falling back to simple variable replacement. Install mustache package.')
}
}
// Fallback to simple variable replacement
return this.simpleVariableReplacement(template, variables)
}
private async renderWithMustache(template: string, variables: Record<string, any>): Promise<string | null> {
try {
const mustacheModule = await import('mustache')
const Mustache = mustacheModule.default || mustacheModule
return Mustache.render(template, variables)
} catch (error) {
return null
}
}
private simpleVariableReplacement(template: string, variables: Record<string, any>): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
const value = variables[key]
return value !== undefined ? String(value) : match
})
} }
private async renderEmailTemplate(template: EmailTemplate, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> { private async renderEmailTemplate(template: EmailTemplate, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
@@ -358,11 +405,11 @@ export class MailingService implements IMailingService {
let html = serializeRichTextToHTML(template.content) let html = serializeRichTextToHTML(template.content)
let text = serializeRichTextToText(template.content) let text = serializeRichTextToText(template.content)
// Apply Handlebars variables to the rendered content // Apply template variables to the rendered content
html = this.renderHandlebarsTemplate(html, variables) html = await this.renderTemplateString(html, variables)
text = this.renderHandlebarsTemplate(text, variables) text = await this.renderTemplateString(text, variables)
return { html, text } return { html, text }
} }
} }

View File

@@ -1,5 +1,5 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import type { CollectionConfig, RichTextField } from 'payload' import type { CollectionConfig, RichTextField, TypedCollection } from 'payload'
import { Transporter } from 'nodemailer' import { Transporter } from 'nodemailer'
export interface EmailObject { export interface EmailObject {
@@ -16,17 +16,24 @@ export interface EmailObject {
export type EmailWrapperHook = (email: EmailObject) => EmailObject | Promise<EmailObject> export type EmailWrapperHook = (email: EmailObject) => EmailObject | Promise<EmailObject>
export type TemplateRendererHook = (template: string, variables: Record<string, any>) => string | Promise<string>
export type TemplateEngine = 'liquidjs' | 'mustache' | 'simple'
export interface MailingPluginConfig { export interface MailingPluginConfig {
collections?: { collections?: {
templates?: string | Partial<CollectionConfig> templates?: string | Partial<CollectionConfig>
emails?: string | Partial<CollectionConfig> emails?: string | Partial<CollectionConfig>
} }
defaultFrom?: string defaultFrom?: string
defaultFromName?: string
transport?: Transporter | MailingTransportConfig transport?: Transporter | MailingTransportConfig
queue?: string queue?: string
retryAttempts?: number retryAttempts?: number
retryDelay?: number retryDelay?: number
emailWrapper?: EmailWrapperHook emailWrapper?: EmailWrapperHook
templateRenderer?: TemplateRendererHook
templateEngine?: TemplateEngine
richTextEditor?: RichTextField['editor'] richTextEditor?: RichTextField['editor']
onReady?: (payload: any) => Promise<void> onReady?: (payload: any) => Promise<void>
initOrder?: 'before' | 'after' initOrder?: 'before' | 'after'
@@ -76,26 +83,15 @@ export interface QueuedEmail {
updatedAt: string updatedAt: string
} }
export interface SendEmailOptions { // Simple helper type for template variables
templateSlug?: string export interface TemplateVariables {
to: string | string[] [key: string]: any
cc?: string | string[]
bcc?: string | string[]
from?: string
replyTo?: string
subject?: string
html?: string
text?: string
variables?: Record<string, any>
scheduledAt?: Date
priority?: number
} }
export interface MailingService { export interface MailingService {
sendEmail(options: SendEmailOptions): Promise<string>
scheduleEmail(options: SendEmailOptions): Promise<string>
processEmails(): Promise<void> processEmails(): Promise<void>
retryFailedEmails(): Promise<void> retryFailedEmails(): Promise<void>
renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }>
} }
export interface MailingContext { export interface MailingContext {

7
src/types/mustache.d.ts vendored Normal file
View File

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

View File

@@ -1,5 +1,40 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import { SendEmailOptions } from '../types/index.js' import { TemplateVariables } from '../types/index.js'
/**
* Parse and validate email addresses
* @internal
*/
export const parseAndValidateEmails = (emails: string | string[] | null | undefined): string[] | undefined => {
if (!emails || emails === null) return undefined
let emailList: string[]
if (Array.isArray(emails)) {
emailList = emails
} else {
emailList = emails.split(',').map(email => email.trim()).filter(Boolean)
}
// RFC 5322 compliant email validation
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
const invalidEmails = emailList.filter(email => {
// Check basic format
if (!emailRegex.test(email)) return true
// Check for common invalid patterns
if (email.includes('..') || email.startsWith('.') || email.endsWith('.')) return true
if (email.includes('@.') || email.includes('.@')) return true
// Check domain has at least one dot
const parts = email.split('@')
if (parts.length !== 2 || !parts[1].includes('.')) return true
return false
})
if (invalidEmails.length > 0) {
throw new Error(`Invalid email addresses: ${invalidEmails.join(', ')}`)
}
return emailList
}
export const getMailing = (payload: Payload) => { export const getMailing = (payload: Payload) => {
const mailing = (payload as any).mailing const mailing = (payload as any).mailing
@@ -9,14 +44,9 @@ export const getMailing = (payload: Payload) => {
return mailing return mailing
} }
export const sendEmail = async (payload: Payload, options: SendEmailOptions): Promise<string> => { export const renderTemplate = async (payload: Payload, templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> => {
const mailing = getMailing(payload) const mailing = getMailing(payload)
return mailing.service.sendEmail(options) return mailing.service.renderTemplate(templateSlug, variables)
}
export const scheduleEmail = async (payload: Payload, options: SendEmailOptions): Promise<string> => {
const mailing = getMailing(payload)
return mailing.service.scheduleEmail(options)
} }
export const processEmails = async (payload: Payload): Promise<void> => { export const processEmails = async (payload: Payload): Promise<void> => {