mirror of
https://github.com/xtr-dev/payload-mailing.git
synced 2025-12-11 00:33:23 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
672ab3236a | ||
| c7db65980a | |||
| 624dc12471 | |||
| e20ebe27bf | |||
|
|
7f04275d39 | ||
| 20afe30e88 | |||
| 02b3fecadf | |||
|
|
ea87f14308 | ||
| 6886027727 | |||
| 965569be06 | |||
|
|
ff788c1ecf | ||
| c12438aaa2 | |||
| 4dcbc1446a | |||
|
|
72f3d7f66d | ||
| ecc0b0a73e | |||
| a959673fc1 | |||
| 8809db6aff | |||
|
|
5905f732de | ||
| 4c495a72b0 | |||
| 8518c716e8 | |||
| 570190be01 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-mailing",
|
"name": "@xtr-dev/payload-mailing",
|
||||||
"version": "0.1.12",
|
"version": "0.1.18",
|
||||||
"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',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { sendEmail } from '../sendEmail.js'
|
import { sendEmail } from '../sendEmail.js'
|
||||||
import {Email, EmailTemplate} from '../payload-types.js'
|
import { BaseEmailDocument } from '../types/index.js'
|
||||||
|
|
||||||
export interface SendEmailTaskInput {
|
export interface SendEmailTaskInput {
|
||||||
// Template mode fields
|
// Template mode fields
|
||||||
@@ -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,
|
||||||
@@ -170,7 +197,7 @@ export const sendEmailJob = {
|
|||||||
const sendEmailOptions = transformTaskInputToSendEmailOptions(taskInput)
|
const sendEmailOptions = transformTaskInputToSendEmailOptions(taskInput)
|
||||||
|
|
||||||
// Use the sendEmail helper to create the email
|
// Use the sendEmail helper to create the email
|
||||||
const email = await sendEmail<Email>(payload, sendEmailOptions)
|
const email = await sendEmail<BaseEmailDocument>(payload, sendEmailOptions)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Payload } from 'payload'
|
import { Payload } from 'payload'
|
||||||
import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js'
|
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
|
// Options for sending emails
|
||||||
export interface SendEmailOptions<T extends Email = Email> {
|
export interface SendEmailOptions<T extends BaseEmailDocument = BaseEmailDocument> {
|
||||||
// Template-based email
|
// Template-based email
|
||||||
template?: {
|
template?: {
|
||||||
slug: string
|
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,
|
payload: Payload,
|
||||||
options: SendEmailOptions<TEmail>
|
options: SendEmailOptions<TEmail>
|
||||||
): Promise<TEmail> => {
|
): Promise<TEmail> => {
|
||||||
@@ -83,12 +83,50 @@ export const sendEmail = async <TEmail extends Email = Email>(
|
|||||||
if (emailData.to) {
|
if (emailData.to) {
|
||||||
emailData.to = parseAndValidateEmails(emailData.to as string | string[])
|
emailData.to = parseAndValidateEmails(emailData.to as string | string[])
|
||||||
}
|
}
|
||||||
if (emailData.cc) {
|
if (emailData.cc && emailData.cc !== null) {
|
||||||
emailData.cc = parseAndValidateEmails(emailData.cc as string | string[])
|
emailData.cc = parseAndValidateEmails(emailData.cc as string | string[])
|
||||||
}
|
}
|
||||||
if (emailData.bcc) {
|
if (emailData.bcc && emailData.bcc !== null) {
|
||||||
emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[])
|
emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[])
|
||||||
}
|
}
|
||||||
|
if (emailData.replyTo && emailData.replyTo !== null) {
|
||||||
|
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 && emailData.from !== null) {
|
||||||
|
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 !== 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({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
TemplateVariables,
|
TemplateVariables,
|
||||||
MailingService as IMailingService,
|
MailingService as IMailingService,
|
||||||
MailingTransportConfig,
|
MailingTransportConfig,
|
||||||
BaseEmail, BaseEmailTemplate
|
BaseEmail, BaseEmailTemplate, BaseEmailDocument, BaseEmailTemplateDocument
|
||||||
} from '../types/index.js'
|
} from '../types/index.js'
|
||||||
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.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 {
|
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 || ''
|
||||||
@@ -131,7 +155,7 @@ export class MailingService implements IMailingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const emailContent = await this.renderEmailTemplate(template, variables)
|
const emailContent = await this.renderEmailTemplate(template, variables)
|
||||||
const subject = await this.renderTemplateString(template.subject, variables)
|
const subject = await this.renderTemplateString(template.subject || '', variables)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: emailContent.html,
|
html: emailContent.html,
|
||||||
@@ -236,10 +260,18 @@ export class MailingService implements IMailingService {
|
|||||||
const email = await this.payload.findByID({
|
const email = await this.payload.findByID({
|
||||||
collection: this.emailsCollection as any,
|
collection: this.emailsCollection as any,
|
||||||
id: emailId,
|
id: emailId,
|
||||||
}) as BaseEmail
|
}) 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,
|
||||||
@@ -300,7 +332,7 @@ export class MailingService implements IMailingService {
|
|||||||
return newAttempts
|
return newAttempts
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getTemplateBySlug(templateSlug: string): Promise<BaseEmailTemplate | null> {
|
private async getTemplateBySlug(templateSlug: string): Promise<BaseEmailTemplateDocument | null> {
|
||||||
try {
|
try {
|
||||||
const { docs } = await this.payload.find({
|
const { docs } = await this.payload.find({
|
||||||
collection: this.templatesCollection as any,
|
collection: this.templatesCollection as any,
|
||||||
@@ -312,7 +344,7 @@ export class MailingService implements IMailingService {
|
|||||||
limit: 1,
|
limit: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
return docs.length > 0 ? docs[0] as BaseEmailTemplate : null
|
return docs.length > 0 ? docs[0] as BaseEmailTemplateDocument : null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Template with slug '${templateSlug}' not found:`, error)
|
console.error(`Template with slug '${templateSlug}' not found:`, error)
|
||||||
return null
|
return null
|
||||||
@@ -377,7 +409,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) {
|
if (!template.content) {
|
||||||
return { html: '', text: '' }
|
return { html: '', text: '' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,48 @@
|
|||||||
import { Payload } from 'payload'
|
import { Payload } from 'payload'
|
||||||
import type { CollectionConfig, RichTextField } from 'payload'
|
import type { CollectionConfig, RichTextField } from 'payload'
|
||||||
import { Transporter } from 'nodemailer'
|
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 TemplateRendererHook = (template: string, variables: Record<string, any>) => string | Promise<string>
|
||||||
|
|
||||||
@@ -42,23 +79,24 @@ export interface MailingTransportConfig {
|
|||||||
|
|
||||||
export interface QueuedEmail {
|
export interface QueuedEmail {
|
||||||
id: string
|
id: string
|
||||||
template?: string
|
template?: string | null
|
||||||
to: string[]
|
to: string[]
|
||||||
cc?: string[]
|
cc?: string[] | null
|
||||||
bcc?: string[]
|
bcc?: string[] | null
|
||||||
from?: string
|
from?: string | null
|
||||||
replyTo?: string
|
fromName?: string | null
|
||||||
|
replyTo?: string | null
|
||||||
subject: string
|
subject: string
|
||||||
html: string
|
html: string
|
||||||
text?: string
|
text?: string | null
|
||||||
variables?: Record<string, any>
|
variables?: JSONValue
|
||||||
scheduledAt?: string
|
scheduledAt?: string | Date | null
|
||||||
sentAt?: string
|
sentAt?: string | Date | null
|
||||||
status: 'pending' | 'processing' | 'sent' | 'failed'
|
status: 'pending' | 'processing' | 'sent' | 'failed'
|
||||||
attempts: number
|
attempts: number
|
||||||
lastAttemptAt?: string
|
lastAttemptAt?: string | Date | null
|
||||||
error?: string
|
error?: string | null
|
||||||
priority?: number
|
priority?: number | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user