Compare commits

...

14 Commits

Author SHA1 Message Date
Bas
9811d63a92 Merge pull request #69 from xtr-dev/dev
Bump version to 0.4.21
2025-10-11 15:18:16 +02:00
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
Bas
53ab62ed10 Merge pull request #68 from xtr-dev/dev
Fix filterOptions ObjectId casting error and bump version to 0.4.20
2025-10-07 22:04:22 +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
Bas
4633ead274 Merge pull request #67 from xtr-dev/dev
Fix ObjectId casting error when jobs relationship is populated
2025-10-07 21:38:42 +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
Bas
57984e8633 Merge pull request #65 from xtr-dev/dev
Fix template relationship population in sendEmail and bump version to…
2025-10-06 23:58:25 +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
Bas
63a5c5f982 Merge pull request #64 from xtr-dev/dev
Add templateSlug field auto-populated from template relationship and …
2025-10-06 23:38:11 +02:00
107f67e22b Add templateSlug field auto-populated from template relationship and bump version to 0.4.17
Added templateSlug text field to Emails collection that is automatically populated via beforeChange hook when template relationship is set, making template slug accessible in beforeSend hook.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 23:37:17 +02:00
Bas
e95296feff Merge pull request #63 from xtr-dev/dev
Fix template population in beforeSend hook and bump version to 0.4.16
2025-10-06 23:23:14 +02:00
7b853cbd4a Fix template population in beforeSend hook and bump version to 0.4.16
Added depth parameter to findByID call in processEmailItem to ensure template relationship is populated when passed to beforeSend hook.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 23:20:56 +02:00
7 changed files with 129 additions and 40 deletions

View File

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

View File

