Compare commits

..

6 Commits
v0.4.17 ... dev

Author SHA1 Message Date
0fa10164bf Replace type assertions with CollectionSlug for better type safety
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 15:14:09 +02:00
ada13d27cb Bump version to 0.4.21
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 15:03:14 +02:00
de57dd4102 Fix filterOptions ObjectId casting error and bump version to 0.4.20
Fixed incorrect usage of resolveID in filterOptions where { id } was passed instead of id directly. This caused ObjectId casting errors when the id parameter was a populated object.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 22:01:23 +02:00
d69f7c1f98 Fix ObjectId casting error when jobs relationship is populated
When the email's jobs relationship is populated with full job objects instead of just IDs,
calling String(job) on an object results in "[object Object]", which causes a Mongoose
ObjectId casting error. This fix properly extracts the ID from job objects or uses the
value directly if it's already an ID.

Fixes job scheduler error: "Cast to ObjectId failed for value '[object Object]'"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 21:35:55 +02:00
d15fa454a0 Refactor template lookup to eliminate duplication and improve type safety
Changes:
- Added MailingService.renderTemplateDocument() method to render from template document
- Created renderTemplateWithId() helper that combines lookup and rendering in one operation
- Updated sendEmail() to use renderTemplateWithId() instead of separate lookup and render
- Added runtime validation to ensure template collection exists before querying
- Eliminated duplicate template lookup (previously looked up twice per email send)

Benefits:
- Improved performance by reducing database queries from 2 to 1 per template-based email
- Better error messages when template collection is misconfigured
- Runtime validation complements TypeScript type assertions for safer code
- Cleaner separation of concerns in sendEmail() function

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 23:55:31 +02:00
f431786907 Fix template relationship population in sendEmail and bump version to 0.4.18
The sendEmail function now properly populates the template relationship field when using template-based emails. This ensures:
- Template relationship is set on the email document
- templateSlug field is auto-populated via beforeChange hook
- beforeSend hook has access to the full template relationship
- Proper record of which template was used for each email

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 23:48:23 +02:00
7 changed files with 97 additions and 39 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-mailing", "name": "@xtr-dev/payload-mailing",
"version": "0.4.17", "version": "0.4.21",
"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",

View File

