diff --git a/README.md b/README.md index 8dc2225..4a1b760 100644 --- a/README.md +++ b/README.md @@ -359,14 +359,127 @@ 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: ```typescript -import { - MailingPluginConfig, - SendEmailOptions, +import { + MailingPluginConfig, + SendEmailOptions, EmailTemplate, QueuedEmail, EmailObject, diff --git a/dev/payload.config.ts b/dev/payload.config.ts index 57ac215..8e96282 100644 --- a/dev/payload.config.ts +++ b/dev/payload.config.ts @@ -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({ diff --git a/src/plugin.ts b/src/plugin.ts index 085fe9b..8b94155 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -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', diff --git a/src/utils/richTextSerializer.ts b/src/utils/richTextSerializer.ts index f8a0391..b98dcf4 100644 --- a/src/utils/richTextSerializer.ts +++ b/src/utils/richTextSerializer.ts @@ -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) { @@ -43,16 +57,25 @@ 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 = `${text}` // Bold - if (node.format & 2) text = `${text}` // Italic - if (node.format & 4) text = `${text}` // Strikethrough - if (node.format & 8) text = `${text}` // Underline - if (node.format & 16) text = `${text}` // 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 = `${text}` + if (formatFlags.bold) text = `${text}` + if (formatFlags.italic) text = `${text}` + if (formatFlags.underline) text = `${text}` + if (formatFlags.strikethrough) text = `${text}` } - + return text case 'linebreak': @@ -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) {