Compare commits

..

26 Commits

Author SHA1 Message Date
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
11 changed files with 423 additions and 37 deletions

View File

@@ -142,6 +142,18 @@ mailingPlugin({
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
}
return options
}
})
```
@@ -255,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:
@@ -266,7 +328,7 @@ mailingPlugin({
onReady: async (payload) => {
// Called after plugin is fully initialized
console.log('Mailing plugin ready!')
// Custom initialization logic here
await setupCustomEmailSettings(payload)
}

View File

@@ -248,6 +248,10 @@ export interface Email {
* 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
*/
@@ -543,6 +547,7 @@ export interface EmailsSelect<T extends boolean = true> {
cc?: T;
bcc?: T;
from?: T;
fromName?: T;
replyTo?: T;
subject?: T;
html?: T;
@@ -675,6 +680,18 @@ export interface TaskSendEmail {
* 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
*/
@@ -684,7 +701,9 @@ export interface TaskSendEmail {
*/
priority?: number | null;
};
output?: unknown;
output: {
id?: string | null;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema

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

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

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/payload-mailing",
"version": "0.1.12",
"version": "0.1.21",
"description": "Template-based email system with scheduling and job processing for PayloadCMS",
"type": "module",
"main": "dist/index.js",

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

@@ -1,5 +1,5 @@
import { sendEmail } from '../sendEmail.js'
import {Email, EmailTemplate} from '../payload-types.js'
import { BaseEmailDocument } from '../types/index.js'
export interface SendEmailTaskInput {
// Template mode fields
@@ -15,7 +15,10 @@ export interface SendEmailTaskInput {
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
scheduledAt?: string // ISO date string
from?: string
fromName?: string
replyTo?: string
scheduledAt?: string | Date // ISO date string or Date object
priority?: number
// Allow any additional fields that users might have in their email collection
@@ -39,7 +42,7 @@ function transformTaskInputToSendEmailOptions(taskInput: SendEmailTaskInput) {
}
// Standard email fields that should be copied to data
const standardFields = ['to', 'cc', 'bcc', 'subject', 'html', 'text', 'scheduledAt', 'priority']
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']
@@ -135,6 +138,30 @@ export const sendEmailJob = {
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,
@@ -170,7 +197,7 @@ export const sendEmailJob = {
const sendEmailOptions = transformTaskInputToSendEmailOptions(taskInput)
// Use the sendEmail helper to create the email
const email = await sendEmail<Email>(payload, sendEmailOptions)
const email = await sendEmail<BaseEmailDocument>(payload, sendEmailOptions)
return {
output: {

View File

@@ -143,6 +143,10 @@ export interface Email {
* 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
*/
@@ -336,6 +340,7 @@ export interface EmailsSelect<T extends boolean = true> {
cc?: T;
bcc?: T;
from?: T;
fromName?: T;
replyTo?: T;
subject?: T;
html?: T;

View File

@@ -74,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,
],

View File

@@ -1,9 +1,9 @@
import { Payload } from 'payload'
import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js'
import {Email, EmailTemplate} from "./payload-types.js"
import { BaseEmailDocument } from './types/index.js'
// Options for sending emails
export interface SendEmailOptions<T extends Email = Email> {
export interface SendEmailOptions<T extends BaseEmailDocument = BaseEmailDocument> {
// Template-based email
template?: {
slug: string
@@ -35,7 +35,7 @@ export interface SendEmailOptions<T extends Email = Email> {
* })
* ```
*/
export const sendEmail = async <TEmail extends Email = Email>(
export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocument>(
payload: Payload,
options: SendEmailOptions<TEmail>
): Promise<TEmail> => {
@@ -89,6 +89,44 @@ export const sendEmail = async <TEmail extends Email = Email>(
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({

View File

@@ -6,7 +6,7 @@ import {
TemplateVariables,
MailingService as IMailingService,
MailingTransportConfig,
BaseEmail, BaseEmailTemplate
BaseEmail, BaseEmailTemplate, BaseEmailDocument, BaseEmailTemplateDocument
} from '../types/index.js'
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
@@ -63,15 +63,39 @@ export class MailingService implements IMailingService {
}
}
/**
* 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) {
// Escape quotes in the display name to prevent malformed headers
const escapedName = fromName.replace(/"/g, '\\"')
return `"${escapedName}" <${fromEmail}>`
return this.formatEmailAddress(fromEmail, fromName)
}
return fromEmail || ''
@@ -131,7 +155,7 @@ export class MailingService implements IMailingService {
}
const emailContent = await this.renderEmailTemplate(template, variables)
const subject = await this.renderTemplateString(template.subject, variables)
const subject = await this.renderTemplateString(template.subject || '', variables)
return {
html: emailContent.html,
@@ -236,10 +260,18 @@ export class MailingService implements IMailingService {
const email = await this.payload.findByID({
collection: this.emailsCollection as any,
id: emailId,
}) as BaseEmail
}) as BaseEmailDocument
const mailOptions = {
from: email.from,
// 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,
@@ -249,6 +281,30 @@ export class MailingService implements IMailingService {
text: email.text || undefined,
}
// Call beforeSend hook if configured
if (this.config.beforeSend) {
try {
mailOptions = await this.config.beforeSend(mailOptions, email)
// Validate required properties remain intact after hook execution
if (!mailOptions.from) {
throw new Error('beforeSend hook must not remove the "from" property')
}
if (!mailOptions.to || (Array.isArray(mailOptions.to) && mailOptions.to.length === 0)) {
throw new Error('beforeSend hook must not remove or empty the "to" property')
}
if (!mailOptions.subject) {
throw new Error('beforeSend hook must not remove the "subject" property')
}
if (!mailOptions.html && !mailOptions.text) {
throw new Error('beforeSend hook must not remove both "html" and "text" properties')
}
} catch (error) {
console.error('Error in beforeSend hook:', error)
throw new Error(`beforeSend hook failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
await this.transporter.sendMail(mailOptions)
await this.payload.update({
@@ -300,7 +356,7 @@ export class MailingService implements IMailingService {
return newAttempts
}
private async getTemplateBySlug(templateSlug: string): Promise<BaseEmailTemplate | null> {
private async getTemplateBySlug(templateSlug: string): Promise<BaseEmailTemplateDocument | null> {
try {
const { docs } = await this.payload.find({
collection: this.templatesCollection as any,
@@ -312,7 +368,7 @@ export class MailingService implements IMailingService {
limit: 1,
})
return docs.length > 0 ? docs[0] as BaseEmailTemplate : null
return docs.length > 0 ? docs[0] as BaseEmailTemplateDocument : null
} catch (error) {
console.error(`Template with slug '${templateSlug}' not found:`, error)
return null
@@ -377,7 +433,7 @@ export class MailingService implements IMailingService {
})
}
private async renderEmailTemplate(template: BaseEmailTemplate, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
private async renderEmailTemplate(template: BaseEmailTemplateDocument, variables: Record<string, any> = {}): Promise<{ html: string; text: string }> {
if (!template.content) {
return { html: '', text: '' }
}

View File

@@ -1,16 +1,68 @@
import { Payload } from 'payload'
import type { CollectionConfig, RichTextField } from 'payload'
import { Transporter } from 'nodemailer'
import {Email, EmailTemplate} from "../payload-types.js"
export type BaseEmail<TEmail extends Email = Email, TEmailTemplate extends EmailTemplate = EmailTemplate> = Omit<TEmail, 'id' | 'template'> & {template: Omit<TEmailTemplate, 'id'> | TEmailTemplate['id'] | undefined | null}
// JSON value type that matches Payload's JSON field type
export type JSONValue = string | number | boolean | { [k: string]: unknown } | unknown[] | null | undefined
export type BaseEmailTemplate<TEmailTemplate extends EmailTemplate = EmailTemplate> = Omit<TEmailTemplate, 'id'>
// 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
attachments?: any[]
[key: string]: any
}
export type BeforeSendHook = (options: BeforeSendMailOptions, email: BaseEmailDocument) => BeforeSendMailOptions | Promise<BeforeSendMailOptions>
export interface MailingPluginConfig {
collections?: {
templates?: string | Partial<CollectionConfig>
@@ -25,6 +77,7 @@ export interface MailingPluginConfig {
templateRenderer?: TemplateRendererHook
templateEngine?: TemplateEngine
richTextEditor?: RichTextField['editor']
beforeSend?: BeforeSendHook
onReady?: (payload: any) => Promise<void>
initOrder?: 'before' | 'after'
}
@@ -42,23 +95,24 @@ export interface MailingTransportConfig {
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
}