@@ -206,7 +206,7 @@ const Emails: CollectionConfig = {
readOnly: true, readOnly: true,
}, },
filterOptions: ({ id }) => { filterOptions: ({ id }) => {
const emailId = resolveID({ id }) const emailId = resolveID(id)
return { return {
'input.emailId': { 'input.emailId': {
equals: emailId ? String(emailId) : '', equals: emailId ? String(emailId) : '',

View File

@@ -1,5 +1,5 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import { getMailing, renderTemplate, parseAndValidateEmails, sanitizeFromName } from './utils/helpers.js' import { getMailing, renderTemplateWithId, parseAndValidateEmails, sanitizeFromName } from './utils/helpers.js'
import { BaseEmailDocument } from './types/index.js' import { BaseEmailDocument } from './types/index.js'
import { processJobById } from './utils/emailProcessor.js' import { processJobById } from './utils/emailProcessor.js'
import { createContextLogger } from './utils/logger.js' import { createContextLogger } from './utils/logger.js'
@@ -50,7 +50,8 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail> let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail>
if (options.template) { if (options.template) {
const { html, text, subject } = await renderTemplate( // Look up and render the template in a single operation to avoid duplicate lookups
const { html, text, subject, templateId } = await renderTemplateWithId(
payload, payload,
options.template.slug, options.template.slug,
options.template.variables || {} options.template.variables || {}
@@ -58,6 +59,7 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
emailData = { emailData = {
...emailData, ...emailData,
template: templateId,
subject, subject,
html, html,
text, text,

View File

@@ -1,10 +1,10 @@
import { Payload } from 'payload' import {CollectionSlug, EmailAdapter, Payload, SendEmailOptions} from 'payload'
import { Liquid } from 'liquidjs' import { Liquid } from 'liquidjs'
import { import {
MailingPluginConfig, MailingPluginConfig,
TemplateVariables, TemplateVariables,
MailingService as IMailingService, MailingService as IMailingService,
BaseEmail, BaseEmailTemplate, BaseEmailDocument, BaseEmailTemplateDocument BaseEmailDocument, BaseEmailTemplateDocument
} from '../types/index.js' } from '../types/index.js'
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js' import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
import { sanitizeDisplayName } from '../utils/helpers.js' import { sanitizeDisplayName } from '../utils/helpers.js'
@@ -12,7 +12,6 @@ import { sanitizeDisplayName } from '../utils/helpers.js'
export class MailingService implements IMailingService { export class MailingService implements IMailingService {
public payload: Payload public payload: Payload
private config: MailingPluginConfig private config: MailingPluginConfig
private emailAdapter: any
private templatesCollection: string private templatesCollection: string
private emailsCollection: string private emailsCollection: string
private liquid: Liquid | null | false = null private liquid: Liquid | null | false = null
@@ -31,14 +30,13 @@ export class MailingService implements IMailingService {
if (!this.payload.email) { if (!this.payload.email) {
throw new Error('Payload email configuration is required. Please configure email in your Payload config.') throw new Error('Payload email configuration is required. Please configure email in your Payload config.')
} }
this.emailAdapter = this.payload.email
} }
private ensureInitialized(): void { private ensureInitialized(): void {
if (!this.payload || !this.payload.db) { if (!this.payload || !this.payload.db) {
throw new Error('MailingService payload not properly initialized') throw new Error('MailingService payload not properly initialized')
} }
if (!this.emailAdapter) { if (!this.payload.email) {
throw new Error('Email adapter not configured. Please ensure Payload has email configured.') throw new Error('Email adapter not configured. Please ensure Payload has email configured.')
} }
} }
@@ -127,6 +125,17 @@ export class MailingService implements IMailingService {
throw new Error(`Email template not found: ${templateSlug}`) throw new Error(`Email template not found: ${templateSlug}`)
} }
return this.renderTemplateDocument(template, variables)
}
/**
* Render a template document (for when you already have the template loaded)
* This avoids duplicate template lookups
* @internal
*/
async renderTemplateDocument(template: BaseEmailTemplateDocument, variables: TemplateVariables): Promise<{ html: string; text: string; subject: string }> {
this.ensureInitialized()
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)
@@ -142,7 +151,7 @@ export class MailingService implements IMailingService {
const currentTime = new Date().toISOString() const currentTime = new Date().toISOString()
const { docs: pendingEmails } = await this.payload.find({ const { docs: pendingEmails } = await this.payload.find({
collection: this.emailsCollection as any, collection: this.emailsCollection as CollectionSlug,
where: { where: {
and: [ and: [
{ {
@@ -182,7 +191,7 @@ export class MailingService implements IMailingService {
const retryTime = new Date(Date.now() - retryDelay).toISOString() const retryTime = new Date(Date.now() - retryDelay).toISOString()
const { docs: failedEmails } = await this.payload.find({ const { docs: failedEmails } = await this.payload.find({
collection: this.emailsCollection as any, collection: this.emailsCollection as CollectionSlug,
where: { where: {
and: [ and: [
{ {
@@ -222,7 +231,7 @@ export class MailingService implements IMailingService {
async processEmailItem(emailId: string): Promise<void> { async processEmailItem(emailId: string): Promise<void> {
try { try {
await this.payload.update({ await this.payload.update({
collection: this.emailsCollection as any, collection: this.emailsCollection as CollectionSlug,
id: emailId, id: emailId,
data: { data: {
status: 'processing', status: 'processing',
@@ -231,7 +240,7 @@ 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 CollectionSlug,
id: emailId, id: emailId,
depth: 1, depth: 1,
}) as BaseEmailDocument }) as BaseEmailDocument
@@ -244,7 +253,7 @@ export class MailingService implements IMailingService {
fromField = this.getDefaultFrom() fromField = this.getDefaultFrom()
} }
let mailOptions: any = { let mailOptions: SendEmailOptions = {
from: fromField, from: fromField,
to: email.to, to: email.to,
cc: email.cc || undefined, cc: email.cc || undefined,
@@ -255,6 +264,19 @@ export class MailingService implements IMailingService {
text: email.text || undefined, text: email.text || undefined,
} }
if (!mailOptions.from) {
throw new Error('Email from field is required')
}
if (!mailOptions.to || (Array.isArray(mailOptions.to) && mailOptions.to.length === 0)) {
throw new Error('Email to field is required')
}
if (!mailOptions.subject) {
throw new Error('Email subject is required')
}
if (!mailOptions.html && !mailOptions.text) {
throw new Error('Email content is required')
}
// Call beforeSend hook if configured // Call beforeSend hook if configured
if (this.config.beforeSend) { if (this.config.beforeSend) {
try { try {
@@ -280,10 +302,10 @@ export class MailingService implements IMailingService {
} }
// Send email using Payload's email adapter // Send email using Payload's email adapter
await this.emailAdapter.sendEmail(mailOptions) await this.payload.email.sendEmail(mailOptions)
await this.payload.update({ await this.payload.update({
collection: this.emailsCollection as any, collection: this.emailsCollection as CollectionSlug,
id: emailId, id: emailId,
data: { data: {
status: 'sent', status: 'sent',
@@ -297,7 +319,7 @@ export class MailingService implements IMailingService {
const maxAttempts = this.config.retryAttempts || 3 const maxAttempts = this.config.retryAttempts || 3
await this.payload.update({ await this.payload.update({
collection: this.emailsCollection as any, collection: this.emailsCollection as CollectionSlug,
id: emailId, id: emailId,
data: { data: {
status: attempts >= maxAttempts ? 'failed' : 'pending', status: attempts >= maxAttempts ? 'failed' : 'pending',
@@ -314,14 +336,14 @@ export class MailingService implements IMailingService {
private async incrementAttempts(emailId: string): Promise<number> { private async incrementAttempts(emailId: string): Promise<number> {
const email = await this.payload.findByID({ const email = await this.payload.findByID({
collection: this.emailsCollection as any, collection: this.emailsCollection as CollectionSlug,
id: emailId, id: emailId,
}) })
const newAttempts = ((email as any).attempts || 0) + 1 const newAttempts = ((email as any).attempts || 0) + 1
await this.payload.update({ await this.payload.update({
collection: this.emailsCollection as any, collection: this.emailsCollection as CollectionSlug,
id: emailId, id: emailId,
data: { data: {
attempts: newAttempts, attempts: newAttempts,

View File

@@ -1,4 +1,4 @@
import { Payload } from 'payload' import {Payload, SendEmailOptions} from 'payload'
import type { CollectionConfig, RichTextField } from 'payload' import type { CollectionConfig, RichTextField } from 'payload'
// Payload ID type (string or number) // Payload ID type (string or number)
@@ -46,28 +46,11 @@ export interface BaseEmailTemplateDocument {
updatedAt?: 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>
export type TemplateEngine = 'liquidjs' | 'mustache' | 'simple' export type TemplateEngine = 'liquidjs' | 'mustache' | 'simple'
export interface BeforeSendMailOptions { export type BeforeSendHook = (options: SendEmailOptions, email: BaseEmailDocument) => SendEmailOptions | Promise<SendEmailOptions>
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 JobPollingConfig { export interface JobPollingConfig {
maxAttempts?: number // Maximum number of polling attempts (default: 5) maxAttempts?: number // Maximum number of polling attempts (default: 5)

View File

@@ -130,6 +130,53 @@ export const renderTemplate = async (payload: Payload, templateSlug: string, var
return mailing.service.renderTemplate(templateSlug, variables) return mailing.service.renderTemplate(templateSlug, variables)
} }
/**
* Render a template and return both rendered content and template ID
* This is used by sendEmail to avoid duplicate template lookups
* @internal
*/
export const renderTemplateWithId = async (
payload: Payload,
templateSlug: string,
variables: TemplateVariables
): Promise<{ html: string; text: string; subject: string; templateId: PayloadID }> => {
const mailing = getMailing(payload)
const templatesCollection = mailing.config.collections?.templates || 'email-templates'
// Runtime validation: Ensure the collection exists in Payload
if (!payload.collections[templatesCollection]) {
throw new Error(
`Templates collection '${templatesCollection}' not found. ` +
`Available collections: ${Object.keys(payload.collections).join(', ')}`
)
}
// Look up the template document once
const { docs: templateDocs } = await payload.find({
collection: templatesCollection as any,
where: {
slug: {
equals: templateSlug,
},
},
limit: 1,
})
if (!templateDocs || templateDocs.length === 0) {
throw new Error(`Template not found: ${templateSlug}`)
}
const templateDoc = templateDocs[0]
// Render using the document directly to avoid duplicate lookup
const rendered = await mailing.service.renderTemplateDocument(templateDoc, variables)
return {
...rendered,
templateId: templateDoc.id,
}
}
export const processEmails = async (payload: Payload): Promise<void> => { export const processEmails = async (payload: Payload): Promise<void> => {
const mailing = getMailing(payload) const mailing = getMailing(payload)
return mailing.service.processEmails() return mailing.service.processEmails()

View File

@@ -129,7 +129,11 @@ export async function updateEmailJobRelationship(
id: normalizedEmailId, id: normalizedEmailId,
}) })
const currentJobs = (currentEmail.jobs || []).map((job: any) => String(job)) // Extract IDs from job objects or use the value directly if it's already an ID
// Jobs can be populated (objects with id field) or just IDs (strings/numbers)
const currentJobs = (currentEmail.jobs || []).map((job: any) =>
typeof job === 'object' && job !== null && job.id ? String(job.id) : String(job)
)
const allJobs = [...new Set([...currentJobs, ...normalizedJobIds])] // Deduplicate with normalized strings const allJobs = [...new Set([...currentJobs, ...normalizedJobIds])] // Deduplicate with normalized strings
await payload.update({ await payload.update({