@@ -12,7 +12,7 @@ const Emails: CollectionConfig = {
description: 'Email delivery and status tracking',
},
defaultPopulate: {
template: true,
templateSlug: true,
to: true,
cc: true,
bcc: true,
@@ -40,6 +40,14 @@ const Emails: CollectionConfig = {
description: 'Email template used (optional if custom content provided)',
},
},
{
name: 'templateSlug',
type: 'text',
admin: {
description: 'Slug of the email template (auto-populated from template relationship)',
readOnly: true,
},
},
{
name: 'to',
type: 'text',
@@ -198,7 +206,7 @@ const Emails: CollectionConfig = {
readOnly: true,
},
filterOptions: ({ id }) => {
const emailId = resolveID({ id })
const emailId = resolveID(id)
return {
'input.emailId': {
equals: emailId ? String(emailId) : '',
@@ -208,6 +216,27 @@ const Emails: CollectionConfig = {
},
],
hooks: {
beforeChange: [
async ({ data, req }) => {
// Auto-populate templateSlug from template relationship
if (data.template) {
try {
const template = await req.payload.findByID({
collection: 'email-templates',
id: typeof data.template === 'string' ? data.template : data.template.id,
})
data.templateSlug = template.slug
} catch (error) {
// If template lookup fails, clear the slug
data.templateSlug = undefined
}
} else {
// Clear templateSlug if template is removed
data.templateSlug = undefined
}
return data
}
],
// Simple approach: Only use afterChange hook for job management
// This avoids complex interaction between hooks and ensures document ID is always available
afterChange: [

View File

@@ -1,5 +1,5 @@
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 { processJobById } from './utils/emailProcessor.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>
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,
options.template.slug,
options.template.variables || {}
@@ -58,6 +59,7 @@ export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocu
emailData = {
...emailData,
template: templateId,
subject,
html,
text,

View File

@@ -1,10 +1,10 @@
import { Payload } from 'payload'
import {CollectionSlug, EmailAdapter, Payload, SendEmailOptions} from 'payload'
import { Liquid } from 'liquidjs'
import {
MailingPluginConfig,
TemplateVariables,
MailingService as IMailingService,
BaseEmail, BaseEmailTemplate, BaseEmailDocument, BaseEmailTemplateDocument
BaseEmailDocument, BaseEmailTemplateDocument
} from '../types/index.js'
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
import { sanitizeDisplayName } from '../utils/helpers.js'
@@ -12,7 +12,6 @@ import { sanitizeDisplayName } from '../utils/helpers.js'
export class MailingService implements IMailingService {
public payload: Payload
private config: MailingPluginConfig
private emailAdapter: any
private templatesCollection: string
private emailsCollection: string
private liquid: Liquid | null | false = null
@@ -31,14 +30,13 @@ export class MailingService implements IMailingService {
if (!this.payload.email) {
throw new Error('Payload email configuration is required. Please configure email in your Payload config.')
}
this.emailAdapter = this.payload.email
}
private ensureInitialized(): void {
if (!this.payload || !this.payload.db) {
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.')
}
}
@@ -127,6 +125,17 @@ export class MailingService implements IMailingService {
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 subject = await this.renderTemplateString(template.subject || '', variables)
@@ -142,7 +151,7 @@ export class MailingService implements IMailingService {
const currentTime = new Date().toISOString()
const { docs: pendingEmails } = await this.payload.find({
collection: this.emailsCollection as any,
collection: this.emailsCollection as CollectionSlug,
where: {
and: [
{
@@ -182,7 +191,7 @@ export class MailingService implements IMailingService {
const retryTime = new Date(Date.now() - retryDelay).toISOString()
const { docs: failedEmails } = await this.payload.find({
collection: this.emailsCollection as any,
collection: this.emailsCollection as CollectionSlug,
where: {
and: [
{
@@ -222,7 +231,7 @@ export class MailingService implements IMailingService {
async processEmailItem(emailId: string): Promise<void> {
try {
await this.payload.update({
collection: this.emailsCollection as any,
collection: this.emailsCollection as CollectionSlug,
id: emailId,
data: {
status: 'processing',
@@ -231,8 +240,9 @@ export class MailingService implements IMailingService {
})
const email = await this.payload.findByID({
collection: this.emailsCollection as any,
collection: this.emailsCollection as CollectionSlug,
id: emailId,
depth: 1,
}) as BaseEmailDocument
// Combine from and fromName for nodemailer using proper sanitization
@@ -243,7 +253,7 @@ export class MailingService implements IMailingService {
fromField = this.getDefaultFrom()
}
let mailOptions: any = {
let mailOptions: SendEmailOptions = {
from: fromField,
to: email.to,
cc: email.cc || undefined,
@@ -254,6 +264,19 @@ export class MailingService implements IMailingService {
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
if (this.config.beforeSend) {
try {
@@ -279,10 +302,10 @@ export class MailingService implements IMailingService {
}
// Send email using Payload's email adapter
await this.emailAdapter.sendEmail(mailOptions)
await this.payload.email.sendEmail(mailOptions)
await this.payload.update({
collection: this.emailsCollection as any,
collection: this.emailsCollection as CollectionSlug,
id: emailId,
data: {
status: 'sent',
@@ -296,7 +319,7 @@ export class MailingService implements IMailingService {
const maxAttempts = this.config.retryAttempts || 3
await this.payload.update({
collection: this.emailsCollection as any,
collection: this.emailsCollection as CollectionSlug,
id: emailId,
data: {
status: attempts >= maxAttempts ? 'failed' : 'pending',
@@ -313,14 +336,14 @@ export class MailingService implements IMailingService {
private async incrementAttempts(emailId: string): Promise<number> {
const email = await this.payload.findByID({
collection: this.emailsCollection as any,
collection: this.emailsCollection as CollectionSlug,
id: emailId,
})
const newAttempts = ((email as any).attempts || 0) + 1
await this.payload.update({
collection: this.emailsCollection as any,
collection: this.emailsCollection as CollectionSlug,
id: emailId,
data: {
attempts: newAttempts,

View File

@@ -1,4 +1,4 @@
import { Payload } from 'payload'
import {Payload, SendEmailOptions} from 'payload'
import type { CollectionConfig, RichTextField } from 'payload'
// Payload ID type (string or number)
@@ -14,6 +14,7 @@ export type JSONValue = string | number | boolean | { [k: string]: unknown } | u
export interface BaseEmailDocument {
id: string | number
template?: any
templateSlug?: string | null
to: string[]
cc?: string[] | null
bcc?: string[] | null
@@ -45,28 +46,11 @@ export interface BaseEmailTemplateDocument {
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 type BeforeSendHook = (options: SendEmailOptions, email: BaseEmailDocument) => SendEmailOptions | Promise<SendEmailOptions>
export interface JobPollingConfig {
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)
}
/**
* 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> => {
const mailing = getMailing(payload)
return mailing.service.processEmails()

View File

@@ -129,7 +129,11 @@ export async function updateEmailJobRelationship(
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
await payload.update({