Compare commits

..

8 Commits

Author SHA1 Message Date
Bas
6d4e020133 Merge pull request #21 from xtr-dev/dev
Dev
2025-09-13 20:39:44 +02:00
25838bcba4 Bump package version to 0.1.5 in package.json. 2025-09-13 20:37:20 +02:00
dfa833fa5e Eliminate code duplication between helpers and jobs
- Extract parseAndValidateEmails() as shared utility function
- Refactor sendEmailJob to use sendEmail helper internally
- Remove 100+ lines of duplicated validation and processing logic
- Maintain single source of truth for email handling logic
- Cleaner, more maintainable codebase with DRY principles

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:36:08 +02:00
cb5ce2e720 Add type-safe sendEmail helper with generics
- New sendEmail<T>() helper that extends BaseEmailData for full type safety
- Supports both template-based and direct HTML emails
- Automatic email validation and address parsing
- Merges template output with custom data fields
- Full TypeScript autocomplete for custom Email collection fields
- Updated README with comprehensive examples and API reference
- Exports BaseEmailData and SendEmailOptions types for external use

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:30:55 +02:00
f8b7dd8f4c Remove WIP comments from README 2025-09-13 20:23:53 +02:00
Bas
b3de54b953 Merge pull request #20 from xtr-dev/dev
Simplify job system architecture
2025-09-13 20:16:10 +02:00
186c340d96 Bump package version to 0.1.4 in package.json. 2025-09-13 20:14:59 +02:00
08b4d49019 Simplify job system architecture
- Replace createMailingJobs() function with static mailingJobs array
- Remove complex initialization dependencies and function wrappers
- Jobs now get MailingService from payload context instead of factory injection
- Fix PayloadCMS task handler return types to use proper {output: {}} format
- Eliminate potential initialization race conditions
- Cleaner, more straightforward job registration process

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:12:14 +02:00
7 changed files with 279 additions and 153 deletions

108
README.md
View File

