mirror of
https://github.com/xtr-dev/payload-mailing.git
synced 2025-12-10 00:03:23 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c4d894f51 | ||
| 1af54c6573 | |||
| 24f1f4c5a4 | |||
| de41f4ecb2 | |||
|
|
6d4e020133 | ||
| 25838bcba4 | |||
| dfa833fa5e | |||
| cb5ce2e720 | |||
| f8b7dd8f4c |
109
README.md
109
README.md
@@ -56,53 +56,55 @@ export default buildConfig({
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Send emails using Payload collections
|
||||
### 2. Send emails with type-safe helper
|
||||
|
||||
```typescript
|
||||
import { renderTemplate } from '@xtr-dev/payload-mailing'
|
||||
// sendEmail is a primary export for easy access
|
||||
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 +850,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.
|
||||
|
||||
@@ -184,25 +184,43 @@ The plugin automatically processes the outbox every 5 minutes and retries failed
|
||||
## Plugin API Usage
|
||||
|
||||
```javascript
|
||||
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
|
||||
import { sendEmail } from '@xtr-dev/payload-mailing'
|
||||
|
||||
// Send immediate email
|
||||
const emailId = await sendEmail(payload, {
|
||||
templateId: 'welcome-template-id',
|
||||
to: 'user@example.com',
|
||||
variables: {
|
||||
firstName: 'John',
|
||||
siteName: 'My App'
|
||||
// Send immediate email with template
|
||||
const email = await sendEmail(payload, {
|
||||
template: {
|
||||
slug: 'welcome-email',
|
||||
variables: {
|
||||
firstName: 'John',
|
||||
siteName: 'My App'
|
||||
}
|
||||
},
|
||||
data: {
|
||||
to: 'user@example.com',
|
||||
}
|
||||
})
|
||||
|
||||
// Schedule email
|
||||
const scheduledId = await scheduleEmail(payload, {
|
||||
templateId: 'reminder-template-id',
|
||||
to: 'user@example.com',
|
||||
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
variables: {
|
||||
eventName: 'Product Launch'
|
||||
// Schedule email for later
|
||||
const scheduledEmail = await sendEmail(payload, {
|
||||
template: {
|
||||
slug: 'reminder',
|
||||
variables: {
|
||||
eventName: 'Product Launch'
|
||||
}
|
||||
},
|
||||
data: {
|
||||
to: 'user@example.com',
|
||||
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
}
|
||||
})
|
||||
|
||||
// Send direct HTML email (no template)
|
||||
const directEmail = await sendEmail(payload, {
|
||||
data: {
|
||||
to: 'user@example.com',
|
||||
subject: 'Direct Email',
|
||||
html: '<h1>Hello World</h1>',
|
||||
text: 'Hello World'
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -1,37 +1,50 @@
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
|
||||
import { sendEmail } from '@xtr-dev/payload-mailing'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const body = await request.json()
|
||||
const { type = 'send', templateSlug, to, variables, scheduledAt } = body
|
||||
const { type = 'send', templateSlug, to, variables, scheduledAt, subject, html, text } = body
|
||||
|
||||
let result
|
||||
if (type === 'send') {
|
||||
// Send immediately
|
||||
result = await sendEmail(payload, {
|
||||
templateSlug,
|
||||
// Use the new sendEmail API
|
||||
const emailOptions: any = {
|
||||
data: {
|
||||
to,
|
||||
variables,
|
||||
})
|
||||
} else if (type === 'schedule') {
|
||||
// Schedule for later
|
||||
result = await scheduleEmail(payload, {
|
||||
templateSlug,
|
||||
to,
|
||||
variables,
|
||||
scheduledAt: scheduledAt ? new Date(scheduledAt) : new Date(Date.now() + 60000), // Default to 1 minute
|
||||
})
|
||||
} else {
|
||||
return Response.json({ error: 'Invalid type. Use "send" or "schedule"' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Add template if provided
|
||||
if (templateSlug) {
|
||||
emailOptions.template = {
|
||||
slug: templateSlug,
|
||||
variables: variables || {}
|
||||
}
|
||||
} else if (subject && html) {
|
||||
// Direct email without template
|
||||
emailOptions.data.subject = subject
|
||||
emailOptions.data.html = html
|
||||
if (text) {
|
||||
emailOptions.data.text = text
|
||||
}
|
||||
} else {
|
||||
return Response.json({
|
||||
error: 'Either templateSlug or subject+html must be provided'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Add scheduling if needed
|
||||
if (type === 'schedule' || scheduledAt) {
|
||||
emailOptions.data.scheduledAt = scheduledAt ? new Date(scheduledAt) : new Date(Date.now() + 60000)
|
||||
}
|
||||
|
||||
const result = await sendEmail(payload, emailOptions)
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
emailId: result,
|
||||
message: type === 'send' ? 'Email sent successfully' : 'Email scheduled successfully',
|
||||
emailId: result.id,
|
||||
message: scheduledAt ? 'Email scheduled successfully' : 'Email queued successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Test email error:', error)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Simple test to verify plugin can be imported and initialized
|
||||
import { mailingPlugin, sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing'
|
||||
import { mailingPlugin, sendEmail, renderTemplate } from '@xtr-dev/payload-mailing'
|
||||
|
||||
console.log('✅ Plugin imports successfully')
|
||||
console.log('✅ mailingPlugin:', typeof mailingPlugin)
|
||||
console.log('✅ sendEmail:', typeof sendEmail)
|
||||
console.log('✅ scheduleEmail:', typeof scheduleEmail)
|
||||
console.log('✅ sendEmail:', typeof sendEmail)
|
||||
console.log('✅ renderTemplate:', typeof renderTemplate)
|
||||
|
||||
// Test plugin configuration
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xtr-dev/payload-mailing",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.6",
|
||||
"description": "Template-based email system with scheduling and job processing for PayloadCMS",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -15,10 +15,15 @@ export { default as Emails } from './collections/Emails.js'
|
||||
export { mailingJobs, sendEmailJob } from './jobs/index.js'
|
||||
export type { SendEmailTaskInput } from './jobs/sendEmailTask.js'
|
||||
|
||||
// Main email sending function
|
||||
export { sendEmail, type BaseEmailData, type SendEmailOptions } from './sendEmail.js'
|
||||
export { default as sendEmailDefault } from './sendEmail.js'
|
||||
|
||||
// Utility functions for developers
|
||||
export {
|
||||
getMailing,
|
||||
renderTemplate,
|
||||
processEmails,
|
||||
retryFailedEmails,
|
||||
parseAndValidateEmails,
|
||||
} from './utils/helpers.js'
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderTemplate } from '../utils/helpers.js'
|
||||
import { sendEmail, type BaseEmailData } from '../sendEmail.js'
|
||||
|
||||
export interface SendEmailTaskInput {
|
||||
// Template mode fields
|
||||
@@ -116,96 +116,44 @@ 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 {
|
||||
output: {
|
||||
@@ -214,15 +162,14 @@ export const sendEmailJob = {
|
||||
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
|
||||
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'
|
||||
|
||||
throw new Error(`Failed to queue email: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
110
src/sendEmail.ts
Normal file
110
src/sendEmail.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Payload } from 'payload'
|
||||
import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
export default sendEmail
|
||||
@@ -1,6 +1,30 @@
|
||||
import { Payload } from 'payload'
|
||||
import { TemplateVariables } from '../types/index.js'
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
Reference in New Issue
Block a user