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>
This commit is contained in:
2025-09-13 20:30:55 +02:00
parent f8b7dd8f4c
commit cb5ce2e720
3 changed files with 209 additions and 21 deletions

100
README.md
View File

@@ -56,41 +56,50 @@ export default buildConfig({
}) })
``` ```
### 2. Send emails using Payload collections ### 2. Send emails with type-safe helper
```typescript ```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 // Option 1: Using templates with full type safety
const { html, text, subject } = await renderTemplate(payload, 'welcome-email', { const email = await sendEmail<Email>(payload, {
firstName: 'John', template: {
welcomeUrl: 'https://yoursite.com/welcome' slug: 'welcome-email',
}) variables: {
firstName: 'John',
// Create email using Payload's collection API (full type safety!) welcomeUrl: 'https://yoursite.com/welcome'
const email = await payload.create({ }
collection: 'emails', },
data: { data: {
to: ['user@example.com'], to: 'user@example.com',
subject,
html,
text,
// Schedule for later (optional) // Schedule for later (optional)
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
// Add any custom fields you've defined
priority: 1, 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) // Option 2: Direct HTML email (no template)
const directEmail = await payload.create({ const directEmail = await sendEmail<Email>(payload, {
collection: 'emails',
data: { data: {
to: ['user@example.com'], to: ['user@example.com', 'another@example.com'],
subject: 'Welcome!', subject: 'Welcome!',
html: '<h1>Welcome John!</h1><p>Thanks for joining!</p>', html: '<h1>Welcome John!</h1><p>Thanks for joining!</p>',
text: 'Welcome John! Thanks for joining!', 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>',
} }
}) })
``` ```
@@ -840,6 +849,55 @@ import {
} from '@xtr-dev/payload-mailing' } 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) ## Migration Guide (v0.0.x → v0.1.0)
**🚨 BREAKING CHANGES**: The API has been simplified to use Payload collections directly. **🚨 BREAKING CHANGES**: The API has been simplified to use Payload collections directly.

View File

@@ -21,4 +21,7 @@ export {
renderTemplate, renderTemplate,
processEmails, processEmails,
retryFailedEmails, retryFailedEmails,
sendEmail,
type BaseEmailData,
type SendEmailOptions,
} from './utils/helpers.js' } from './utils/helpers.js'

View File

@@ -1,6 +1,32 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import { TemplateVariables } from '../types/index.js' 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'
}
export const getMailing = (payload: Payload) => { export const getMailing = (payload: Payload) => {
const mailing = (payload as any).mailing const mailing = (payload as any).mailing
if (!mailing) { if (!mailing) {
@@ -22,4 +48,105 @@ export const processEmails = async (payload: Payload): Promise<void> => {
export const retryFailedEmails = async (payload: Payload): Promise<void> => { export const retryFailedEmails = async (payload: Payload): Promise<void> => {
const mailing = getMailing(payload) const mailing = getMailing(payload)
return mailing.service.retryFailedEmails() 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')
}
// Parse and validate email addresses
const parseEmails = (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
}
// Process email addresses
if (emailData.to) {
emailData.to = parseEmails(emailData.to as string | string[])
}
if (emailData.cc) {
emailData.cc = parseEmails(emailData.cc as string | string[])
}
if (emailData.bcc) {
emailData.bcc = parseEmails(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
} }