@@ -56,53 +56,54 @@ export default buildConfig({
})
```
### 2. Send emails using Payload collections
### 2. Send emails with type-safe helper
```typescript
import { renderTemplate } from '@xtr-dev/payload-mailing'
import { sendEmail } from '@xtr-dev/payload-mailing'
import { Email } from './payload-types' // Your generated types
// Option 1: Using templates with variables
const { html, text, subject } = await renderTemplate(payload, 'welcome-email', {
firstName: 'John',
welcomeUrl: 'https://yoursite.com/welcome'
})
// Create email using Payload's collection API (full type safety!)
const email = await payload.create({
collection: 'emails',
// Option 1: Using templates with full type safety
const email = await sendEmail<Email>(payload, {
template: {
slug: 'welcome-email',
variables: {
firstName: 'John',
welcomeUrl: 'https://yoursite.com/welcome'
}
},
data: {
to: ['user@example.com'],
subject,
html,
text,
to: 'user@example.com',
// Schedule for later (optional)
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
// Add any custom fields you've defined
priority: 1,
customField: 'your-value', // Your custom collection fields work!
// Your custom fields are type-safe!
customField: 'your-value',
}
})
// Option 2: Direct HTML email (no template needed)
const directEmail = await payload.create({
collection: 'emails',
// Option 2: Direct HTML email (no template)
const directEmail = await sendEmail<Email>(payload, {
data: {
to: ['user@example.com'],
to: ['user@example.com', 'another@example.com'],
subject: 'Welcome!',
html: '<h1>Welcome John!</h1><p>Thanks for joining!</p>',
text: 'Welcome John! Thanks for joining!',
// All your custom fields work with TypeScript autocomplete!
customField: 'value',
}
})
// Option 3: Use payload.create() directly for full control
const manualEmail = await payload.create({
collection: 'emails',
data: {
to: ['user@example.com'],
subject: 'Hello',
html: '<p>Hello World</p>',
}
})
```
## Why This Approach is Better
-**Full Type Safety**: Use your generated Payload types
-**No Learning Curve**: Just use `payload.create()` like any collection
-**Maximum Flexibility**: Add any custom fields to your email collection
-**Payload Integration**: Leverage validation, hooks, access control
-**Consistent API**: One way to create data in Payload
## Configuration
### Plugin Options
@@ -848,6 +849,55 @@ import {
} from '@xtr-dev/payload-mailing'
```
## API Reference
### `sendEmail<T>(payload, options)`
Type-safe email sending with automatic template rendering and validation.
```typescript
import { sendEmail } from '@xtr-dev/payload-mailing'
import { Email } from './payload-types'
const email = await sendEmail<Email>(payload, {
template: {
slug: 'template-slug',
variables: { /* template variables */ }
},
data: {
to: 'user@example.com',
// Your custom fields are type-safe here!
}
})
```
**Type Parameters:**
- `T extends BaseEmailData` - Your generated Email type for full type safety
**Options:**
- `template.slug` - Template slug to render
- `template.variables` - Variables to pass to template
- `data` - Email data (merged with template output)
- `collectionSlug` - Custom collection name (defaults to 'emails')
### `renderTemplate(payload, slug, variables)`
Render an email template without sending.
```typescript
const { html, text, subject } = await renderTemplate(
payload,
'welcome-email',
{ name: 'John' }
)
```
### Helper Functions
- `getMailing(payload)` - Get mailing context
- `processEmails(payload)` - Manually trigger email processing
- `retryFailedEmails(payload)` - Manually retry failed emails
## Migration Guide (v0.0.x → v0.1.0)
**🚨 BREAKING CHANGES**: The API has been simplified to use Payload collections directly.

View File

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

View File

@@ -12,7 +12,7 @@ export { default as EmailTemplates, createEmailTemplatesCollection } from './col
export { default as Emails } from './collections/Emails.js'
// Jobs (includes the send email task)
export { createMailingJobs, sendEmailJob } from './jobs/index.js'
export { mailingJobs, sendEmailJob } from './jobs/index.js'
export type { SendEmailTaskInput } from './jobs/sendEmailTask.js'
// Utility functions for developers
@@ -21,4 +21,7 @@ export {
renderTemplate,
processEmails,
retryFailedEmails,
sendEmail,
type BaseEmailData,
type SendEmailOptions,
} from './utils/helpers.js'

View File

@@ -2,21 +2,34 @@ import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js'
import { sendEmailJob } from './sendEmailTask.js'
import { MailingService } from '../services/MailingService.js'
export const createMailingJobs = (mailingService: MailingService): any[] => {
return [
{
slug: 'processEmails',
handler: async ({ job, req }: { job: any; req: any }) => {
return processEmailsJob(
job as { data: ProcessEmailsJobData },
{ req, mailingService }
)
},
interfaceName: 'ProcessEmailsJob',
export const mailingJobs = [
{
slug: 'processEmails',
handler: async ({ job, req }: { job: any; req: any }) => {
// Get mailing context from payload
const payload = (req as any).payload
const mailingContext = payload.mailing
if (!mailingContext) {
throw new Error('Mailing plugin not properly initialized')
}
// Use the existing mailing service from context
await processEmailsJob(
job as { data: ProcessEmailsJobData },
{ req, mailingService: mailingContext.service }
)
return {
output: {
success: true,
message: 'Email queue processing completed successfully'
}
}
},
sendEmailJob,
]
}
interfaceName: 'ProcessEmailsJob',
},
sendEmailJob,
]
export * from './processEmailsJob.js'
export * from './sendEmailTask.js'

View File

@@ -1,4 +1,4 @@
import { renderTemplate } from '../utils/helpers.js'
import { sendEmail, type BaseEmailData } from '../utils/helpers.js'
export interface SendEmailTaskInput {
// Template mode fields
@@ -116,117 +116,61 @@ export const sendEmailJob = {
}
],
handler: async ({ input, payload }: any) => {
// Get mailing context from payload
const mailingContext = (payload as any).mailing
if (!mailingContext) {
throw new Error('Mailing plugin not properly initialized')
}
// Cast input to our expected type with validation
// Cast input to our expected type
const taskInput = input as SendEmailTaskInput
// Validate required fields
if (!taskInput.to) {
throw new Error('Field "to" is required')
}
try {
let html: string
let text: string | undefined
let subject: string
// Prepare options for sendEmail based on task input
const sendEmailOptions: any = {
data: {}
}
// Check if using template or direct email
// If using template mode
if (taskInput.templateSlug) {
// Template mode: render the template
const rendered = await renderTemplate(
payload,
taskInput.templateSlug,
taskInput.variables || {}
)
html = rendered.html
text = rendered.text
subject = rendered.subject
} else {
// Direct email mode: use provided content
if (!taskInput.subject || !taskInput.html) {
throw new Error('Subject and HTML content are required when not using a template')
sendEmailOptions.template = {
slug: taskInput.templateSlug,
variables: taskInput.variables || {}
}
subject = taskInput.subject
html = taskInput.html
text = taskInput.text
}
// Parse and validate email addresses
const parseEmails = (emails: string | string[] | undefined): string[] | undefined => {
if (!emails) return undefined
// Build data object from task input
const dataFields = ['to', 'cc', 'bcc', 'subject', 'html', 'text', 'scheduledAt', 'priority']
const additionalFields: string[] = []
let emailList: string[]
if (Array.isArray(emails)) {
emailList = emails
} else {
emailList = emails.split(',').map(email => email.trim()).filter(Boolean)
// Copy standard fields
dataFields.forEach(field => {
if (taskInput[field] !== undefined) {
sendEmailOptions.data[field] = taskInput[field]
}
})
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const invalidEmails = emailList.filter(email => !emailRegex.test(email))
if (invalidEmails.length > 0) {
throw new Error(`Invalid email addresses: ${invalidEmails.join(', ')}`)
}
return emailList
}
// Prepare email data
const emailData: any = {
to: parseEmails(taskInput.to),
cc: parseEmails(taskInput.cc),
bcc: parseEmails(taskInput.bcc),
subject,
html,
text,
priority: taskInput.priority || 5,
}
// Add scheduled date if provided
if (taskInput.scheduledAt) {
emailData.scheduledAt = new Date(taskInput.scheduledAt).toISOString()
}
// Add any additional fields from input (excluding the ones we've already handled)
const handledFields = ['templateSlug', 'to', 'cc', 'bcc', 'variables', 'scheduledAt', 'priority']
// Copy any additional custom fields
Object.keys(taskInput).forEach(key => {
if (!handledFields.includes(key)) {
emailData[key] = taskInput[key]
if (!['templateSlug', 'variables', ...dataFields].includes(key)) {
sendEmailOptions.data[key] = taskInput[key]
additionalFields.push(key)
}
})
// Create the email in the collection using configurable collection name
const email = await payload.create({
collection: mailingContext.collections.emails,
data: emailData
})
// Use the sendEmail helper to create the email
const email = await sendEmail<BaseEmailData>(payload, sendEmailOptions)
return {
success: true,
emailId: email.id,
message: `Email queued successfully with ID: ${email.id}`,
mode: taskInput.templateSlug ? 'template' : 'direct',
templateSlug: taskInput.templateSlug || null,
subject: subject,
recipients: emailData.to?.length || 0,
scheduledAt: emailData.scheduledAt || null
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
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
return {
success: false,
error: errorMessage,
templateSlug: taskInput.templateSlug,
message: `Failed to queue email: ${errorMessage}`
}
throw new Error(`Failed to queue email: ${errorMessage}`)
}
}
}

View File

@@ -3,7 +3,7 @@ import { MailingPluginConfig, MailingContext } from './types/index.js'
import { MailingService } from './services/MailingService.js'
import { createEmailTemplatesCollection } from './collections/EmailTemplates.js'
import Emails from './collections/Emails.js'
import { createMailingJobs, scheduleEmailsJob } from './jobs/index.js'
import { mailingJobs, scheduleEmailsJob } from './jobs/index.js'
export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => {
@@ -14,14 +14,6 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
throw new Error('Invalid queue configuration: queue must be a non-empty string')
}
// Create a factory function that will provide the mailing service once initialized
const getMailingService = () => {
if (!mailingService) {
throw new Error('MailingService not yet initialized - this should only be called after plugin initialization')
}
return mailingService
}
let mailingService: MailingService
// Handle templates collection configuration
const templatesConfig = pluginConfig.collections?.templates
@@ -93,7 +85,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
...(config.jobs || {}),
tasks: [
...(config.jobs?.tasks || []),
// Jobs will be properly added after initialization
...mailingJobs,
],
},
onInit: async (payload: any) => {
@@ -102,13 +94,7 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
}
// Initialize mailing service with proper payload instance
mailingService = new MailingService(payload, pluginConfig)
// Add mailing jobs to payload's job system
const mailingJobs = createMailingJobs(mailingService)
mailingJobs.forEach(job => {
payload.jobs.tasks.push(job)
})
const mailingService = new MailingService(payload, pluginConfig)
// Add mailing context to payload for developer access
;(payload as any).mailing = {

View File

@@ -1,6 +1,56 @@
import { Payload } from 'payload'
import { TemplateVariables } from '../types/index.js'
// Base type for email data that all emails must have
export interface BaseEmailData {
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
subject?: string
html?: string
text?: string
scheduledAt?: string | Date
priority?: number
[key: string]: any
}
// Options for sending emails
export interface SendEmailOptions<T extends BaseEmailData = BaseEmailData> {
// Template-based email
template?: {
slug: string
variables?: Record<string, any>
}
// Direct email data
data?: Partial<T>
// Common options
collectionSlug?: string // defaults to 'emails'
}
/**
* Parse and validate email addresses
* @internal
*/
export const parseAndValidateEmails = (emails: string | string[] | undefined): string[] | undefined => {
if (!emails) return undefined
let emailList: string[]
if (Array.isArray(emails)) {
emailList = emails
} else {
emailList = emails.split(',').map(email => email.trim()).filter(Boolean)
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const invalidEmails = emailList.filter(email => !emailRegex.test(email))
if (invalidEmails.length > 0) {
throw new Error(`Invalid email addresses: ${invalidEmails.join(', ')}`)
}
return emailList
}
export const getMailing = (payload: Payload) => {
const mailing = (payload as any).mailing
if (!mailing) {
@@ -23,3 +73,83 @@ export const retryFailedEmails = async (payload: Payload): Promise<void> => {
const mailing = getMailing(payload)
return mailing.service.retryFailedEmails()
}
/**
* Send an email with full type safety
*
* @example
* ```typescript
* // With your generated Email type
* import { Email } from './payload-types'
*
* const email = await sendEmail<Email>(payload, {
* template: {
* slug: 'welcome',
* variables: { name: 'John' }
* },
* data: {
* to: 'user@example.com',
* customField: 'value' // Your custom fields are type-safe!
* }
* })
* ```
*/
export const sendEmail = async <T extends BaseEmailData = BaseEmailData>(
payload: Payload,
options: SendEmailOptions<T>
): Promise<T> => {
const mailing = getMailing(payload)
const collectionSlug = options.collectionSlug || mailing.collections.emails || 'emails'
let emailData: Partial<T> = { ...options.data } as Partial<T>
// If using a template, render it first
if (options.template) {
const { html, text, subject } = await renderTemplate(
payload,
options.template.slug,
options.template.variables || {}
)
// Template values take precedence over data values
emailData = {
...emailData,
subject,
html,
text,
} as Partial<T>
}
// Validate required fields
if (!emailData.to) {
throw new Error('Field "to" is required for sending emails')
}
if (!emailData.subject || !emailData.html) {
throw new Error('Fields "subject" and "html" are required when not using a template')
}
// Process email addresses using shared validation
if (emailData.to) {
emailData.to = parseAndValidateEmails(emailData.to as string | string[])
}
if (emailData.cc) {
emailData.cc = parseAndValidateEmails(emailData.cc as string | string[])
}
if (emailData.bcc) {
emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[])
}
// Convert scheduledAt to ISO string if it's a Date
if (emailData.scheduledAt instanceof Date) {
emailData.scheduledAt = emailData.scheduledAt.toISOString()
}
// Create the email in the collection
const email = await payload.create({
collection: collectionSlug as any,
data: emailData as any
})
return email as unknown as T
}