Compare commits

...

14 Commits

Author SHA1 Message Date
Bas
5905f732de Merge pull request #29 from xtr-dev/dev
Support custom ID types (string/number) for improved compatibility
2025-09-13 23:24:55 +02:00
4c495a72b0 Remove duplicate BaseEmailDocument definition
- Remove duplicate BaseEmailDocument interface from sendEmail.ts
- Import BaseEmailDocument from types/index.ts instead
- Update sendEmailTask.ts to import from types/index.ts
- Maintain single source of truth for BaseEmailDocument type definition

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:22:23 +02:00
8518c716e8 Bump package version to 0.1.13 in package.json. 2025-09-13 23:21:33 +02:00
570190be01 Support custom ID types (string/number) for improved compatibility
- Replace hardcoded payload-types imports with generic BaseEmailDocument interface
- Update sendEmail and sendEmailTask to work with both string and number IDs
- Refactor MailingService to use generic document types instead of specific ones
- Add BaseEmailDocument and BaseEmailTemplateDocument interfaces supporting id: string | number
- Export BaseEmailDocument for users to extend with their custom fields
- Fix TypeScript compilation error in template subject handling
- Add CUSTOM-TYPES.md documentation for users with different ID types

Fixes compatibility issue where plugin required number IDs but user projects used string IDs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:18:37 +02:00
Bas
685875d1b9 Merge pull request #28 from xtr-dev/dev
Dev
2025-09-13 23:11:16 +02:00
79044b7bc3 Remove unused BaseEmail imports
- Remove BaseEmail import from sendEmail.ts (no longer used after type refactoring)
- Remove BaseEmail import from sendEmailTask.ts (no longer used after type refactoring)
- BaseEmail types are still used in MailingService.ts for proper type casting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:10:20 +02:00
e7304fe1a2 Improve type safety, error handling, and code maintainability
- Simplify sendEmail generic constraints for better type safety
- Add validation before type assertions in sendEmail
- Preserve error stack traces in sendEmailTask error handling
- Extract field copying logic into reusable helper function
- Improve code documentation and separation of concerns

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:06:02 +02:00
790eedfee7 Bump package version to 0.1.12 in package.json. 2025-09-13 23:01:06 +02:00
9520ec5ed1 Refactor email types for enhanced consistency and type safety
- Replace `EmailTemplate` with `BaseEmailTemplate` for stricter type validation.
- Update `sendEmail` and `sendEmailTask` to utilize refined `BaseEmail` structure.
- Simplify type definitions in `MailingService` and related modules.
2025-09-13 23:00:41 +02:00
Bas
768b70a003 Merge pull request #27 from xtr-dev/dev
Align `sendEmail` and `sendEmailTask` with updated `BaseEmail` typing
2025-09-13 22:49:05 +02:00
e91ab7e54e Bump package version to 0.1.11 in package.json. 2025-09-13 22:48:55 +02:00
06f9c2cb5b Align sendEmail and sendEmailTask with updated BaseEmail typing
- Refactor `sendEmail` to return extended type with `id` for better type inference.
- Update `sendEmailTask` to use `BaseEmail` instead of `Email`.
- Add `outputSchema` in `sendEmailTask` for consistent output structure.
2025-09-13 22:46:30 +02:00
Bas
21b22a033a Merge pull request #26 from xtr-dev/dev
Refactor `sendEmail` to improve type safety and align with `BaseEmail…
2025-09-13 22:41:28 +02:00
6ad90874cf Refactor sendEmail to improve type safety and align with BaseEmail interface
- Replace `Email` with `BaseEmail` for stricter type validation.
- Update `SendEmailOptions` and `sendEmail` typing for improved extensibility.
2025-09-13 22:39:28 +02:00
6 changed files with 206 additions and 73 deletions

88
CUSTOM-TYPES.md Normal file
View File

@@ -0,0 +1,88 @@
# 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!
}
})
```
## ID Type Compatibility
The plugin works with both:
- **String IDs**: `id: string`
- **Number IDs**: `id: number`
Your Payload configuration determines which type is used. The plugin automatically adapts to your setup.
## Type Definitions
The base interfaces provided by the plugin:
```typescript
interface BaseEmailDocument {
id: string | number
template?: any
to: string[]
cc?: string[]
bcc?: string[]
from?: string
replyTo?: string
subject: string
html: string
text?: string
variables?: Record<string, any>
scheduledAt?: string
sentAt?: string
status?: 'pending' | 'processing' | 'sent' | 'failed'
attempts?: number
lastAttemptAt?: string
error?: string
priority?: number
createdAt?: string
updatedAt?: string
}
interface BaseEmailTemplateDocument {
id: string | number
name: string
slug: string
subject?: string
content?: any
createdAt?: string
updatedAt?: string
}
```
These provide a foundation that works with any ID type while maintaining type safety for the core email functionality.

