mirror of
https://github.com/xtr-dev/payload-mailing.git
synced 2025-12-10 08:13:23 +00:00
Enhance mailing plugin with collection overrides, detailed access controls, and improved rich text serialization logic
This commit is contained in:
113
README.md
113
README.md
@@ -359,6 +359,119 @@ EMAIL_PASS=your-app-password
|
|||||||
EMAIL_FROM=noreply@yoursite.com
|
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
|
## TypeScript Support
|
||||||
|
|
||||||
The plugin includes full TypeScript definitions. Import types as needed:
|
The plugin includes full TypeScript definitions. Import types as needed:
|
||||||
|
|||||||
@@ -146,6 +146,123 @@ const buildConfigWithMemoryDB = async () => {
|
|||||||
retryDelay: 60000, // 1 minute for dev
|
retryDelay: 60000, // 1 minute for dev
|
||||||
queue: 'email-queue',
|
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
|
// Optional: Custom rich text editor configuration
|
||||||
// Comment out to use default lexical editor
|
// Comment out to use default lexical editor
|
||||||
richTextEditor: lexicalEditor({
|
richTextEditor: lexicalEditor({
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
|
|||||||
},
|
},
|
||||||
// Update relationship fields to point to correct templates collection
|
// Update relationship fields to point to correct templates collection
|
||||||
fields: (emailsOverrides.fields || Emails.fields).map((field: any) => {
|
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 {
|
return {
|
||||||
...field,
|
...field,
|
||||||
relationTo: templatesSlug,
|
relationTo: templatesSlug,
|
||||||
@@ -95,12 +98,10 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error processing email queue:', error)
|
console.error('❌ Error processing email queue:', error)
|
||||||
return {
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
output: {
|
|
||||||
success: false,
|
// Properly fail the job by throwing the error
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
throw new Error(`Email queue processing failed: ${errorMessage}`)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
interfaceName: 'ProcessEmailQueueJob',
|
interfaceName: 'ProcessEmailQueueJob',
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
// Using any type for now since Lexical types have import issues
|
// Proper type definitions for Lexical serialization
|
||||||
// import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
interface SerializedEditorState {
|
||||||
type SerializedEditorState = any
|
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
|
* Converts Lexical richtext content to HTML
|
||||||
@@ -24,11 +38,11 @@ export function serializeRichTextToText(richTextData: SerializedEditorState): st
|
|||||||
return serializeNodesToText(richTextData.root.children)
|
return serializeNodesToText(richTextData.root.children)
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeNodesToHTML(nodes: any[]): string {
|
function serializeNodesToHTML(nodes: SerializedLexicalNode[]): string {
|
||||||
return nodes.map(node => serializeNodeToHTML(node)).join('')
|
return nodes.map(node => serializeNodeToHTML(node)).join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeNodeToHTML(node: any): string {
|
function serializeNodeToHTML(node: SerializedLexicalNode): string {
|
||||||
if (!node) return ''
|
if (!node) return ''
|
||||||
|
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
@@ -44,13 +58,22 @@ function serializeNodeToHTML(node: any): string {
|
|||||||
case 'text':
|
case 'text':
|
||||||
let text = node.text || ''
|
let text = node.text || ''
|
||||||
|
|
||||||
// Apply text formatting
|
// Apply text formatting using proper nesting order
|
||||||
if (node.format) {
|
if (node.format) {
|
||||||
if (node.format & 1) text = `<strong>${text}</strong>` // Bold
|
const formatFlags = {
|
||||||
if (node.format & 2) text = `<em>${text}</em>` // Italic
|
bold: (node.format & 1) !== 0,
|
||||||
if (node.format & 4) text = `<s>${text}</s>` // Strikethrough
|
italic: (node.format & 2) !== 0,
|
||||||
if (node.format & 8) text = `<u>${text}</u>` // Underline
|
strikethrough: (node.format & 4) !== 0,
|
||||||
if (node.format & 16) text = `<code>${text}</code>` // Code
|
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
|
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('')
|
return nodes.map(node => serializeNodeToText(node)).join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeNodeToText(node: any): string {
|
function serializeNodeToText(node: SerializedLexicalNode): string {
|
||||||
if (!node) return ''
|
if (!node) return ''
|
||||||
|
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
|
|||||||
Reference in New Issue
Block a user