mirror of
https://github.com/xtr-dev/payload-mailing.git
synced 2025-12-10 00:03:23 +00:00
Merge pull request #34 from xtr-dev/dev
Add fromName field support to emails collection
This commit is contained in:
@@ -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",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"description": "Template-based email system with scheduling and job processing for PayloadCMS",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface SendEmailTaskInput {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -100,6 +100,17 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
|
||||
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()
|
||||
|
||||
@@ -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 || ''
|
||||
@@ -238,8 +262,16 @@ export class MailingService implements IMailingService {
|
||||
id: emailId,
|
||||
}) 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 = {
|
||||
from: email.from,
|
||||
from: fromField,
|
||||
to: email.to,
|
||||
cc: email.cc || undefined,
|
||||
bcc: email.bcc || undefined,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface BaseEmailDocument {
|
||||
cc?: string[] | null
|
||||
bcc?: string[] | null
|
||||
from?: string | null
|
||||
fromName?: string | null
|
||||
replyTo?: string | null
|
||||
subject: string
|
||||
html: string
|
||||
@@ -83,6 +84,7 @@ export interface QueuedEmail {
|
||||
cc?: string[] | null
|
||||
bcc?: string[] | null
|
||||
from?: string | null
|
||||
fromName?: string | null
|
||||
replyTo?: string | null
|
||||
subject: string
|
||||
html: string
|
||||
|
||||
Reference in New Issue
Block a user