View File

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

View File

@@ -1,5 +1,5 @@
import { sendEmail } from '../sendEmail.js'
import { Email } from '../payload-types.js'
import { BaseEmailDocument } from '../types/index.js'
export interface SendEmailTaskInput {
// Template mode fields
@@ -22,6 +22,45 @@ export interface SendEmailTaskInput {
[key: string]: any
}
/**
* Transforms task input into sendEmail options by separating template and data fields
*/
function transformTaskInputToSendEmailOptions(taskInput: SendEmailTaskInput) {
const sendEmailOptions: any = {
data: {}
}
// If using template mode, set template options
if (taskInput.templateSlug) {
sendEmailOptions.template = {
slug: taskInput.templateSlug,
variables: taskInput.variables || {}
}
}
// Standard email fields that should be copied to data
const standardFields = ['to', 'cc', 'bcc', 'subject', 'html', 'text', 'scheduledAt', 'priority']
// Template-specific fields that should not be copied to data
const templateFields = ['templateSlug', 'variables']
// Copy standard fields to data
standardFields.forEach(field => {
if (taskInput[field] !== undefined) {
sendEmailOptions.data[field] = taskInput[field]
}
})
// Copy any additional custom fields that aren't template or standard fields
Object.keys(taskInput).forEach(key => {
if (!templateFields.includes(key) && !standardFields.includes(key)) {
sendEmailOptions.data[key] = taskInput[key]
}
})
return sendEmailOptions
}
export const sendEmailJob = {
slug: 'send-email',
label: 'Send Email',
@@ -116,64 +155,42 @@ export const sendEmailJob = {
}
}
],
outputSchema: [
{
name: 'id',
type: 'text' as const
}
],
handler: async ({ input, payload }: any) => {
// Cast input to our expected type
const taskInput = input as SendEmailTaskInput
try {
// Prepare options for sendEmail based on task input
const sendEmailOptions: any = {
data: {}
}
// If using template mode
if (taskInput.templateSlug) {
sendEmailOptions.template = {
slug: taskInput.templateSlug,
variables: taskInput.variables || {}
}
}
// Build data object from task input
const dataFields = ['to', 'cc', 'bcc', 'subject', 'html', 'text', 'scheduledAt', 'priority']
const additionalFields: string[] = []
// Copy standard fields
dataFields.forEach(field => {
if (taskInput[field] !== undefined) {
sendEmailOptions.data[field] = taskInput[field]
}
})
// Copy any additional custom fields
Object.keys(taskInput).forEach(key => {
if (!['templateSlug', 'variables', ...dataFields].includes(key)) {
sendEmailOptions.data[key] = taskInput[key]
additionalFields.push(key)
}
})
// Transform task input into sendEmail options using helper function
const sendEmailOptions = transformTaskInputToSendEmailOptions(taskInput)
// Use the sendEmail helper to create the email
const email = await sendEmail<Email>(payload, sendEmailOptions)
const email = await sendEmail<BaseEmailDocument>(payload, sendEmailOptions)
return {
output: {
success: true,
emailId: email.id,
message: `Email queued successfully with ID: ${email.id}`,
mode: taskInput.templateSlug ? 'template' : 'direct',
templateSlug: taskInput.templateSlug || null,
subject: email.subject,
recipients: Array.isArray(email.to) ? email.to.length : 1,
scheduledAt: email.scheduledAt || null
id: email.id,
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
throw new Error(`Failed to queue email: ${errorMessage}`)
if (error instanceof Error) {
// Preserve original error and stack trace
const wrappedError = new Error(`Failed to queue email: ${error.message}`)
wrappedError.stack = error.stack
wrappedError.cause = error
throw wrappedError
} else {
throw new Error(`Failed to queue email: ${String(error)}`)
}
}
}
}
export default sendEmailJob
export default sendEmailJob

View File

@@ -1,9 +1,9 @@
import { Payload } from 'payload'
import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js'
import {Email} from "./payload-types.js"
import { BaseEmailDocument } from './types/index.js'
// Options for sending emails
export interface SendEmailOptions<T extends Email = Email> {
export interface SendEmailOptions<T extends BaseEmailDocument = BaseEmailDocument> {
// Template-based email
template?: {
slug: string
@@ -35,14 +35,14 @@ export interface SendEmailOptions<T extends Email = Email> {
* })
* ```
*/
export const sendEmail = async <T extends Email = Email>(
export const sendEmail = async <TEmail extends BaseEmailDocument = BaseEmailDocument>(
payload: Payload,
options: SendEmailOptions<T>
): Promise<T> => {
options: SendEmailOptions<TEmail>
): Promise<TEmail> => {
const mailing = getMailing(payload)
const collectionSlug = options.collectionSlug || mailing.collections.emails || 'emails'
let emailData: Partial<T> = { ...options.data } as Partial<T>
let emailData: Partial<TEmail> = { ...options.data } as Partial<TEmail>
// If using a template, render it first
if (options.template) {
@@ -58,7 +58,7 @@ export const sendEmail = async <T extends Email = Email>(
subject,
html,
text,
} as Partial<T>
} as Partial<TEmail>
}
// Validate required fields
@@ -96,7 +96,12 @@ export const sendEmail = async <T extends Email = Email>(
data: emailData
})
return email as T
// Validate that the created email has the expected structure
if (!email || typeof email !== 'object' || !email.id) {
throw new Error('Failed to create email: invalid response from database')
}
return email as TEmail
}
export default sendEmail

View File

@@ -5,10 +5,8 @@ import {
MailingPluginConfig,
TemplateVariables,
MailingService as IMailingService,
EmailTemplate,
QueuedEmail,
MailingTransportConfig,
BaseEmail
BaseEmail, BaseEmailTemplate, BaseEmailDocument, BaseEmailTemplateDocument
} from '../types/index.js'
import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js'
@@ -133,7 +131,7 @@ export class MailingService implements IMailingService {
}
const emailContent = await this.renderEmailTemplate(template, variables)
const subject = await this.renderTemplateString(template.subject, variables)
const subject = await this.renderTemplateString(template.subject || '', variables)
return {
html: emailContent.html,
@@ -238,7 +236,7 @@ export class MailingService implements IMailingService {
const email = await this.payload.findByID({
collection: this.emailsCollection as any,
id: emailId,
}) as BaseEmail
}) as BaseEmailDocument
const mailOptions = {
from: email.from,
@@ -287,7 +285,7 @@ export class MailingService implements IMailingService {
const email = await this.payload.findByID({
collection: this.emailsCollection as any,
id: emailId,
}) as QueuedEmail
}) as BaseEmail
const newAttempts = (email.attempts || 0) + 1
@@ -302,7 +300,7 @@ export class MailingService implements IMailingService {
return newAttempts
}
private async getTemplateBySlug(templateSlug: string): Promise<EmailTemplate | null> {
private async getTemplateBySlug(templateSlug: string): Promise<BaseEmailTemplateDocument | null> {
try {
const { docs } = await this.payload.find({
collection: this.templatesCollection as any,
@@ -314,7 +312,7 @@ export class MailingService implements IMailingService {
limit: 1,
})
return docs.length > 0 ? docs[0] as EmailTemplate : null
return docs.length > 0 ? docs[0] as BaseEmailTemplateDocument : null
} catch (error) {
console.error(`Template with slug '${templateSlug}' not found:`, error)
return null
@@ -379,7 +377,7 @@ export class MailingService implements IMailingService {
})
}
private async renderEmailTemplate(template: EmailTemplate, 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) {
return { html: '', text: '' }
}

View File

@@ -1,9 +1,44 @@
import { Payload } from 'payload'
import type { CollectionConfig, RichTextField } from 'payload'
import { Transporter } from 'nodemailer'
import {Email} from "../payload-types.js"
export type BaseEmail<TEmail = Email, TEmailTemplate = EmailTemplate> = Omit<TEmail, 'id' | 'template'> & {template: Omit<TEmailTemplate, 'id'>}
// Generic base interfaces that work with any ID type
export interface BaseEmailDocument {
id: string | number
template?: any
to: string[]
cc?: string[]
bcc?: string[]
from?: string
replyTo?: string
subject: string
html: string
text?: string
variables?: Record<string, any>
scheduledAt?: string
sentAt?: string
status?: 'pending' | 'processing' | 'sent' | 'failed'
attempts?: number
lastAttemptAt?: string
error?: string
priority?: number
createdAt?: string
updatedAt?: string
}
export interface BaseEmailTemplateDocument {
id: string | number
name: string
slug: string
subject?: string
content?: any
createdAt?: string
updatedAt?: string
}
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>
@@ -37,16 +72,6 @@ export interface MailingTransportConfig {
}
}
export interface EmailTemplate {
id: string
name: string
slug: string
subject: string
content: any // Lexical editor state
createdAt: string
updatedAt: string
}
export interface QueuedEmail {
id: string