Compare commits

..

92 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:18:37 +02:00
Bas
685875d1b9 Merge pull request #28 from xtr-dev/dev
Dev
2025-09-13 23:11:16 +02:00
79044b7bc3 Remove unused BaseEmail imports
- Remove BaseEmail import from sendEmail.ts (no longer used after type refactoring)
- Remove BaseEmail import from sendEmailTask.ts (no longer used after type refactoring)
- BaseEmail types are still used in MailingService.ts for proper type casting

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:06:02 +02:00
790eedfee7 Bump package version to 0.1.12 in package.json. 2025-09-13 23:01:06 +02:00
9520ec5ed1 Refactor email types for enhanced consistency and type safety
- Replace `EmailTemplate` with `BaseEmailTemplate` for stricter type validation.
- Update `sendEmail` and `sendEmailTask` to utilize refined `BaseEmail` structure.
- Simplify type definitions in `MailingService` and related modules.
2025-09-13 23:00:41 +02:00
Bas
768b70a003 Merge pull request #27 from xtr-dev/dev
Align `sendEmail` and `sendEmailTask` with updated `BaseEmail` typing
2025-09-13 22:49:05 +02:00
e91ab7e54e Bump package version to 0.1.11 in package.json. 2025-09-13 22:48:55 +02:00
06f9c2cb5b Align sendEmail and sendEmailTask with updated BaseEmail typing
- Refactor `sendEmail` to return extended type with `id` for better type inference.
- Update `sendEmailTask` to use `BaseEmail` instead of `Email`.
- Add `outputSchema` in `sendEmailTask` for consistent output structure.
2025-09-13 22:46:30 +02:00
Bas
21b22a033a Merge pull request #26 from xtr-dev/dev
Refactor `sendEmail` to improve type safety and align with `BaseEmail…
2025-09-13 22:41:28 +02:00
6ad90874cf Refactor sendEmail to improve type safety and align with BaseEmail interface
- Replace `Email` with `BaseEmail` for stricter type validation.
- Update `SendEmailOptions` and `sendEmail` typing for improved extensibility.
2025-09-13 22:39:28 +02:00
Bas
03f1f62fbf Merge pull request #25 from xtr-dev/dev
Remove `emailWrapper` hook and all associated references.
2025-09-13 22:34:48 +02:00
e55e4197d3 Bump package version to 0.1.9 in package.json. 2025-09-13 22:32:19 +02:00
2e6feccf54 Remove emailWrapper hook and all associated references.
- Simplified email sending logic by dropping custom layout wrapping.
- Updated service, config, types, and readme to remove `emailWrapper` usage.
- Retained focus on core email functionality while ensuring consistent formatting.
2025-09-13 22:31:05 +02:00
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
24 changed files with 1981 additions and 794 deletions

767
README.md
View File

@@ -6,12 +6,19 @@
## Features
**Template System**: Create reusable email templates with Handlebars syntax
**Outbox Scheduling**: Schedule emails for future delivery
**Template System**: Create reusable email templates with LiquidJS, Mustache, or simple variables
**Type Safety**: Full TypeScript support using your generated Payload types
**Flexible Template Engines**: LiquidJS, Mustache, or bring your own template renderer
**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
**Template Variables**: Dynamic content with validation
**Developer API**: Simple methods for sending emails programmatically
**Payload Native**: Uses Payload collections directly - no custom APIs to learn
## Installation
@@ -49,29 +56,51 @@ export default buildConfig({
})
```
### 2. Send emails in your code
### 2. Send emails with type-safe helper
```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
const emailId = await sendEmail(payload, {
templateSlug: 'welcome-email',
to: 'user@example.com',
// Option 1: Using templates with full type safety
const email = await sendEmail<Email>(payload, {
template: {
slug: 'welcome-email',
variables: {
firstName: 'John',
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
const scheduledId = await scheduleEmail(payload, {
templateSlug: 'reminder-email',
to: 'user@example.com',
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
variables: {
eventName: 'Product Launch',
eventDate: new Date('2024-01-15')
// Option 2: Direct HTML email (no template)
const directEmail = await sendEmail<Email>(payload, {
data: {
to: ['user@example.com', 'another@example.com'],
subject: 'Welcome!',
html: '<h1>Welcome John!</h1><p>Thanks for joining!</p>',
text: 'Welcome John! Thanks for joining!',
// All your custom fields work with TypeScript autocomplete!
customField: 'value',
}
})
// Option 3: Use payload.create() directly for full control
const manualEmail = await payload.create({
collection: 'emails',
data: {
to: ['user@example.com'],
subject: 'Hello',
html: '<p>Hello World</p>',
}
})
```
@@ -81,21 +110,80 @@ const scheduledId = await scheduleEmail(payload, {
### Plugin Options
```typescript
interface MailingPluginConfig {
collections?: {
templates?: string // default: 'email-templates'
emails?: string // default: 'emails'
mailingPlugin({
// Template engine (optional)
templateEngine: 'liquidjs', // 'liquidjs' | 'mustache' | 'simple'
// 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
richTextEditor: lexicalEditor(), // optional custom editor
onReady: async (payload) => { // optional initialization hook
console.log('Mailing plugin ready!')
},
// beforeSend hook - modify emails before sending
beforeSend: async (options, email) => {
// Add attachments, modify headers, etc.
options.attachments = [
{ filename: 'invoice.pdf', content: pdfBuffer }
]
options.headers = {
'X-Campaign-ID': email.campaignId
}
defaultFrom?: string
transport?: Transporter | MailingTransportConfig
queue?: string // default: 'default'
retryAttempts?: number // default: 3
retryDelay?: number // default: 300000 (5 minutes)
emailWrapper?: EmailWrapperHook // optional email layout wrapper
richTextEditor?: RichTextField['editor'] // optional custom rich text editor
onReady?: (payload: any) => Promise<void> // optional callback after plugin initialization
initOrder?: 'before' | 'after' // default: 'before'
return options
}
})
```
### Template Engine Options
Choose your preferred template engine:
```typescript
// 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
@@ -147,7 +235,7 @@ Thanks for joining {{siteName}}. We're excited to have you!
**What you can do:**
• Create beautiful emails with rich text formatting
• Use the emailWrapper hook to add custom layouts
• Queue and schedule emails effortlessly
Your account was created on {{formatDate createdAt "long"}}.
@@ -158,282 +246,6 @@ The {{siteName}} Team
## Advanced Features
### Custom HTML Layouts with Email Wrapper Hook
The `emailWrapper` hook allows you to apply consistent HTML layouts and styling to all emails sent through the plugin. This is perfect for adding company branding, headers, footers, and responsive styling.
#### Basic Email Wrapper
```typescript
mailingPlugin({
// ... other config
emailWrapper: (email) => {
const wrappedHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${email.subject}</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f4f4f4; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; }
.header { background: #007bff; color: white; padding: 20px; text-align: center; }
.content { padding: 30px; line-height: 1.6; }
.footer { background: #f8f9fa; padding: 15px; text-align: center; color: #6c757d; font-size: 14px; }
/* Responsive styles */
@media only screen and (max-width: 600px) {
.container { margin: 0 10px; }
.content { padding: 20px; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>My Company</h1>
</div>
<div class="content">
${email.html}
</div>
<div class="footer">
© 2024 My Company. All rights reserved.<br>
<a href="#" style="color: #007bff;">Unsubscribe</a> |
<a href="#" style="color: #007bff;">Contact Support</a>
</div>
</div>
</body>
</html>
`
return {
...email,
html: wrappedHtml,
text: `MY COMPANY\n\n${email.text}\n\n© 2024 My Company\nUnsubscribe: [link] | Contact Support: [link]`
}
}
})
```
#### Advanced Email Wrapper with Dynamic Content
```typescript
mailingPlugin({
// ... other config
emailWrapper: (email) => {
// You can access email properties and customize based on content
const isTransactional = email.subject?.includes('Receipt') || email.subject?.includes('Confirmation');
const headerColor = isTransactional ? '#28a745' : '#007bff';
const headerText = isTransactional ? 'Order Confirmation' : 'My Company';
const wrappedHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>${email.subject}</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style>
/* Reset styles */
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
/* Base styles */
body {
margin: 0 !important;
padding: 0 !important;
background-color: #f4f4f4;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.email-header {
background: linear-gradient(135deg, ${headerColor}, ${headerColor}dd);
color: white;
padding: 30px 20px;
text-align: center;
}
.email-content {
padding: 30px;
color: #333333;
line-height: 1.6;
}
.email-footer {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
color: #6c757d;
font-size: 14px;
border-top: 1px solid #e9ecef;
}
.email-footer a {
color: ${headerColor};
text-decoration: none;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0 10px !important;
}
.email-header, .email-content {
padding: 20px !important;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h1 style="margin: 0; font-size: 24px;">${headerText}</h1>
</div>
<div class="email-content">
${email.html}
</div>
<div class="email-footer">
<p style="margin: 0 0 10px;">© ${new Date().getFullYear()} My Company. All rights reserved.</p>
<p style="margin: 0;">
<a href="mailto:support@mycompany.com">Contact Support</a> |
<a href="#">Privacy Policy</a> |
<a href="#">Unsubscribe</a>
</p>
</div>
</div>
</body>
</html>
`
// Also enhance the plain text version
const wrappedText = `
${headerText.toUpperCase()}
${'='.repeat(headerText.length)}
${email.text || email.html?.replace(/<[^>]*>/g, '')}
---
© ${new Date().getFullYear()} My Company. All rights reserved.
Contact Support: support@mycompany.com
Privacy Policy: [link]
Unsubscribe: [link]
`.trim();
return {
...email,
html: wrappedHtml,
text: wrappedText
}
}
})
```
#### External CSS and Assets
You can also reference external stylesheets and assets:
```typescript
mailingPlugin({
emailWrapper: (email) => {
const wrappedHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${email.subject}</title>
<!-- External CSS -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
/* Your custom styles here */
</style>
</head>
<body>
<div style="max-width: 600px; margin: 0 auto;">
<img src="https://mycompany.com/email-header.png" alt="My Company" style="width: 100%; height: auto;">
<div style="padding: 20px;">
${email.html}
</div>
<img src="https://mycompany.com/email-footer.png" alt="Footer" style="width: 100%; height: auto;">
</div>
</body>
</html>
`;
return { ...email, html: wrappedHtml };
}
})
```
#### Template-Specific Layouts
You can customize layouts based on email templates:
```typescript
mailingPlugin({
emailWrapper: (email, context) => {
// Access template information if available
const templateSlug = context?.templateSlug;
let layoutClass = 'default-layout';
let headerColor = '#007bff';
if (templateSlug === 'welcome-email') {
layoutClass = 'welcome-layout';
headerColor = '#28a745';
} else if (templateSlug === 'invoice-email') {
layoutClass = 'invoice-layout';
headerColor = '#dc3545';
}
const wrappedHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${email.subject}</title>
<style>
.${layoutClass} { /* template-specific styles */ }
.header { background-color: ${headerColor}; }
</style>
</head>
<body>
<div class="${layoutClass}">
${email.html}
</div>
</body>
</html>
`;
return { ...email, html: wrappedHtml };
}
})
```
The `emailWrapper` hook receives the email object with `html`, `text`, and `subject` properties, and should return the modified email object with your custom layout applied.
### Custom Rich Text Editor
Override the rich text editor used for templates:
@@ -455,6 +267,56 @@ mailingPlugin({
})
```
### beforeSend Hook
Modify emails before they are sent to add attachments, custom headers, or make other changes:
```typescript
mailingPlugin({
// ... other config
beforeSend: async (options, email) => {
// Add attachments dynamically
if (email.invoiceId) {
const invoice = await generateInvoicePDF(email.invoiceId)
options.attachments = [
{
filename: `invoice-${email.invoiceId}.pdf`,
content: invoice.buffer,
contentType: 'application/pdf'
}
]
}
// Add custom headers
options.headers = {
'X-Campaign-ID': email.campaignId,
'X-Customer-ID': email.customerId,
'X-Priority': email.priority === 1 ? 'High' : 'Normal'
}
// Modify recipients based on conditions
if (process.env.NODE_ENV === 'development') {
// Redirect all emails to test address in dev
options.to = ['test@example.com']
options.subject = `[TEST] ${options.subject}`
}
// Add BCC for compliance
if (email.requiresAudit) {
options.bcc = ['audit@company.com']
}
return options
}
})
```
The `beforeSend` hook receives:
- `options`: The nodemailer mail options that will be sent
- `email`: The full email document from the database
You must return the modified options object.
### Initialization Hooks
Control plugin initialization order and add post-initialization logic:
@@ -473,73 +335,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)
- `{{formatCurrency amount 'USD'}}` - Format currency
- `{{capitalize string}}` - Capitalize first letter
- `{{#ifEquals value1 value2}}...{{/ifEquals}}` - Conditional equality
### LiquidJS (Default)
- Variables: `{{ user.name }}`
- Logic: `{% if user.isPremium %}Premium content{% endif %}`
- 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
const emailId = await sendEmail(payload, {
templateSlug: 'order-confirmation', // optional - use template slug
to: ['customer@example.com'], // string or array of emails
cc: ['manager@example.com'], // optional - array of emails
bcc: ['archive@example.com'], // optional - array of emails
from: 'orders@yoursite.com', // optional, uses default
replyTo: 'support@yoursite.com', // optional
subject: 'Custom subject', // required if no template
html: '<h1>Custom HTML</h1>', // required if no template
text: 'Custom text version', // optional
variables: { // template variables
orderNumber: '12345',
customerName: 'John Doe'
},
priority: 1 // optional, 1-10 (1 = highest)
import {
renderTemplate, // Render email templates with variables
processEmails, // Process pending emails manually
retryFailedEmails, // Retry failed emails
getMailing // Get mailing service instance
} from '@xtr-dev/payload-mailing'
// Render a template
const { html, text, subject } = await renderTemplate(payload, 'welcome', {
name: 'John',
activationUrl: 'https://example.com/activate'
})
// 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
const emailId = await scheduleEmail(payload, {
templateSlug: 'newsletter',
to: ['user1@example.com', 'user2@example.com'],
scheduledAt: new Date('2024-01-15T10:00:00Z'),
variables: {
month: 'January',
highlights: ['Feature A', 'Feature B']
import { buildConfig } from 'payload/config'
import { sendTemplateEmailTask } from '@xtr-dev/payload-mailing'
export default buildConfig({
// ... your config
jobs: {
tasks: [
sendTemplateEmailTask,
// ... your other tasks
]
}
})
```
### processEmails(payload)
Manually process pending emails:
### 2. Queue emails from your code
```typescript
import { processEmails } from '@xtr-dev/payload-mailing'
await processEmails(payload)
import type { SendTemplateEmailInput } from '@xtr-dev/payload-mailing'
// Queue a template email
const result = await payload.jobs.queue({
task: 'send-template-email',
input: {
templateSlug: 'welcome-email',
to: ['user@example.com'],
cc: ['manager@example.com'],
variables: {
firstName: 'John',
activationUrl: 'https://example.com/activate/123'
},
priority: 1,
// Add any custom fields from your email collection
customField: 'value'
} as SendTemplateEmailInput
})
// Queue a scheduled email
await payload.jobs.queue({
task: 'send-template-email',
input: {
templateSlug: 'reminder-email',
to: ['user@example.com'],
variables: { eventName: 'Product Launch' },
scheduledAt: new Date('2024-01-15T10:00:00Z').toISOString(),
priority: 3
}
})
```
### 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
import { retryFailedEmails } from '@xtr-dev/payload-mailing'
await retryFailedEmails(payload)
```
### Task Benefits
-**Easy Integration**: Just add to your tasks array
-**Type Safety**: Full TypeScript support with `SendTemplateEmailInput`
-**Admin UI**: Ready-to-use form interface
-**Flexible**: Supports all your custom email collection fields
-**Error Handling**: Comprehensive error reporting
-**Queue Management**: Leverage Payload's job queue system
## Job Processing
@@ -716,35 +632,134 @@ import {
} 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
### v0.0.x (Latest)
### v0.1.0 (Latest - Breaking Changes)
**🔄 Breaking Changes:**
- Removed email layouts system in favor of `emailWrapper` hook for better flexibility
- Email fields (`to`, `cc`, `bcc`) now use `hasMany: true` for proper array handling
- Templates now use slug-based lookup instead of ID-based for developer-friendly API
- Email collection renamed from "outbox" to "emails"
- Unified job processing: single `process-email-queue` job handles both pending and failed emails
**🚀 Major API Simplification:**
- **REMOVED**: `sendEmail()` and `scheduleEmail()` wrapper methods
- **REMOVED**: `SendEmailOptions` custom types
- **ADDED**: Direct Payload collection usage with full type safety
- **ADDED**: `renderTemplate()` helper for template rendering
- **ADDED**: Support for LiquidJS, Mustache, and custom template engines
- **IMPROVED**: Webpack compatibility with proper dynamic imports
**✨ New Features:**
- Rich text editor with automatic HTML/text conversion
- Template slugs for easier template reference
- `emailWrapper` hook for consistent email layouts
- Custom rich text editor configuration support
- Initialization hooks (`onReady`, `initOrder`) for better plugin lifecycle control
- Improved Handlebars variable interpolation with defensive programming
**Template Engine Enhancements:**
- **NEW**: LiquidJS support (default) with modern syntax and logic
- **NEW**: Mustache support for logic-less templates
- **NEW**: Custom template renderer hook for maximum flexibility
- **NEW**: Simple variable replacement as fallback
- **FIXED**: All webpack compatibility issues resolved
**🐛 Bug Fixes:**
- Fixed text version uppercase conversion in headings
- Fixed Handlebars interpolation issues in text version
- Improved plugin initialization order to prevent timing issues
**💡 Improvements:**
- Better admin UI with proper array input controls
- More robust error handling and logging
- Enhanced TypeScript definitions
- Simplified template creation workflow
**Developer Experience:**
- **IMPROVED**: Full TypeScript inference using generated Payload types
- **IMPROVED**: Comprehensive migration guide and documentation
- **IMPROVED**: Better error handling and async patterns
- **SIMPLIFIED**: Cleaner codebase with fewer abstractions
## License

View File

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

View File

@@ -1,37 +1,50 @@
import { getPayload } from 'payload'
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) {
try {
const payload = await getPayload({ config })
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
if (type === 'send') {
// Send immediately
result = await sendEmail(payload, {
templateSlug,
// Use the new sendEmail API
const emailOptions: any = {
data: {
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({
success: true,
emailId: result,
message: type === 'send' ? 'Email sent successfully' : 'Email scheduled successfully',
emailId: result.id,
message: scheduledAt ? 'Email scheduled successfully' : 'Email queued successfully',
})
} catch (error) {
console.error('Test email error:', error)

View File

@@ -100,7 +100,8 @@ export interface Config {
};
jobs: {
tasks: {
'process-email-queue': ProcessEmailQueueJob;
processEmails: ProcessEmailsJob;
'send-email': TaskSendEmail;
inline: {
input: unknown;
output: unknown;
@@ -232,25 +233,25 @@ export interface Email {
*/
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 email address(es), comma-separated
*/
bcc?: string | null;
bcc?: string[] | null;
/**
* Sender email address (optional, uses default if not provided)
*/
from?: string | null;
/**
* Sender display name (optional, e.g., "John Doe" for "John Doe <john@example.com>")
*/
fromName?: string | null;
/**
* Reply-to email address
*/
@@ -362,7 +363,7 @@ export interface PayloadJob {
| {
executedAt: string;
completedAt: string;
taskSlug: 'inline' | 'process-email-queue';
taskSlug: 'inline' | 'processEmails' | 'send-email';
taskID: string;
input?:
| {
@@ -395,7 +396,7 @@ export interface PayloadJob {
id?: string | null;
}[]
| null;
taskSlug?: ('inline' | 'process-email-queue') | null;
taskSlug?: ('inline' | 'processEmails' | 'send-email') | null;
queue?: string | null;
waitUntil?: string | null;
processing?: boolean | null;
@@ -542,11 +543,11 @@ export interface EmailTemplatesSelect<T extends boolean = true> {
*/
export interface EmailsSelect<T extends boolean = true> {
template?: T;
templateSlug?: T;
to?: T;
cc?: T;
bcc?: T;
from?: T;
fromName?: T;
replyTo?: T;
subject?: T;
html?: T;
@@ -627,12 +628,83 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
}
/**
* 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;
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 sender email address (uses default if not provided)
*/
from?: string | null;
/**
* Optional sender display name (e.g., "John Doe")
*/
fromName?: string | null;
/**
* Optional reply-to email address
*/
replyTo?: string | null;
/**
* Optional date/time to schedule email for future delivery
*/
scheduledAt?: string | null;
/**
* Email priority (1 = highest, 10 = lowest)
*/
priority?: number | null;
};
output: {
id?: string | null;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".

View File

@@ -1,12 +1,10 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import {
BlocksFeature,
FixedToolbarFeature,
HeadingFeature,
HorizontalRuleFeature,
InlineToolbarFeature,
lexicalHTML,
} from '@payloadcms/richtext-lexical'
import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path'
@@ -17,7 +15,7 @@ import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
import { seed, seedUser } from './seed.js'
import mailingPlugin from "../src/plugin.js"
import { sendEmail } from "../src/utils/helpers.js"
import {sendEmail} from "@xtr-dev/payload-mailing"
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -85,8 +83,8 @@ const buildConfigWithMemoryDB = async () => {
// Queue the welcome email using template slug
const emailId = await sendEmail(req.payload, {
templateSlug: 'welcome-email',
to: doc.email,
template: {
slug: 'welcome-email',
variables: {
firstName: doc.firstName || doc.email?.split('@')?.[0],
siteName: 'PayloadCMS Mailing Demo',
@@ -94,6 +92,10 @@ const buildConfigWithMemoryDB = async () => {
isPremium: false,
dashboardUrl: 'http://localhost:3000/admin',
},
},
data: {
to: doc.email,
}
})
console.log('✅ Welcome email queued successfully. Email ID:', emailId)
@@ -280,56 +282,6 @@ const buildConfigWithMemoryDB = async () => {
],
}),
emailWrapper: (email) => {
// Example: wrap email content in a custom layout
const wrappedHtml = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${email.subject}</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; overflow: hidden; }
.header { background: #007bff; color: white; padding: 20px; text-align: center; }
.content { padding: 30px; }
.footer { background: #f8f9fa; padding: 15px; text-align: center; font-size: 12px; color: #6c757d; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>My Company</h1>
</div>
<div class="content">
${email.html}
</div>
<div class="footer">
This email was sent from My Company. If you have questions, contact support@mycompany.com
</div>
</div>
</body>
</html>
`
const wrappedText = `
MY COMPANY
==========
${email.text || email.html?.replace(/<[^>]*>/g, '')}
---
This email was sent from My Company.
If you have questions, contact support@mycompany.com
`
return {
...email,
html: wrappedHtml,
text: wrappedText.trim(),
}
},
// Called after mailing plugin is fully initialized
onReady: async (payload) => {

View File

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

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

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

View File

@@ -1,10 +1,10 @@
// 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('✅ mailingPlugin:', typeof mailingPlugin)
console.log('✅ sendEmail:', typeof sendEmail)
console.log('✅ scheduleEmail:', typeof scheduleEmail)
console.log('✅ renderTemplate:', typeof renderTemplate)
// Test plugin configuration
try {

View File

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

View File

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

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

View File

@@ -49,6 +49,13 @@ const Emails: CollectionConfig = {
description: 'Sender email address (optional, uses default if not provided)',
},
},
{
name: 'fromName',
type: 'text',
admin: {
description: 'Sender display name (optional, e.g., "John Doe" for "John Doe <john@example.com>")',
},
},
{
name: 'replyTo',
type: 'text',

View File

@@ -11,13 +11,19 @@ export { MailingService } from './services/MailingService.js'
export { default as EmailTemplates, createEmailTemplatesCollection } from './collections/EmailTemplates.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
export {
getMailing,
sendEmail,
scheduleEmail,
renderTemplate,
processEmails,
retryFailedEmails,
parseAndValidateEmails,
} from './utils/helpers.js'

View File

@@ -1,19 +1,35 @@
import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js'
import { sendEmailJob } from './sendEmailTask.js'
import { MailingService } from '../services/MailingService.js'
export const createMailingJobs = (mailingService: MailingService): any[] => {
return [
export const mailingJobs = [
{
slug: 'processEmails',
handler: async ({ job, req }: { job: any; req: any }) => {
return processEmailsJob(
// Get mailing context from payload
const payload = (req as any).payload
const mailingContext = payload.mailing
if (!mailingContext) {
throw new Error('Mailing plugin not properly initialized')
}
// Use the existing mailing service from context
await processEmailsJob(
job as { data: ProcessEmailsJobData },
{ req, mailingService }
{ 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'
export interface ProcessEmailsJobData {
type: 'process-emails' | 'retry-failed'
// No type needed - always processes both pending and failed emails
}
export const processEmailsJob = async (
@@ -10,18 +10,19 @@ export const processEmailsJob = async (
context: { req: PayloadRequest; mailingService: MailingService }
) => {
const { mailingService } = context
const { type } = job.data
try {
if (type === 'process-emails') {
console.log('🔄 Processing email queue (pending + failed emails)...')
// Process pending emails first
await mailingService.processEmails()
console.log('Email processing completed successfully')
} else if (type === 'retry-failed') {
// Then retry failed emails
await mailingService.retryFailedEmails()
console.log('Failed email retry completed successfully')
}
console.log('✅ Email queue processing completed successfully (pending and failed emails)')
} catch (error) {
console.error(`${type} job failed:`, error)
console.error('❌ Email queue processing failed:', error)
throw error
}
}
@@ -29,7 +30,6 @@ export const processEmailsJob = async (
export const scheduleEmailsJob = async (
payload: any,
queueName: string,
jobType: 'process-emails' | 'retry-failed',
delay?: number
) => {
if (!payload.jobs) {
@@ -41,10 +41,10 @@ export const scheduleEmailsJob = async (
await payload.jobs.queue({
queue: queueName,
task: 'processEmails',
input: { type: jobType },
input: {},
waitUntil: delay ? new Date(Date.now() + delay) : undefined,
})
} catch (error) {
console.error(`Failed to schedule ${jobType} job:`, error)
console.error('Failed to schedule email processing job:', error)
}
}

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

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

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

@@ -0,0 +1,436 @@
/* 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;
/**
* Sender display name (optional, e.g., "John Doe" for "John Doe <john@example.com>")
*/
fromName?: 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;
fromName?: 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 { createEmailTemplatesCollection } from './collections/EmailTemplates.js'
import Emails from './collections/Emails.js'
import { mailingJobs, scheduleEmailsJob } from './jobs/index.js'
export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
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
const templatesConfig = pluginConfig.collections?.templates
const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates'
@@ -66,10 +74,15 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
}),
} satisfies CollectionConfig
// Filter out any existing collections with the same slugs to prevent duplicates
const existingCollections = (config.collections || []).filter(
(collection) => collection.slug !== templatesSlug && collection.slug !== emailsSlug
)
return {
...config,
collections: [
...(config.collections || []),
...existingCollections,
templatesCollection,
emailsCollection,
],
@@ -77,36 +90,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
...(config.jobs || {}),
tasks: [
...(config.jobs?.tasks || []),
{
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',
},
...mailingJobs,
],
},
onInit: async (payload: any) => {
@@ -114,7 +98,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
await config.onInit(payload)
}
// Initialize mailing service
// Initialize mailing service with proper payload instance
const mailingService = new MailingService(payload, pluginConfig)
// Add mailing context to payload for developer access
@@ -130,6 +114,14 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
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
if (pluginConfig.onReady) {
await pluginConfig.onReady(payload)

145
src/sendEmail.ts Normal file
View File

@@ -0,0 +1,145 @@
import { Payload } from 'payload'
import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js'
import { BaseEmailDocument } from './types/index.js'
// Options for sending emails
export interface SendEmailOptions<T extends BaseEmailDocument = BaseEmailDocument> {
// Template-based email
template?: {
slug: string
variables?: Record<string, any>
}
// Direct email data
data?: Partial<T>
// Common options
collectionSlug?: string // defaults to 'emails'
}
/**
* Send an email with full type safety
*
* @example
* ```typescript
* // With your generated Email type
* import { Email } from './payload-types'
*
* const email = await sendEmail<Email>(payload, {
* template: {
* slug: 'welcome',
* variables: { name: 'John' }
* },
* data: {
* to: 'user@example.com',
* customField: 'value' // Your custom fields are type-safe!
* }
* })
* ```
*/
export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocument>(
payload: Payload,
options: SendEmailOptions<TEmail>
): Promise<TEmail> => {
const mailing = getMailing(payload)
const collectionSlug = options.collectionSlug || mailing.collections.emails || 'emails'
let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail>
// If using a template, render it first
if (options.template) {
const { html, text, subject } = await renderTemplate(
payload,
options.template.slug,
options.template.variables || {}
)
// Template values take precedence over data values
emailData = {
...emailData,
subject,
html,
text,
} as Partial<TEmail>
}
// Validate required fields
if (!emailData.to) {
throw new Error('Field "to" is required for sending emails')
}
// Validate required fields based on whether template was used
if (options.template) {
// When using template, subject and html should have been set by renderTemplate
if (!emailData.subject || !emailData.html) {
throw new Error(`Template rendering failed: template "${options.template.slug}" did not provide required subject and html content`)
}
} else {
// When not using template, user must provide subject and html directly
if (!emailData.subject || !emailData.html) {
throw new Error('Fields "subject" and "html" are required when sending direct emails without a template')
}
}
// Process email addresses using shared validation (handle null values)
if (emailData.to) {
emailData.to = parseAndValidateEmails(emailData.to as string | string[])
}
if (emailData.cc) {
emailData.cc = parseAndValidateEmails(emailData.cc as string | string[])
}
if (emailData.bcc) {
emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[])
}
if (emailData.replyTo) {
const validated = parseAndValidateEmails(emailData.replyTo as string | string[])
// replyTo should be a single email, so take the first one if array
emailData.replyTo = validated && validated.length > 0 ? validated[0] : undefined
}
if (emailData.from) {
const validated = parseAndValidateEmails(emailData.from as string | string[])
// from should be a single email, so take the first one if array
emailData.from = validated && validated.length > 0 ? validated[0] : undefined
}
// Sanitize fromName to prevent header injection
if (emailData.fromName) {
emailData.fromName = emailData.fromName
.trim()
// Remove/replace newlines and carriage returns to prevent header injection
.replace(/[\r\n]/g, ' ')
// Remove control characters (except space and printable characters)
.replace(/[\x00-\x1F\x7F-\x9F]/g, '')
// Note: We don't escape quotes here as that's handled in MailingService
}
// Normalize Date objects to ISO strings for consistent database storage
if (emailData.scheduledAt instanceof Date) {
emailData.scheduledAt = emailData.scheduledAt.toISOString()
}
if (emailData.sentAt instanceof Date) {
emailData.sentAt = emailData.sentAt.toISOString()
}
if (emailData.lastAttemptAt instanceof Date) {
emailData.lastAttemptAt = emailData.lastAttemptAt.toISOString()
}
if (emailData.createdAt instanceof Date) {
emailData.createdAt = emailData.createdAt.toISOString()
}
if (emailData.updatedAt instanceof Date) {
emailData.updatedAt = emailData.updatedAt.toISOString()
}
// Create the email in the collection with proper typing
const email = await payload.create({
collection: collectionSlug,
data: emailData
})
// Validate that the created email has the expected structure
if (!email || typeof email !== 'object' || !email.id) {
throw new Error('Failed to create email: invalid response from database')
}
return email as TEmail
}
export default sendEmail

View File

@@ -1,23 +1,23 @@
import { Payload } from 'payload'
import Handlebars from 'handlebars'
import { Liquid } from 'liquidjs'
import nodemailer, { Transporter } from 'nodemailer'
import {
MailingPluginConfig,
SendEmailOptions,
TemplateVariables,
MailingService as IMailingService,
EmailTemplate,
QueuedEmail,
MailingTransportConfig,
EmailObject
BaseEmail, BaseEmailTemplate, BaseEmailDocument, BaseEmailTemplateDocument
} from '../types/index.js'
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
export class MailingService implements IMailingService {
private payload: Payload
public payload: Payload
private config: MailingPluginConfig
private transporter!: Transporter
private transporter!: Transporter | any
private templatesCollection: string
private emailsCollection: string
private liquid: Liquid | null | false = null
private transporterInitialized = false
constructor(payload: Payload, config: MailingPluginConfig) {
this.payload = payload
@@ -30,23 +30,85 @@ export class MailingService implements IMailingService {
this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails'
this.initializeTransporter()
this.registerHandlebarsHelpers()
}
private initializeTransporter(): void {
if (this.transporterInitialized) return
if (this.config.transport) {
if ('sendMail' in this.config.transport) {
this.transporter = this.config.transport
} else {
this.transporter = nodemailer.createTransport(this.config.transport as MailingTransportConfig)
}
} else if (this.payload.email && 'sendMail' in this.payload.email) {
// Use Payload's configured mailer (cast to any to handle different adapter types)
this.transporter = this.payload.email as any
} else {
throw new Error('Email transport configuration is required')
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 {
Handlebars.registerHelper('formatDate', (date: Date, format?: string) => {
/**
* Sanitizes a display name for use in email headers to prevent header injection
* and ensure proper formatting
*/
private sanitizeDisplayName(name: string): string {
return name
.trim()
// Remove/replace newlines and carriage returns to prevent header injection
.replace(/[\r\n]/g, ' ')
// Remove control characters (except space and printable characters)
.replace(/[\x00-\x1F\x7F-\x9F]/g, '')
// Escape quotes to prevent malformed headers
.replace(/"/g, '\\"')
}
/**
* Formats an email address with optional display name
*/
private formatEmailAddress(email: string, displayName?: string | null): string {
if (displayName && displayName.trim()) {
const sanitizedName = this.sanitizeDisplayName(displayName)
return `"${sanitizedName}" <${email}>`
}
return email
}
private getDefaultFrom(): string {
const fromEmail = this.config.defaultFrom
const fromName = this.config.defaultFromName
// Check if fromName exists, is not empty after trimming, and fromEmail exists
if (fromName && fromName.trim() && fromEmail) {
return this.formatEmailAddress(fromEmail, fromName)
}
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') {
@@ -62,7 +124,7 @@ export class MailingService implements IMailingService {
return d.toLocaleString()
})
Handlebars.registerHelper('formatCurrency', (amount: number, currency = 'USD') => {
this.liquid.registerFilter('formatCurrency', (amount: any, currency = 'USD') => {
if (typeof amount !== 'number') return amount
return new Intl.NumberFormat('en-US', {
style: 'currency',
@@ -70,82 +132,37 @@ export class MailingService implements IMailingService {
}).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) => {
this.liquid.registerFilter('capitalize', (str: any) => {
if (typeof str !== 'string') return str
return str.charAt(0).toUpperCase() + str.slice(1)
})
}
async sendEmail(options: SendEmailOptions): Promise<string> {
const emailId = await this.scheduleEmail({
...options,
scheduledAt: new Date()
})
await this.processEmailItem(emailId)
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}`)
} 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
}
}
if (!subject && !options.subject) {
throw new Error('Email subject is required')
async renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> {
this.ensureInitialized()
const template = await this.getTemplateBySlug(templateSlug)
if (!template) {
throw new Error(`Email template not found: ${templateSlug}`)
}
if (!html && !options.html) {
throw new Error('Email HTML content is required')
const emailContent = await this.renderEmailTemplate(template, variables)
const subject = await this.renderTemplateString(template.subject || '', variables)
return {
html: emailContent.html,
text: emailContent.text,
subject
}
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> {
this.ensureInitialized()
const currentTime = new Date().toISOString()
const { docs: pendingEmails } = await this.payload.find({
@@ -183,6 +200,7 @@ export class MailingService implements IMailingService {
}
async retryFailedEmails(): Promise<void> {
this.ensureInitialized()
const maxAttempts = this.config.retryAttempts || 3
const retryDelay = this.config.retryDelay || 300000 // 5 minutes
const retryTime = new Date(Date.now() - retryDelay).toISOString()
@@ -239,10 +257,18 @@ export class MailingService implements IMailingService {
const email = await this.payload.findByID({
collection: this.emailsCollection as any,
id: emailId,
}) as QueuedEmail
}) as BaseEmailDocument
let emailObject: EmailObject = {
from: email.from || this.config.defaultFrom,
// Combine from and fromName for nodemailer using proper sanitization
let fromField: string
if (email.from) {
fromField = this.formatEmailAddress(email.from, email.fromName)
} else {
fromField = this.getDefaultFrom()
}
let mailOptions: any = {
from: fromField,
to: email.to,
cc: email.cc || undefined,
bcc: email.bcc || undefined,
@@ -250,23 +276,30 @@ export class MailingService implements IMailingService {
subject: email.subject,
html: email.html,
text: email.text || undefined,
variables: email.variables,
}
// Apply emailWrapper hook if configured
if (this.config.emailWrapper) {
emailObject = await this.config.emailWrapper(emailObject)
}
// Call beforeSend hook if configured
if (this.config.beforeSend) {
try {
mailOptions = await this.config.beforeSend(mailOptions, email)
const mailOptions = {
from: emailObject.from || this.config.defaultFrom,
to: emailObject.to,
cc: emailObject.cc || undefined,
bcc: emailObject.bcc || undefined,
replyTo: emailObject.replyTo || undefined,
subject: emailObject.subject,
html: emailObject.html,
text: emailObject.text || undefined,
// Validate required properties remain intact after hook execution
if (!mailOptions.from) {
throw new Error('beforeSend hook must not remove the "from" property')
}
if (!mailOptions.to || (Array.isArray(mailOptions.to) && mailOptions.to.length === 0)) {
throw new Error('beforeSend hook must not remove or empty the "to" property')
}
if (!mailOptions.subject) {
throw new Error('beforeSend hook must not remove the "subject" property')
}
if (!mailOptions.html && !mailOptions.text) {
throw new Error('beforeSend hook must not remove both "html" and "text" properties')
}
} catch (error) {
console.error('Error in beforeSend hook:', error)
throw new Error(`beforeSend hook failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
await this.transporter.sendMail(mailOptions)
@@ -305,7 +338,7 @@ export class MailingService implements IMailingService {
const email = await this.payload.findByID({
collection: this.emailsCollection as any,
id: emailId,
}) as QueuedEmail
}) as BaseEmail
const newAttempts = (email.attempts || 0) + 1
@@ -320,7 +353,7 @@ export class MailingService implements IMailingService {
return newAttempts
}
private async getTemplateBySlug(templateSlug: string): Promise<EmailTemplate | null> {
private async getTemplateBySlug(templateSlug: string): Promise<BaseEmailTemplateDocument | null> {
try {
const { docs } = await this.payload.find({
collection: this.templatesCollection as any,
@@ -332,24 +365,72 @@ export class MailingService implements IMailingService {
limit: 1,
})
return docs.length > 0 ? docs[0] as EmailTemplate : null
return docs.length > 0 ? docs[0] as BaseEmailTemplateDocument : null
} catch (error) {
console.error(`Template with slug '${templateSlug}' not found:`, error)
return null
}
}
private renderHandlebarsTemplate(template: string, variables: Record<string, any>): string {
private async renderTemplateString(template: string, variables: Record<string, any>): Promise<string> {
// Use custom template renderer if provided
if (this.config.templateRenderer) {
try {
const compiled = Handlebars.compile(template)
return compiled(variables)
return await this.config.templateRenderer(template, variables)
} catch (error) {
console.error('Handlebars template rendering error:', error)
console.error('Custom template renderer error:', error)
return template
}
}
private async renderEmailTemplate(template: EmailTemplate, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
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: BaseEmailTemplateDocument, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
if (!template.content) {
return { html: '', text: '' }
}
@@ -358,9 +439,9 @@ export class MailingService implements IMailingService {
let html = serializeRichTextToHTML(template.content)
let text = serializeRichTextToText(template.content)
// Apply Handlebars variables to the rendered content
html = this.renderHandlebarsTemplate(html, variables)
text = this.renderHandlebarsTemplate(text, variables)
// Apply template variables to the rendered content
html = await this.renderTemplateString(html, variables)
text = await this.renderTemplateString(text, variables)
return { html, text }
}

View File

@@ -2,19 +2,66 @@ import { Payload } from 'payload'
import type { CollectionConfig, RichTextField } from 'payload'
import { Transporter } from 'nodemailer'
export interface EmailObject {
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
from?: string
// JSON value type that matches Payload's JSON field type
export type JSONValue = string | number | boolean | { [k: string]: unknown } | unknown[] | null | undefined
// Generic base interfaces that work with any ID type and null values
export interface BaseEmailDocument {
id: string | number
template?: any
to: string[]
cc?: string[] | null
bcc?: string[] | null
from?: string | null
fromName?: string | null
replyTo?: string | null
subject: string
html: string
text?: string | null
variables?: JSONValue
scheduledAt?: string | Date | null
sentAt?: string | Date | null
status?: 'pending' | 'processing' | 'sent' | 'failed' | null
attempts?: number | null
lastAttemptAt?: string | Date | null
error?: string | null
priority?: number | null
createdAt?: string | Date | null
updatedAt?: string | Date | null
}
export interface BaseEmailTemplateDocument {
id: string | number
name: string
slug: string
subject?: string | null
content?: any
createdAt?: string | Date | null
updatedAt?: string | Date | null
}
export type BaseEmail<TEmail extends BaseEmailDocument = BaseEmailDocument, TEmailTemplate extends BaseEmailTemplateDocument = BaseEmailTemplateDocument> = Omit<TEmail, 'id' | 'template'> & {template: Omit<TEmailTemplate, 'id'> | TEmailTemplate['id'] | undefined | null}
export type BaseEmailTemplate<TEmailTemplate extends BaseEmailTemplateDocument = BaseEmailTemplateDocument> = Omit<TEmailTemplate, 'id'>
export type TemplateRendererHook = (template: string, variables: Record<string, any>) => string | Promise<string>
export type TemplateEngine = 'liquidjs' | 'mustache' | 'simple'
export interface BeforeSendMailOptions {
from: string
to: string[]
cc?: string[]
bcc?: string[]
replyTo?: string
subject: string
html: string
text?: string
variables?: Record<string, any>
attachments?: any[]
[key: string]: any
}
export type EmailWrapperHook = (email: EmailObject) => EmailObject | Promise<EmailObject>
export type BeforeSendHook = (options: BeforeSendMailOptions, email: BaseEmailDocument) => BeforeSendMailOptions | Promise<BeforeSendMailOptions>
export interface MailingPluginConfig {
collections?: {
@@ -22,12 +69,15 @@ export interface MailingPluginConfig {
emails?: string | Partial<CollectionConfig>
}
defaultFrom?: string
defaultFromName?: string
transport?: Transporter | MailingTransportConfig
queue?: string
retryAttempts?: number
retryDelay?: number
emailWrapper?: EmailWrapperHook
templateRenderer?: TemplateRendererHook
templateEngine?: TemplateEngine
richTextEditor?: RichTextField['editor']
beforeSend?: BeforeSendHook
onReady?: (payload: any) => Promise<void>
initOrder?: 'before' | 'after'
}
@@ -42,60 +92,40 @@ export interface MailingTransportConfig {
}
}
export interface EmailTemplate {
id: string
name: string
slug: string
subject: string
content: any // Lexical editor state
createdAt: string
updatedAt: string
}
export interface QueuedEmail {
id: string
template?: string
template?: string | null
to: string[]
cc?: string[]
bcc?: string[]
from?: string
replyTo?: string
cc?: string[] | null
bcc?: string[] | null
from?: string | null
fromName?: string | null
replyTo?: string | null
subject: string
html: string
text?: string
variables?: Record<string, any>
scheduledAt?: string
sentAt?: string
text?: string | null
variables?: JSONValue
scheduledAt?: string | Date | null
sentAt?: string | Date | null
status: 'pending' | 'processing' | 'sent' | 'failed'
attempts: number
lastAttemptAt?: string
error?: string
priority?: number
lastAttemptAt?: string | Date | null
error?: string | null
priority?: number | null
createdAt: string
updatedAt: string
}
export interface SendEmailOptions {
templateSlug?: string
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
from?: string
replyTo?: string
subject?: string
html?: string
text?: string
variables?: Record<string, any>
scheduledAt?: Date
priority?: number
// Simple helper type for template variables
export interface TemplateVariables {
[key: string]: any
}
export interface MailingService {
sendEmail(options: SendEmailOptions): Promise<string>
scheduleEmail(options: SendEmailOptions): Promise<string>
processEmails(): Promise<void>
retryFailedEmails(): Promise<void>
renderTemplate(templateSlug: string, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }>
}
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 { 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) => {
const mailing = (payload as any).mailing
@@ -9,14 +44,9 @@ export const getMailing = (payload: Payload) => {
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)
return mailing.service.sendEmail(options)
}
export const scheduleEmail = async (payload: Payload, options: SendEmailOptions): Promise<string> => {
const mailing = getMailing(payload)
return mailing.service.scheduleEmail(options)
return mailing.service.renderTemplate(templateSlug, variables)
}
export const processEmails = async (payload: Payload): Promise<void> => {