mirror of
https://github.com/xtr-dev/payload-mailing.git
synced 2025-12-10 16:23:23 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f12ac8172e | ||
| 347cd33e13 | |||
|
|
672ab3236a | ||
| c7db65980a | |||
| 624dc12471 | |||
| e20ebe27bf | |||
|
|
7f04275d39 | ||
| 20afe30e88 | |||
| 02b3fecadf |
@@ -1,95 +0,0 @@
|
|||||||
# Using Custom ID Types
|
|
||||||
|
|
||||||
The mailing plugin now supports both `string` and `number` ID types. By default, it works with the generic `BaseEmailDocument` interface, but you can provide your own types for full type safety.
|
|
||||||
|
|
||||||
## Usage with Your Generated Types
|
|
||||||
|
|
||||||
When you have your own generated Payload types (e.g., from `payload generate:types`), you can use them with the mailing plugin:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { sendEmail, BaseEmailDocument } from '@xtr-dev/payload-mailing'
|
|
||||||
import { Email } from './payload-types' // Your generated types
|
|
||||||
|
|
||||||
// Option 1: Use your specific Email type
|
|
||||||
const email = await sendEmail<Email>(payload, {
|
|
||||||
template: {
|
|
||||||
slug: 'welcome',
|
|
||||||
variables: { name: 'John' }
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
to: 'user@example.com',
|
|
||||||
// All your custom fields are now type-safe
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Option 2: Extend BaseEmailDocument for custom fields
|
|
||||||
interface MyEmail extends BaseEmailDocument {
|
|
||||||
customField: string
|
|
||||||
anotherField?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const customEmail = await sendEmail<MyEmail>(payload, {
|
|
||||||
data: {
|
|
||||||
to: 'user@example.com',
|
|
||||||
subject: 'Hello',
|
|
||||||
html: '<p>Hello World</p>',
|
|
||||||
customField: 'my value', // Type-safe!
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
The plugin works with:
|
|
||||||
- **String IDs**: `id: string`
|
|
||||||
- **Number IDs**: `id: number`
|
|
||||||
- **Nullable fields**: Fields can be `null`, `undefined`, or have values
|
|
||||||
- **Date fields**: Timestamp fields support both `Date` objects and `string` (ISO) formats
|
|
||||||
- **JSON variables**: Variables field supports any JSON-compatible value type
|
|
||||||
- **Generated types**: Works with `payload generate:types` output
|
|
||||||
|
|
||||||
Your Payload configuration determines which types are used. The plugin automatically adapts to your setup.
|
|
||||||
|
|
||||||
## Type Definitions
|
|
||||||
|
|
||||||
The base interfaces provided by the plugin:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// JSON value type that matches Payload's JSON field type
|
|
||||||
type JSONValue = string | number | boolean | { [k: string]: unknown } | unknown[] | null | undefined
|
|
||||||
|
|
||||||
interface BaseEmailDocument {
|
|
||||||
id: string | number
|
|
||||||
template?: any
|
|
||||||
to: string[]
|
|
||||||
cc?: string[] | null
|
|
||||||
bcc?: string[] | null
|
|
||||||
from?: string | null
|
|
||||||
replyTo?: string | null
|
|
||||||
subject: string
|
|
||||||
html: string
|
|
||||||
text?: string | null
|
|
||||||
variables?: JSONValue // Supports any JSON-compatible value
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BaseEmailTemplateDocument {
|
|
||||||
id: string | number
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
subject?: string | null
|
|
||||||
content?: any
|
|
||||||
createdAt?: string | Date | null
|
|
||||||
updatedAt?: string | Date | null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
These provide a foundation that works with any ID type while maintaining type safety for the core email functionality.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-mailing",
|
"name": "@xtr-dev/payload-mailing",
|
||||||
"version": "0.1.16",
|
"version": "0.1.19",
|
||||||
"description": "Template-based email system with scheduling and job processing for PayloadCMS",
|
"description": "Template-based email system with scheduling and job processing for PayloadCMS",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ const Emails: CollectionConfig = {
|
|||||||
description: 'Sender email address (optional, uses default if not provided)',
|
description: 'Sender email address (optional, uses default if not provided)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'fromName',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'Sender display name (optional, e.g., "John Doe" for "John Doe <john@example.com>")',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'replyTo',
|
name: 'replyTo',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ export interface SendEmailTaskInput {
|
|||||||
to: string | string[]
|
to: string | string[]
|
||||||
cc?: string | string[]
|
cc?: string | string[]
|
||||||
bcc?: string | string[]
|
bcc?: string | string[]
|
||||||
scheduledAt?: string // ISO date string
|
from?: string
|
||||||
|
fromName?: string
|
||||||
|
replyTo?: string
|
||||||
|
scheduledAt?: string | Date // ISO date string or Date object
|
||||||
priority?: number
|
priority?: number
|
||||||
|
|
||||||
// Allow any additional fields that users might have in their email collection
|
// 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
|
// 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
|
// Template-specific fields that should not be copied to data
|
||||||
const templateFields = ['templateSlug', 'variables']
|
const templateFields = ['templateSlug', 'variables']
|
||||||
@@ -135,6 +138,30 @@ export const sendEmailJob = {
|
|||||||
description: 'Optional comma-separated list of BCC email addresses'
|
description: 'Optional comma-separated list of BCC email addresses'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'from',
|
||||||
|
type: 'text' as const,
|
||||||
|
label: 'From Email',
|
||||||
|
admin: {
|
||||||
|
description: 'Optional sender email address (uses default if not provided)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fromName',
|
||||||
|
type: 'text' as const,
|
||||||
|
label: 'From Name',
|
||||||
|
admin: {
|
||||||
|
description: 'Optional sender display name (e.g., "John Doe")'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'replyTo',
|
||||||
|
type: 'text' as const,
|
||||||
|
label: 'Reply To',
|
||||||
|
admin: {
|
||||||
|
description: 'Optional reply-to email address'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'scheduledAt',
|
name: 'scheduledAt',
|
||||||
type: 'date' as const,
|
type: 'date' as const,
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ export interface Email {
|
|||||||
* Sender email address (optional, uses default if not provided)
|
* Sender email address (optional, uses default if not provided)
|
||||||
*/
|
*/
|
||||||
from?: string | null;
|
from?: string | null;
|
||||||
|
/**
|
||||||
|
* Sender display name (optional, e.g., "John Doe" for "John Doe <john@example.com>")
|
||||||
|
*/
|
||||||
|
fromName?: string | null;
|
||||||
/**
|
/**
|
||||||
* Reply-to email address
|
* Reply-to email address
|
||||||
*/
|
*/
|
||||||
@@ -336,6 +340,7 @@ export interface EmailsSelect<T extends boolean = true> {
|
|||||||
cc?: T;
|
cc?: T;
|
||||||
bcc?: T;
|
bcc?: T;
|
||||||
from?: T;
|
from?: T;
|
||||||
|
fromName?: T;
|
||||||
replyTo?: T;
|
replyTo?: T;
|
||||||
subject?: T;
|
subject?: T;
|
||||||
html?: T;
|
html?: T;
|
||||||
|
|||||||
@@ -74,10 +74,15 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
|
|||||||
}),
|
}),
|
||||||
} satisfies CollectionConfig
|
} satisfies CollectionConfig
|
||||||
|
|
||||||
|
// Filter out any existing collections with the same slugs to prevent duplicates
|
||||||
|
const existingCollections = (config.collections || []).filter(
|
||||||
|
(collection) => collection.slug !== templatesSlug && collection.slug !== emailsSlug
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
collections: [
|
collections: [
|
||||||
...(config.collections || []),
|
...existingCollections,
|
||||||
templatesCollection,
|
templatesCollection,
|
||||||
emailsCollection,
|
emailsCollection,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -100,6 +100,34 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
|||||||
emailData.from = validated && validated.length > 0 ? validated[0] : undefined
|
emailData.from = validated && validated.length > 0 ? validated[0] : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize fromName to prevent header injection
|
||||||
|
if (emailData.fromName && emailData.fromName !== null) {
|
||||||
|
emailData.fromName = emailData.fromName
|
||||||
|
.trim()
|
||||||
|
// Remove/replace newlines and carriage returns to prevent header injection
|
||||||
|
.replace(/[\r\n]/g, ' ')
|
||||||
|
// Remove control characters (except space and printable characters)
|
||||||
|
.replace(/[\x00-\x1F\x7F-\x9F]/g, '')
|
||||||
|
// Note: We don't escape quotes here as that's handled in MailingService
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize Date objects to ISO strings for consistent database storage
|
||||||
|
if (emailData.scheduledAt instanceof Date) {
|
||||||
|
emailData.scheduledAt = emailData.scheduledAt.toISOString()
|
||||||
|
}
|
||||||
|
if (emailData.sentAt instanceof Date) {
|
||||||
|
emailData.sentAt = emailData.sentAt.toISOString()
|
||||||
|
}
|
||||||
|
if (emailData.lastAttemptAt instanceof Date) {
|
||||||
|
emailData.lastAttemptAt = emailData.lastAttemptAt.toISOString()
|
||||||
|
}
|
||||||
|
if (emailData.createdAt instanceof Date) {
|
||||||
|
emailData.createdAt = emailData.createdAt.toISOString()
|
||||||
|
}
|
||||||
|
if (emailData.updatedAt instanceof Date) {
|
||||||
|
emailData.updatedAt = emailData.updatedAt.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
// Create the email in the collection with proper typing
|
// Create the email in the collection with proper typing
|
||||||
const email = await payload.create({
|
const email = await payload.create({
|
||||||
collection: collectionSlug,
|
collection: collectionSlug,
|
||||||
|
|||||||
@@ -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 {
|
private getDefaultFrom(): string {
|
||||||
const fromEmail = this.config.defaultFrom
|
const fromEmail = this.config.defaultFrom
|
||||||
const fromName = this.config.defaultFromName
|
const fromName = this.config.defaultFromName
|
||||||
|
|
||||||
// Check if fromName exists, is not empty after trimming, and fromEmail exists
|
// Check if fromName exists, is not empty after trimming, and fromEmail exists
|
||||||
if (fromName && fromName.trim() && fromEmail) {
|
if (fromName && fromName.trim() && fromEmail) {
|
||||||
// Escape quotes in the display name to prevent malformed headers
|
return this.formatEmailAddress(fromEmail, fromName)
|
||||||
const escapedName = fromName.replace(/"/g, '\\"')
|
|
||||||
return `"${escapedName}" <${fromEmail}>`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fromEmail || ''
|
return fromEmail || ''
|
||||||
@@ -238,8 +262,16 @@ export class MailingService implements IMailingService {
|
|||||||
id: emailId,
|
id: emailId,
|
||||||
}) as BaseEmailDocument
|
}) as BaseEmailDocument
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: email.from,
|
from: fromField,
|
||||||
to: email.to,
|
to: email.to,
|
||||||
cc: email.cc || undefined,
|
cc: email.cc || undefined,
|
||||||
bcc: email.bcc || undefined,
|
bcc: email.bcc || undefined,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface BaseEmailDocument {
|
|||||||
cc?: string[] | null
|
cc?: string[] | null
|
||||||
bcc?: string[] | null
|
bcc?: string[] | null
|
||||||
from?: string | null
|
from?: string | null
|
||||||
|
fromName?: string | null
|
||||||
replyTo?: string | null
|
replyTo?: string | null
|
||||||
subject: string
|
subject: string
|
||||||
html: string
|
html: string
|
||||||
@@ -83,6 +84,7 @@ export interface QueuedEmail {
|
|||||||
cc?: string[] | null
|
cc?: string[] | null
|
||||||
bcc?: string[] | null
|
bcc?: string[] | null
|
||||||
from?: string | null
|
from?: string | null
|
||||||
|
fromName?: string | null
|
||||||
replyTo?: string | null
|
replyTo?: string | null
|
||||||
subject: string
|
subject: string
|
||||||
html: string
|
html: string
|
||||||
|
|||||||
Reference in New Issue
Block a user