Enhance mailing plugin with collection overrides, detailed access controls, and improved rich text serialization logic

This commit is contained in:
2025-09-13 12:24:19 +02:00
parent 3868e74770
commit 5c9ef19d69
4 changed files with 279 additions and 25 deletions

113
README.md
View File

@@ -359,6 +359,119 @@ EMAIL_PASS=your-app-password
EMAIL_FROM=noreply@yoursite.com
```
## Security and Access Control
### Collection Access Restrictions
By default, both email templates and emails collections allow full access (`read/create/update/delete: () => true`). For production use, you should configure proper access restrictions using collection overrides:
```typescript
mailingPlugin({
// ... other config
collections: {
templates: {
access: {
read: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:read')
},
create: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:create')
},
update: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:update')
},
delete: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
}
},
emails: {
access: {
read: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:read')
},
create: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:create')
},
update: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:update')
},
delete: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
}
}
}
})
```
### Collection Overrides
You can override any collection configuration using the `collections.templates` or `collections.emails` options. This includes:
- **Access controls** - Restrict who can read/create/update/delete
- **Admin UI settings** - Customize admin interface appearance
- **Field modifications** - Add custom fields or modify existing ones
- **Hooks** - Add custom validation or processing logic
Example with additional custom fields:
```typescript
mailingPlugin({
// ... other config
collections: {
templates: {
admin: {
group: 'Custom Marketing',
description: 'Custom email templates with enhanced features'
},
fields: [
// Plugin's default fields are preserved
{
name: 'category',
type: 'select',
options: [
{ label: 'Marketing', value: 'marketing' },
{ label: 'Transactional', value: 'transactional' },
{ label: 'System', value: 'system' }
],
admin: {
position: 'sidebar'
}
},
{
name: 'tags',
type: 'text',
hasMany: true,
admin: {
description: 'Tags for organizing templates'
}
}
],
hooks: {
beforeChange: [
({ data, req }) => {
// Custom validation logic
if (data.category === 'system' && req.user?.role !== 'admin') {
throw new Error('Only admins can create system templates')
}
return data
}
]
}
}
}
})
```
## TypeScript Support
The plugin includes full TypeScript definitions. Import types as needed:

View File

@@ -146,6 +146,123 @@ const buildConfigWithMemoryDB = async () => {
retryDelay: 60000, // 1 minute for dev
queue: 'email-queue',
// Example: Collection overrides for customization
// Uncomment and modify as needed for your use case
/*
collections: {
templates: {
// Custom access controls - restrict who can manage templates
access: {
read: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:read')
},
create: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:create')
},
update: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:update')
},
delete: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
},
// Custom admin UI settings
admin: {
group: 'Marketing',
description: 'Email templates with enhanced security and categorization'
},
// Add custom fields to templates
fields: [
// Default plugin fields are automatically included
{
name: 'category',
type: 'select',
options: [
{ label: 'Marketing', value: 'marketing' },
{ label: 'Transactional', value: 'transactional' },
{ label: 'System Notifications', value: 'system' }
],
defaultValue: 'transactional',
admin: {
position: 'sidebar',
description: 'Template category for organization'
}
},
{
name: 'tags',
type: 'text',
hasMany: true,
admin: {
position: 'sidebar',
description: 'Tags for easy template filtering'
}
},
{
name: 'isActive',
type: 'checkbox',
defaultValue: true,
admin: {
position: 'sidebar',
description: 'Only active templates can be used'
}
}
],
// Custom validation hooks
hooks: {
beforeChange: [
({ data, req }) => {
// Example: Only admins can create system templates
if (data.category === 'system' && req.user?.role !== 'admin') {
throw new Error('Only administrators can create system notification templates')
}
// Example: Auto-generate slug if not provided
if (!data.slug && data.name) {
data.slug = data.name.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
return data
}
]
}
},
emails: {
// Restrict access to emails collection
access: {
read: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:read')
},
create: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:create')
},
update: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin' || user.permissions?.includes('mailing:update')
},
delete: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
},
// Custom admin configuration for emails
admin: {
group: 'Marketing',
description: 'Email delivery tracking and management',
defaultColumns: ['subject', 'to', 'status', 'priority', 'scheduledAt'],
}
}
},
*/
// Optional: Custom rich text editor configuration
// Comment out to use default lexical editor
richTextEditor: lexicalEditor({

View File

@@ -52,7 +52,10 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
},
// Update relationship fields to point to correct templates collection
fields: (emailsOverrides.fields || Emails.fields).map((field: any) => {
if (field.name === 'template' && field.type === 'relationship') {
if (field &&
typeof field === 'object' &&
field.name === 'template' &&
field.type === 'relationship') {
return {
...field,
relationTo: templatesSlug,
@@ -95,12 +98,10 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
}
} catch (error) {
console.error('❌ Error processing email queue:', error)
return {
output: {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
// Properly fail the job by throwing the error
throw new Error(`Email queue processing failed: ${errorMessage}`)
}
},
interfaceName: 'ProcessEmailQueueJob',

View File

@@ -1,6 +1,20 @@
// Using any type for now since Lexical types have import issues
// import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
type SerializedEditorState = any
// Proper type definitions for Lexical serialization
interface SerializedEditorState {
root: {
children: SerializedLexicalNode[]
}
}
interface SerializedLexicalNode {
type: string
children?: SerializedLexicalNode[]
text?: string
format?: number
tag?: string
listType?: 'number' | 'bullet'
url?: string
newTab?: boolean
}
/**
* Converts Lexical richtext content to HTML
@@ -24,11 +38,11 @@ export function serializeRichTextToText(richTextData: SerializedEditorState): st
return serializeNodesToText(richTextData.root.children)
}
function serializeNodesToHTML(nodes: any[]): string {
function serializeNodesToHTML(nodes: SerializedLexicalNode[]): string {
return nodes.map(node => serializeNodeToHTML(node)).join('')
}
function serializeNodeToHTML(node: any): string {
function serializeNodeToHTML(node: SerializedLexicalNode): string {
if (!node) return ''
switch (node.type) {
@@ -44,13 +58,22 @@ function serializeNodeToHTML(node: any): string {
case 'text':
let text = node.text || ''
// Apply text formatting
// Apply text formatting using proper nesting order
if (node.format) {
if (node.format & 1) text = `<strong>${text}</strong>` // Bold
if (node.format & 2) text = `<em>${text}</em>` // Italic
if (node.format & 4) text = `<s>${text}</s>` // Strikethrough
if (node.format & 8) text = `<u>${text}</u>` // Underline
if (node.format & 16) text = `<code>${text}</code>` // Code
const formatFlags = {
bold: (node.format & 1) !== 0,
italic: (node.format & 2) !== 0,
strikethrough: (node.format & 4) !== 0,
underline: (node.format & 8) !== 0,
code: (node.format & 16) !== 0,
}
// Apply formatting in proper nesting order: code > bold > italic > underline > strikethrough
if (formatFlags.code) text = `<code>${text}</code>`
if (formatFlags.bold) text = `<strong>${text}</strong>`
if (formatFlags.italic) text = `<em>${text}</em>`
if (formatFlags.underline) text = `<u>${text}</u>`
if (formatFlags.strikethrough) text = `<s>${text}</s>`
}
return text
@@ -89,11 +112,11 @@ function serializeNodeToHTML(node: any): string {
}
}
function serializeNodesToText(nodes: any[]): string {
function serializeNodesToText(nodes: SerializedLexicalNode[]): string {
return nodes.map(node => serializeNodeToText(node)).join('')
}
function serializeNodeToText(node: any): string {
function serializeNodeToText(node: SerializedLexicalNode): string {
if (!node) return ''
switch (node.type) {