Compare commits

..

7 Commits

Author SHA1 Message Date
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
7 changed files with 99 additions and 5 deletions

View File

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

View File

@@ -12,7 +12,7 @@ const Emails: CollectionConfig = {
description: 'Email delivery and status tracking', description: 'Email delivery and status tracking',
}, },
defaultPopulate: { defaultPopulate: {
template: true, templateSlug: true,
to: true, to: true,
cc: true, cc: true,
bcc: true, bcc: true,
@@ -40,6 +40,14 @@ const Emails: CollectionConfig = {
description: 'Email template used (optional if custom content provided)', 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', name: 'to',
type: 'text', type: 'text',
@@ -208,6 +216,27 @@ const Emails: CollectionConfig = {
}, },
], ],
hooks: { 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 // Simple approach: Only use afterChange hook for job management
// This avoids complex interaction between hooks and ensures document ID is always available // This avoids complex interaction between hooks and ensures document ID is always available
afterChange: [ afterChange: [

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

@@ -127,6 +127,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)

View File

@@ -14,6 +14,7 @@ export type JSONValue = string | number | boolean | { [k: string]: unknown } | u
export interface BaseEmailDocument { export interface BaseEmailDocument {
id: string | number id: string | number
template?: any template?: any
templateSlug?: string | null
to: string[] to: string[]
cc?: string[] | null cc?: string[] | null
bcc?: string[] | null bcc?: string[] | null

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({