diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a1a9176 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# PayloadCMS Configuration +PAYLOAD_SECRET=your-secret-key-here + +# Database Configuration +# Leave DATABASE_URI empty to use in-memory MongoDB (recommended for development) +# DATABASE_URI=mongodb://localhost:27017/payload-mailing-dev + +# Force in-memory database (optional, enabled by default if DATABASE_URI is not set) +USE_MEMORY_DB=true + +# Email Configuration (Development) +EMAIL_HOST=localhost +EMAIL_PORT=1025 +EMAIL_USER=test +EMAIL_PASS=test +EMAIL_FROM=noreply@test.com + +# Development Settings +NODE_ENV=development +PORT=3000 + +# Database Options: +# 1. In-memory MongoDB (default) - No installation required, data resets on restart +# 2. Local MongoDB - Install MongoDB and set DATABASE_URI +# 3. Remote MongoDB - Set DATABASE_URI to remote connection string + +# Optional: Use MailHog for email testing +# MailHog runs on localhost:1025 for SMTP and localhost:8025 for web UI +# Download from: https://github.com/mailhog/MailHog \ No newline at end of file diff --git a/.swcrc b/.swcrc new file mode 100644 index 0000000..a1b6660 --- /dev/null +++ b/.swcrc @@ -0,0 +1,19 @@ +{ + "jsc": { + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": true, + "dynamicImport": true + }, + "target": "es2022", + "keepClassNames": true, + "externalHelpers": false, + "loose": false + }, + "module": { + "type": "es6" + }, + "sourceMaps": false, + "minify": false +} \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..adb6f20 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,169 @@ +# PayloadCMS Mailing Plugin - Development Guide + +šŸš€ **Zero-Setup Development** with in-memory MongoDB - no database installation required! + +## Quick Start + +```bash +# Install dependencies +npm install + +# Start dev server with in-memory MongoDB +npm run dev + +# Alternative: Use startup script with helpful info +npm run dev:start + +# Alternative: Force memory database +npm run dev:memory +``` + +## What You Get + +### šŸŽÆ **Instant Setup** +- āœ… **In-memory MongoDB** - no installation needed +- āœ… **Example templates** automatically created +- āœ… **Test interface** at `/mailing-test` +- āœ… **Admin panel** at `/admin` +- āœ… **API endpoints** for testing + +### šŸ“§ **Pre-loaded Templates** +1. **Welcome Email** - User onboarding with premium features +2. **Order Confirmation** - E-commerce receipt with items +3. **Password Reset** - Security email with expiring link + +### šŸ”§ **Development Tools** +- **Web UI**: http://localhost:3000/mailing-test +- **Admin Panel**: http://localhost:3000/admin +- **GraphQL Playground**: http://localhost:3000/api/graphql-playground +- **API Endpoints**: + - `POST /api/test-email` - Send/schedule emails + - `POST /api/process-outbox` - Process email queue + +## Database Options + +### šŸš€ **In-Memory (Default - Recommended)** +```bash +npm run dev # Automatic +``` +- Zero setup required +- Fast startup (5-10 seconds) +- Data resets on restart +- Perfect for development + +### šŸ”— **External MongoDB** +```bash +DATABASE_URI=mongodb://localhost:27017/payload-mailing npm run dev +``` + +## Email Testing + +### MailHog (Recommended) +```bash +# Install and run MailHog +go install github.com/mailhog/MailHog@latest +MailHog + +# View emails at: http://localhost:8025 +``` + +### Console Output +The dev setup logs emails to console if no SMTP server is available. + +## Development Workflow + +1. **Start server**: `npm run dev` +2. **Make changes** to plugin source (`src/`) +3. **Rebuild plugin**: `npm run build` +4. **Test changes** in web interface or admin panel +5. **View results** in MailHog or console + +## Plugin Testing + +### Via Web Interface +1. Go to http://localhost:3000/mailing-test +2. Select a template +3. Fill in variables +4. Send or schedule email +5. Check MailHog for results + +### Via Admin Panel +1. Go to http://localhost:3000/admin +2. Navigate to **Mailing > Email Templates** +3. View/edit templates +4. Navigate to **Mailing > Email Outbox** +5. Monitor email status + +### Via API +```bash +curl -X POST http://localhost:3000/api/test-email \\ + -H "Content-Type: application/json" \\ + -d '{ + "type": "send", + "templateId": "TEMPLATE_ID", + "to": "test@example.com", + "variables": { + "firstName": "John", + "siteName": "Test App" + } + }' +``` + +## Startup Messages + +When you start the dev server, look for these messages: + +``` +šŸš€ PayloadCMS Mailing Plugin - Development Mode +================================================== +šŸ“¦ Using in-memory MongoDB (no installation required) + +šŸ”§ Starting development server... +šŸš€ Starting MongoDB in-memory database... +āœ… MongoDB in-memory database started +šŸ“Š Database URI: mongodb://***@localhost:port/payload-mailing-dev +šŸ“§ Mailing plugin configured with test transport +šŸŽÆ Test interface will be available at: /mailing-test + +āœ… Example email templates created successfully +PayloadCMS Mailing Plugin initialized successfully +``` + +## Troubleshooting + +### Server won't start +- Ensure port 3000 is available +- Check for Node.js version compatibility +- Run `npm install` to ensure dependencies + +### Database issues +- In-memory database automatically handles setup +- For external MongoDB, verify `DATABASE_URI` is correct +- Check MongoDB is running if using external database + +### Email issues +- Verify MailHog is running on port 1025 +- Check console logs for error messages +- Ensure template variables are correctly formatted + +## Plugin Development + +The plugin source is in `src/` directory: +- `src/plugin.ts` - Main plugin configuration +- `src/collections/` - Email templates and outbox collections +- `src/services/` - Mailing service with Handlebars processing +- `src/jobs/` - Background job processing +- `src/utils/` - Helper functions for developers + +Make changes, rebuild with `npm run build`, and test! + +## Success Indicators + +āœ… Server starts without errors +āœ… Admin panel loads at `/admin` +āœ… Test interface loads at `/mailing-test` +āœ… Templates appear in the interface +āœ… Emails can be sent/scheduled +āœ… Outbox shows email status + +You're ready to develop and test the PayloadCMS Mailing Plugin! šŸŽ‰ \ No newline at end of file diff --git a/README.md b/README.md index 937993d..8dc2225 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ šŸ“§ **Template-based email system with scheduling and job processing for PayloadCMS** +āš ļø **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use. + ## Features āœ… **Template System**: Create reusable email templates with Handlebars syntax @@ -52,9 +54,9 @@ export default buildConfig({ ```typescript import { sendEmail, scheduleEmail } from '@xtr-dev/payload-mailing' -// Send immediately +// Send immediately using template slug const emailId = await sendEmail(payload, { - templateId: 'welcome-email', + templateSlug: 'welcome-email', to: 'user@example.com', variables: { firstName: 'John', @@ -64,7 +66,7 @@ const emailId = await sendEmail(payload, { // Schedule for later const scheduledId = await scheduleEmail(payload, { - templateId: 'reminder-email', + templateSlug: 'reminder-email', to: 'user@example.com', scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours variables: { @@ -82,13 +84,17 @@ const scheduledId = await scheduleEmail(payload, { interface MailingPluginConfig { collections?: { templates?: string // default: 'email-templates' - outbox?: string // default: 'email-outbox' + emails?: string // default: 'emails' } defaultFrom?: string transport?: Transporter | MailingTransportConfig queue?: string // default: 'default' retryAttempts?: number // default: 3 retryDelay?: number // default: 300000 (5 minutes) + emailWrapper?: EmailWrapperHook // optional email layout wrapper + richTextEditor?: RichTextField['editor'] // optional custom rich text editor + onReady?: (payload: any) => Promise // optional callback after plugin initialization + initOrder?: 'before' | 'after' // default: 'before' } ``` @@ -125,34 +131,117 @@ import nodemailer from 'nodemailer' 2. Navigate to **Mailing > Email Templates** 3. Create a new template with: - **Name**: Descriptive name for the template + - **Slug**: Unique identifier for the template (auto-generated) - **Subject**: Email subject (supports Handlebars) - - **HTML Template**: HTML content with Handlebars syntax - - **Text Template**: Plain text version (optional) - - **Variables**: Define available variables + - **Content**: Rich text editor with Handlebars syntax (automatically generates HTML and text versions) ### Template Example **Subject**: `Welcome to {{siteName}}, {{firstName}}!` -**HTML Template**: -```html -

Welcome {{firstName}}!

-

Thanks for joining {{siteName}}. We're excited to have you!

+**Content** (using rich text editor with Handlebars): +``` +# Welcome {{firstName}}! šŸŽ‰ -{{#if isPremium}} -

Premium Benefits:

- -{{/if}} +Thanks for joining {{siteName}}. We're excited to have you! -

Your account was created on {{formatDate createdAt 'long'}}.

-

Visit your dashboard: Get Started

+**What you can do:** +• Create beautiful emails with rich text formatting +• Use the emailWrapper hook to add custom layouts +• Queue and schedule emails effortlessly -
-

Best regards,
The {{siteName}} Team

+Your account was created on {{formatDate createdAt "long"}}. + +Best regards, +The {{siteName}} Team +``` + +## Advanced Features + +### Email Wrapper Hook + +Use the `emailWrapper` hook to apply consistent layouts to all emails: + +```typescript +mailingPlugin({ + // ... other config + emailWrapper: (email) => { + const wrappedHtml = ` + + + + + ${email.subject} + + + +
+
+

My Company

+
+
+ ${email.html} +
+ +
+ + + ` + + return { + ...email, + html: wrappedHtml, + text: `MY COMPANY\n\n${email.text}\n\n© 2024 My Company` + } + } +}) +``` + +### Custom Rich Text Editor + +Override the rich text editor used for templates: + +```typescript +import { lexicalEditor } from '@payloadcms/richtext-lexical' +import { FixedToolbarFeature, HeadingFeature } from '@payloadcms/richtext-lexical' + +mailingPlugin({ + // ... other config + richTextEditor: lexicalEditor({ + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + FixedToolbarFeature(), + HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3'] }), + // Add more features as needed + ], + }) +}) +``` + +### Initialization Hooks + +Control plugin initialization order and add post-initialization logic: + +```typescript +mailingPlugin({ + // ... other config + initOrder: 'after', // Initialize after main Payload onInit + onReady: async (payload) => { + // Called after plugin is fully initialized + console.log('Mailing plugin ready!') + + // Custom initialization logic here + await setupCustomEmailSettings(payload) + } +}) ``` ## Handlebars Helpers @@ -172,20 +261,20 @@ Send an email immediately: ```typescript const emailId = await sendEmail(payload, { - templateId: 'order-confirmation', // optional - to: 'customer@example.com', - cc: 'manager@example.com', // optional - bcc: 'archive@example.com', // optional - from: 'orders@yoursite.com', // optional, uses default - replyTo: 'support@yoursite.com', // optional - subject: 'Custom subject', // required if no template - html: '

Custom HTML

', // required if no template - text: 'Custom text version', // optional - variables: { // template variables + templateSlug: 'order-confirmation', // optional - use template slug + to: ['customer@example.com'], // string or array of emails + cc: ['manager@example.com'], // optional - array of emails + bcc: ['archive@example.com'], // optional - array of emails + from: 'orders@yoursite.com', // optional, uses default + replyTo: 'support@yoursite.com', // optional + subject: 'Custom subject', // required if no template + html: '

Custom HTML

', // required if no template + text: 'Custom text version', // optional + variables: { // template variables orderNumber: '12345', customerName: 'John Doe' }, - priority: 1 // optional, 1-10 (1 = highest) + priority: 1 // optional, 1-10 (1 = highest) }) ``` @@ -195,7 +284,7 @@ Schedule an email for later delivery: ```typescript const emailId = await scheduleEmail(payload, { - templateId: 'newsletter', + templateSlug: 'newsletter', to: ['user1@example.com', 'user2@example.com'], scheduledAt: new Date('2024-01-15T10:00:00Z'), variables: { @@ -205,12 +294,13 @@ const emailId = await scheduleEmail(payload, { }) ``` -### processOutbox(payload) +### processEmails(payload) Manually process pending emails: ```typescript -await processOutbox(payload) +import { processEmails } from '@xtr-dev/payload-mailing' +await processEmails(payload) ``` ### retryFailedEmails(payload) @@ -218,31 +308,31 @@ await processOutbox(payload) Manually retry failed emails: ```typescript +import { retryFailedEmails } from '@xtr-dev/payload-mailing' await retryFailedEmails(payload) ``` ## Job Processing -The plugin automatically processes emails using PayloadCMS jobs: +The plugin automatically adds a unified email processing job to PayloadCMS: -- **Outbox Processing**: Every 5 minutes -- **Failed Email Retry**: Every 30 minutes +- **Job Name**: `process-email-queue` +- **Function**: Processes both pending emails and retries failed emails +- **Trigger**: Manual via admin panel or API call -Ensure you have jobs configured in your Payload config: +The job is automatically registered when the plugin initializes. To trigger it manually: ```typescript -export default buildConfig({ - jobs: { - // Configure your job processing - tasks: [], - // ... other job config - }, +// Queue the job for processing +await payload.jobs.queue({ + task: 'process-email-queue', + input: {} }) ``` ## Email Status Tracking -All emails are stored in the outbox collection with these statuses: +All emails are stored in the emails collection with these statuses: - `pending` - Waiting to be sent - `processing` - Currently being sent @@ -251,7 +341,7 @@ All emails are stored in the outbox collection with these statuses: ## Monitoring -Check the **Mailing > Email Outbox** collection in your admin panel to: +Check the **Mailing > Emails** collection in your admin panel to: - View email delivery status - See error messages for failed sends @@ -278,14 +368,46 @@ import { MailingPluginConfig, SendEmailOptions, EmailTemplate, - OutboxEmail + QueuedEmail, + EmailObject, + EmailWrapperHook } from '@xtr-dev/payload-mailing' ``` +## Recent Changes + +### v0.0.x (Latest) + +**šŸ”„ Breaking Changes:** +- Removed email layouts system in favor of `emailWrapper` hook for better flexibility +- Email fields (`to`, `cc`, `bcc`) now use `hasMany: true` for proper array handling +- Templates now use slug-based lookup instead of ID-based for developer-friendly API +- Email collection renamed from "outbox" to "emails" +- Unified job processing: single `process-email-queue` job handles both pending and failed emails + +**✨ New Features:** +- Rich text editor with automatic HTML/text conversion +- Template slugs for easier template reference +- `emailWrapper` hook for consistent email layouts +- Custom rich text editor configuration support +- Initialization hooks (`onReady`, `initOrder`) for better plugin lifecycle control +- Improved Handlebars variable interpolation with defensive programming + +**šŸ› Bug Fixes:** +- Fixed text version uppercase conversion in headings +- Fixed Handlebars interpolation issues in text version +- Improved plugin initialization order to prevent timing issues + +**šŸ’” Improvements:** +- Better admin UI with proper array input controls +- More robust error handling and logging +- Enhanced TypeScript definitions +- Simplified template creation workflow + ## License MIT ## Contributing -Issues and pull requests welcome at [GitHub repository](https://github.com/xtr-dev/payload-mailing) \ No newline at end of file +Issues and pull requests welcome at [GitHub repository](https://github.com/xtr-dev/payload-mailing) diff --git a/dev/README.md b/dev/README.md new file mode 100644 index 0000000..bcea813 --- /dev/null +++ b/dev/README.md @@ -0,0 +1,208 @@ +# PayloadCMS Mailing Plugin - Development Setup + +This directory contains a complete PayloadCMS application for testing and developing the mailing plugin. + +## Quick Start + +1. **Install dependencies:** + ```bash + npm install + ``` + +2. **Start development server (with in-memory MongoDB):** + ```bash + npm run dev + ``` + + **Alternative startup methods:** + ```bash + # Force in-memory database (from root directory) + npm run dev:memory + + # With startup script and helpful info + npm run dev:start + + # From dev directory + cd dev && npm run dev + ``` + +3. **Optional: Set up environment file:** + ```bash + cp ../.env.example .env + # Edit .env if you want to use external MongoDB + ``` + +4. **Access the application:** + - Admin Panel: http://localhost:3000/admin + - Mailing Test Page: http://localhost:3000/mailing-test + - GraphQL Playground: http://localhost:3000/api/graphql-playground + +## Features Included + +### āœ… **Mailing Plugin Integration** +- Configured with test email transport +- Example email templates automatically created +- Collections: `email-templates` and `email-outbox` + +### āœ… **Test Interface** +- Web UI at `/mailing-test` for testing emails +- Send emails immediately or schedule for later +- View outbox status and email history +- Process outbox manually + +### āœ… **API Endpoints** +- `POST /api/test-email` - Send/schedule test emails +- `GET /api/test-email` - Get templates and outbox status +- `POST /api/process-outbox` - Manually process outbox +- `GET /api/process-outbox` - Get outbox statistics + +### āœ… **Example Templates** +1. **Welcome Email** - New user onboarding +2. **Order Confirmation** - E-commerce order receipt +3. **Password Reset** - Security password reset + +## Email Testing + +### Option 1: MailHog (Recommended) +```bash +# Install MailHog +go install github.com/mailhog/MailHog@latest + +# Run MailHog +MailHog +``` +- SMTP: localhost:1025 (configured in dev) +- Web UI: http://localhost:8025 + +### Option 2: Console Logs +The dev environment uses `testEmailAdapter` which logs emails to console. + +## Testing the Plugin + +1. **Via Web Interface:** + - Go to http://localhost:3000/mailing-test + - Select a template + - Fill in variables + - Send or schedule email + +2. **Via Admin Panel:** + - Go to http://localhost:3000/admin + - Navigate to "Mailing > Email Templates" + - Create/edit templates + - Navigate to "Mailing > Email Outbox" + - View scheduled/sent emails + +3. **Via API:** + ```bash + # Send welcome email + curl -X POST http://localhost:3000/api/test-email \\ + -H "Content-Type: application/json" \\ + -d '{ + "type": "send", + "templateId": "TEMPLATE_ID", + "to": "test@example.com", + "variables": { + "firstName": "John", + "siteName": "Test Site" + } + }' + ``` + +## Development Workflow + +1. **Make changes to plugin source** (`../src/`) +2. **Rebuild plugin:** `npm run build` (in root) +3. **Restart dev server:** The dev server watches for changes +4. **Test changes** via web interface or API + +## Database Configuration + +The development setup automatically uses **MongoDB in-memory database** by default - no MongoDB installation required! + +### šŸš€ **In-Memory MongoDB (Default)** +- āœ… **Zero setup** - Works out of the box +- āœ… **No installation** required +- āœ… **Fast startup** - Ready in seconds +- āš ļø **Data resets** on server restart +- šŸ’¾ **Perfect for development** and testing + +### šŸ”§ **Database Options** + +1. **In-Memory (Recommended for Development):** + ```bash + # Automatic - just start the server + npm run dev + + # Or explicitly enable + USE_MEMORY_DB=true npm run dev + ``` + +2. **Local MongoDB:** + ```bash + # Install MongoDB locally, then: + DATABASE_URI=mongodb://localhost:27017/payload-mailing-dev npm run dev + ``` + +3. **Remote MongoDB:** + ```bash + # Set your connection string: + DATABASE_URI=mongodb+srv://user:pass@cluster.mongodb.net/dbname npm run dev + ``` + +### šŸ’” **Database Startup Messages** +When you start the dev server, you'll see helpful messages: +``` +šŸš€ Starting MongoDB in-memory database... +āœ… MongoDB in-memory database started +šŸ“Š Database URI: mongodb://***@localhost:port/payload-mailing-dev +``` + +## Jobs Processing + +The plugin automatically processes the outbox every 5 minutes and retries failed emails every 30 minutes. You can also trigger manual processing via: +- Web interface "Process Outbox" button +- API endpoint `POST /api/process-outbox` + +## Troubleshooting + +### Email not sending: +1. Check MailHog is running on port 1025 +2. Check console logs for errors +3. Verify template variables are correct +4. Check outbox collection for error messages + +### Plugin not loading: +1. Ensure plugin is built: `npm run build` in root +2. Check console for initialization message +3. Verify plugin configuration in `payload.config.ts` + +### Templates not appearing: +1. Check seed function ran successfully +2. Verify database connection +3. Check admin panel collections + +## Plugin API Usage + +```javascript +import { sendEmail, scheduleEmail } 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' + } +}) + +// 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' + } +}) +``` \ No newline at end of file diff --git a/dev/app/(payload)/admin/importMap.js b/dev/app/(payload)/admin/importMap.js index db8e602..ff48963 100644 --- a/dev/app/(payload)/admin/importMap.js +++ b/dev/app/(payload)/admin/importMap.js @@ -1,9 +1,51 @@ -import { BeforeDashboardClient as BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343 } from 'temp-project/client' -import { BeforeDashboardServer as BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f } from 'temp-project/rsc' +import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' +import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' +import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' +import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' export const importMap = { - 'temp-project/client#BeforeDashboardClient': - BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343, - 'temp-project/rsc#BeforeDashboardServer': - BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f, + "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } diff --git a/dev/app/api/create-test-user/route.ts b/dev/app/api/create-test-user/route.ts new file mode 100644 index 0000000..ea8de13 --- /dev/null +++ b/dev/app/api/create-test-user/route.ts @@ -0,0 +1,75 @@ +import { getPayload } from 'payload' +import config from '@payload-config' + +export async function POST(request: Request) { + try { + const payload = await getPayload({ config }) + const body = await request.json() + + // Generate random user data if not provided + const userData = { + email: body.email || `user-${Date.now()}@example.com`, + password: body.password || 'TestPassword123!', + firstName: body.firstName || 'Test', + lastName: body.lastName || 'User', + } + + // Create the user + const user = await payload.create({ + collection: 'users', + data: userData, + }) + + // Check if email was queued + await new Promise(resolve => setTimeout(resolve, 500)) // Brief delay for email processing + + const { docs: emails } = await payload.find({ + collection: 'emails' as const, + where: { + to: { + equals: userData.email, + }, + }, + limit: 1, + sort: '-createdAt', + }) + + return Response.json({ + success: true, + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + }, + emailQueued: emails.length > 0, + email: emails.length > 0 ? { + id: emails[0].id, + subject: emails[0].subject, + status: emails[0].status, + } : null, + }) + } catch (error) { + console.error('Error creating test user:', error) + return Response.json( + { + error: 'Failed to create user', + details: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +} + +export async function GET() { + return Response.json({ + message: 'Use POST to create a test user', + example: { + email: 'optional@example.com', + password: 'optional', + firstName: 'optional', + lastName: 'optional', + }, + note: 'All fields are optional. Random values will be generated if not provided.', + }) +} diff --git a/dev/app/api/process-emails/route.ts b/dev/app/api/process-emails/route.ts new file mode 100644 index 0000000..52bd286 --- /dev/null +++ b/dev/app/api/process-emails/route.ts @@ -0,0 +1,75 @@ +import { getPayload } from 'payload' +import config from '@payload-config' + +export async function POST(request: Request) { + try { + const payload = await getPayload({ config }) + + // Queue the combined email queue processing job + const job = await payload.jobs.queue({ + task: 'process-email-queue', + input: {}, + }) + + return Response.json({ + success: true, + message: 'Email queue processing job queued successfully (will process both pending and failed emails)', + jobId: job.id, + }) + } catch (error) { + console.error('Process emails error:', error) + return Response.json( + { + error: 'Failed to process emails', + details: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +} + +export async function GET() { + try { + const payload = await getPayload({ config }) + + // Get email queue statistics + const pending = await payload.count({ + collection: 'emails' as const, + where: { status: { equals: 'pending' } }, + }) + + const processing = await payload.count({ + collection: 'emails' as const, + where: { status: { equals: 'processing' } }, + }) + + const sent = await payload.count({ + collection: 'emails' as const, + where: { status: { equals: 'sent' } }, + }) + + const failed = await payload.count({ + collection: 'emails' as const, + where: { status: { equals: 'failed' } }, + }) + + return Response.json({ + statistics: { + pending: pending.totalDocs, + processing: processing.totalDocs, + sent: sent.totalDocs, + failed: failed.totalDocs, + total: pending.totalDocs + processing.totalDocs + sent.totalDocs + failed.totalDocs, + }, + }) + } catch (error) { + console.error('Get email stats error:', error) + return Response.json( + { + error: 'Failed to get email statistics', + details: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +} diff --git a/dev/app/api/test-email/route.ts b/dev/app/api/test-email/route.ts new file mode 100644 index 0000000..3572ca4 --- /dev/null +++ b/dev/app/api/test-email/route.ts @@ -0,0 +1,86 @@ +import { getPayload } from 'payload' +import config from '@payload-config' +import { sendEmail, scheduleEmail } 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 + + let result + if (type === 'send') { + // Send immediately + result = await sendEmail(payload, { + templateSlug, + 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 }) + } + + return Response.json({ + success: true, + emailId: result, + message: type === 'send' ? 'Email sent successfully' : 'Email scheduled successfully', + }) + } catch (error) { + console.error('Test email error:', error) + return Response.json( + { + error: 'Failed to send email', + details: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +} + +export async function GET() { + try { + const payload = await getPayload({ config }) + + // Get email templates + const { docs: templates } = await payload.find({ + collection: 'email-templates' as const, + limit: 10, + }) + + // Get email queue status + const { docs: queuedEmails, totalDocs } = await payload.find({ + collection: 'emails' as const, + limit: 10, + sort: '-createdAt', + }) + + return Response.json({ + templates, + outbox: { + emails: queuedEmails, + total: totalDocs, + }, + mailing: { + pluginActive: !!(payload as any).mailing, + service: !!(payload as any).mailing?.service, + }, + }) + } catch (error) { + console.error('Get mailing status error:', error) + return Response.json( + { + error: 'Failed to get mailing status', + details: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +} diff --git a/dev/app/mailing-test/page.tsx b/dev/app/mailing-test/page.tsx new file mode 100644 index 0000000..acb394e --- /dev/null +++ b/dev/app/mailing-test/page.tsx @@ -0,0 +1,344 @@ +'use client' + +import { useState, useEffect } from 'react' + +interface Template { + id: string + name: string + slug: string + subject: string + variables?: Array<{ + name: string + type: string + required: boolean + description?: string + }> + previewData?: Record +} + +interface QueuedEmail { + id: string + subject: string + to: string[] + status: string + createdAt: string + sentAt?: string + attempts: number + error?: string +} + +export default function MailingTestPage() { + const [templates, setTemplates] = useState([]) + const [queuedEmails, setQueuedEmails] = useState([]) + const [selectedTemplate, setSelectedTemplate] = useState('') + const [toEmail, setToEmail] = useState('test@example.com') + const [variables, setVariables] = useState>({}) + const [emailType, setEmailType] = useState<'send' | 'schedule'>('send') + const [scheduleDate, setScheduleDate] = useState('') + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState('') + + useEffect(() => { + fetchData() + }, []) + + const fetchData = async () => { + try { + const response = await fetch('/api/test-email') + const data = await response.json() + setTemplates(data.templates || []) + setQueuedEmails(data.outbox?.emails || []) + } catch (error) { + console.error('Error fetching data:', error) + } + } + + const handleTemplateChange = (templateSlug: string) => { + setSelectedTemplate(templateSlug) + const template = templates.find(t => t.slug === templateSlug) + if (template?.previewData) { + setVariables(template.previewData) + } + } + + const sendTestEmail = async () => { + if (!selectedTemplate || !toEmail) { + setMessage('Please select a template and enter an email address') + return + } + + setLoading(true) + setMessage('') + + try { + const response = await fetch('/api/test-email', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: emailType, + templateSlug: selectedTemplate, + to: toEmail, + variables, + scheduledAt: emailType === 'schedule' ? scheduleDate : undefined, + }), + }) + + const result = await response.json() + + if (result.success) { + setMessage(`āœ… ${result.message} (ID: ${result.emailId})`) + fetchData() // Refresh email queue + } else { + setMessage(`āŒ Error: ${result.error}`) + } + } catch (error) { + setMessage(`āŒ Error: ${error instanceof Error ? error.message : 'Unknown error'}`) + } finally { + setLoading(false) + } + } + + const processEmailQueue = async () => { + setLoading(true) + try { + const response = await fetch('/api/process-emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + const result = await response.json() + setMessage(result.success ? `āœ… ${result.message}` : `āŒ ${result.error}`) + + setTimeout(() => { + fetchData() // Refresh after a delay to see status changes + }, 1000) + } catch (error) { + setMessage(`āŒ Error: ${error instanceof Error ? error.message : 'Unknown error'}`) + } finally { + setLoading(false) + } + } + + const selectedTemplateData = templates.find(t => t.slug === selectedTemplate) + + return ( +
+

šŸ“§ PayloadCMS Mailing Plugin Test

+ + {message && ( +
+ {message} +
+ )} + +
+ {/* Send Email Form */} +
+

Send Test Email

+ +
+ + +
+ +
+ + setToEmail(e.target.value)} + style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} + /> +
+ +
+ + + +
+ + {emailType === 'schedule' && ( +
+ + setScheduleDate(e.target.value)} + style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} + /> +
+ )} + + {selectedTemplateData?.variables && ( +
+

Template Variables:

+ {selectedTemplateData.variables.map(variable => ( +
+ + setVariables({ + ...variables, + [variable.name]: variable.type === 'number' ? Number(e.target.value) : + variable.type === 'boolean' ? e.target.checked : + e.target.value + })} + style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} + /> +
+ ))} +
+ )} + + + + +
+ + {/* Email Queue */} +
+
+

Email Queue

+ +
+ + {queuedEmails.length === 0 ? ( +

No emails in queue

+ ) : ( +
+ {queuedEmails.map(email => ( +
+
{email.subject}
+
+ To: {Array.isArray(email.to) ? email.to.join(', ') : email.to} | Status: {email.status} | Attempts: {email.attempts} +
+
+ Created: {new Date(email.createdAt).toLocaleString()} + {email.sentAt && ` | Sent: ${new Date(email.sentAt).toLocaleString()}`} +
+ {email.error && ( +
+ Error: {email.error} +
+ )} +
+ ))} +
+ )} +
+
+ + {/* Templates Overview */} +
+

Available Templates

+ {templates.length === 0 ? ( +

No templates available

+ ) : ( +
+ {templates.map(template => ( +
+

{template.name}

+

{template.subject}

+
+ Variables: {template.variables?.length || 0} +
+
+ ))} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/dev/helpers/credentials.ts b/dev/helpers/credentials.ts index 7ccbcae..cee737e 100644 --- a/dev/helpers/credentials.ts +++ b/dev/helpers/credentials.ts @@ -1,4 +1,6 @@ export const devUser = { email: 'dev@payloadcms.com', password: 'test', + firstName: 'Dev', + lastName: 'User', } diff --git a/dev/int.spec.ts b/dev/int.spec.ts index 04e6982..87323a6 100644 --- a/dev/int.spec.ts +++ b/dev/int.spec.ts @@ -4,7 +4,7 @@ import config from '@payload-config' import { createPayloadRequest, getPayload } from 'payload' import { afterAll, beforeAll, describe, expect, test } from 'vitest' -import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js' +// import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js' let payload: Payload @@ -17,19 +17,11 @@ beforeAll(async () => { }) describe('Plugin integration tests', () => { - test('should query custom endpoint added by plugin', async () => { - const request = new Request('http://localhost:3000/api/my-plugin-endpoint', { - method: 'GET', - }) - - const payloadRequest = await createPayloadRequest({ config, request }) - const response = await customEndpointHandler(payloadRequest) - expect(response.status).toBe(200) - - const data = await response.json() - expect(data).toMatchObject({ - message: 'Hello from custom endpoint', - }) + test('should have mailing plugin initialized', async () => { + expect(payload).toBeDefined() + expect((payload as any).mailing).toBeDefined() + expect((payload as any).mailing.service).toBeDefined() + expect((payload as any).mailing.config).toBeDefined() }) test('can create post with custom text field added by plugin', async () => { diff --git a/dev/next-env.d.ts b/dev/next-env.d.ts index 1b3be08..830fb59 100644 --- a/dev/next-env.d.ts +++ b/dev/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/dev/payload-types.ts b/dev/payload-types.ts index 620ba8e..17a1f13 100644 --- a/dev/payload-types.ts +++ b/dev/payload-types.ts @@ -6,25 +6,85 @@ * and re-run `payload generate:types` to regenerate this file. */ +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + export interface Config { auth: { users: UserAuthOperations; }; + blocks: {}; collections: { + users: User; posts: Post; media: Media; - 'plugin-collection': PluginCollection; - users: User; + 'email-templates': EmailTemplate; + emails: Email; + 'payload-jobs': PayloadJob; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; }; collectionsJoins: {}; collectionsSelect: { + users: UsersSelect | UsersSelect; posts: PostsSelect | PostsSelect; media: MediaSelect | MediaSelect; - 'plugin-collection': PluginCollectionSelect | PluginCollectionSelect; - users: UsersSelect | UsersSelect; + 'email-templates': EmailTemplatesSelect | EmailTemplatesSelect; + emails: EmailsSelect | EmailsSelect; + 'payload-jobs': PayloadJobsSelect | PayloadJobsSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -39,7 +99,13 @@ export interface Config { collection: 'users'; }; jobs: { - tasks: unknown; + tasks: { + 'process-email-queue': ProcessEmailQueueJob; + inline: { + input: unknown; + output: unknown; + }; + }; workflows: unknown; }; } @@ -61,13 +127,38 @@ export interface UserAuthOperations { password: string; }; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + firstName?: string | null; + lastName?: string | null; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + }[] + | null; + password?: string | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "posts". */ export interface Post { id: string; - addedByPlugin?: string | null; updatedAt: string; createdAt: string; } @@ -91,29 +182,225 @@ export interface Media { } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "plugin-collection". + * via the `definition` "email-templates". */ -export interface PluginCollection { +export interface EmailTemplate { id: string; + /** + * A descriptive name for this email template + */ + name: string; + /** + * Unique identifier for this template (e.g., "welcome-email", "password-reset") + */ + slug: string; + /** + * Email subject line. You can use Handlebars variables like {{firstName}} or {{siteName}}. + */ + subject: string; + /** + * Email content with rich text formatting. Supports Handlebars variables like {{firstName}} and helpers like {{formatDate createdAt "long"}}. Content is converted to HTML and plain text automatically. + */ + content: { + root: { + type: string; + children: { + type: string; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + }; + updatedAt: string; + createdAt: string; +} +/** + * Email delivery and status tracking + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "emails". + */ +export interface Email { + id: string; + /** + * Email template used (optional if custom content provided) + */ + template?: (string | null) | EmailTemplate; + /** + * Template slug used for this email + */ + templateSlug?: string | null; + /** + * Recipient email address(es), comma-separated + */ + to: string; + /** + * CC email address(es), comma-separated + */ + cc?: string | null; + /** + * BCC email address(es), comma-separated + */ + bcc?: string | null; + /** + * Sender email address (optional, uses default if not provided) + */ + from?: string | null; + /** + * Reply-to email address + */ + replyTo?: string | null; + /** + * Email subject line + */ + subject: string; + /** + * Rendered HTML content of the email + */ + html: string; + /** + * Plain text version of the email + */ + text?: string | null; + /** + * Template variables used to render this email + */ + variables?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + /** + * When this email should be sent (leave empty for immediate) + */ + scheduledAt?: string | null; + /** + * When this email was actually sent + */ + sentAt?: string | null; + /** + * Current status of this email + */ + status: 'pending' | 'processing' | 'sent' | 'failed'; + /** + * Number of send attempts made + */ + attempts?: number | null; + /** + * When the last send attempt was made + */ + lastAttemptAt?: string | null; + /** + * Last error message if send failed + */ + error?: string | null; + /** + * Email priority (1=highest, 10=lowest) + */ + priority?: number | null; updatedAt: string; createdAt: string; } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "users". + * via the `definition` "payload-jobs". */ -export interface User { +export interface PayloadJob { id: string; + /** + * Input data provided to the job + */ + input?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + taskStatus?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + completedAt?: string | null; + totalTried?: number | null; + /** + * If hasError is true this job will not be retried + */ + hasError?: boolean | null; + /** + * If hasError is true, this is the error that caused it + */ + error?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + /** + * Task execution log + */ + log?: + | { + executedAt: string; + completedAt: string; + taskSlug: 'inline' | 'process-email-queue'; + taskID: string; + input?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + output?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + state: 'failed' | 'succeeded'; + error?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + id?: string | null; + }[] + | null; + taskSlug?: ('inline' | 'process-email-queue') | null; + queue?: string | null; + waitUntil?: string | null; + processing?: boolean | null; updatedAt: string; createdAt: string; - email: string; - resetPasswordToken?: string | null; - resetPasswordExpiration?: string | null; - salt?: string | null; - hash?: string | null; - loginAttempts?: number | null; - lockUntil?: string | null; - password?: string | null; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -122,6 +409,10 @@ export interface User { export interface PayloadLockedDocument { id: string; document?: + | ({ + relationTo: 'users'; + value: string | User; + } | null) | ({ relationTo: 'posts'; value: string | Post; @@ -131,12 +422,16 @@ export interface PayloadLockedDocument { value: string | Media; } | null) | ({ - relationTo: 'plugin-collection'; - value: string | PluginCollection; + relationTo: 'email-templates'; + value: string | EmailTemplate; } | null) | ({ - relationTo: 'users'; - value: string | User; + relationTo: 'emails'; + value: string | Email; + } | null) + | ({ + relationTo: 'payload-jobs'; + value: string | PayloadJob; } | null); globalSlug?: string | null; user: { @@ -180,12 +475,35 @@ export interface PayloadMigration { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + firstName?: T; + lastName?: T; + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "posts_select". */ export interface PostsSelect { - addedByPlugin?: T; updatedAt?: T; createdAt?: T; } @@ -208,27 +526,72 @@ export interface MediaSelect { } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "plugin-collection_select". + * via the `definition` "email-templates_select". */ -export interface PluginCollectionSelect { - id?: T; +export interface EmailTemplatesSelect { + name?: T; + slug?: T; + subject?: T; + content?: T; updatedAt?: T; createdAt?: T; } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "users_select". + * via the `definition` "emails_select". */ -export interface UsersSelect { +export interface EmailsSelect { + template?: T; + templateSlug?: T; + to?: T; + cc?: T; + bcc?: T; + from?: T; + replyTo?: T; + subject?: T; + html?: T; + text?: T; + variables?: T; + scheduledAt?: T; + sentAt?: T; + status?: T; + attempts?: T; + lastAttemptAt?: T; + error?: T; + priority?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-jobs_select". + */ +export interface PayloadJobsSelect { + input?: T; + taskStatus?: T; + completedAt?: T; + totalTried?: T; + hasError?: T; + error?: T; + log?: + | T + | { + executedAt?: T; + completedAt?: T; + taskSlug?: T; + taskID?: T; + input?: T; + output?: T; + state?: T; + error?: T; + id?: T; + }; + taskSlug?: T; + queue?: T; + waitUntil?: T; + processing?: T; updatedAt?: T; createdAt?: T; - email?: T; - resetPasswordToken?: T; - resetPasswordExpiration?: T; - salt?: T; - hash?: T; - loginAttempts?: T; - lockUntil?: T; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -262,6 +625,14 @@ export interface PayloadMigrationsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "ProcessEmailQueueJob". + */ +export interface ProcessEmailQueueJob { + input?: unknown; + output?: unknown; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". diff --git a/dev/payload.config.ts b/dev/payload.config.ts index 74cd4fb..57ac215 100644 --- a/dev/payload.config.ts +++ b/dev/payload.config.ts @@ -1,14 +1,23 @@ import { mongooseAdapter } from '@payloadcms/db-mongodb' import { lexicalEditor } from '@payloadcms/richtext-lexical' +import { + BlocksFeature, + FixedToolbarFeature, + HeadingFeature, + HorizontalRuleFeature, + InlineToolbarFeature, + lexicalHTML, +} from '@payloadcms/richtext-lexical' import { MongoMemoryReplSet } from 'mongodb-memory-server' import path from 'path' import { buildConfig } from 'payload' -import { tempProject } from 'temp-project' import sharp from 'sharp' import { fileURLToPath } from 'url' import { testEmailAdapter } from './helpers/testEmailAdapter.js' -import { seed } from './seed.js' +import { seed, seedUser } from './seed.js' +import mailingPlugin from "../src/plugin.js" +import { sendEmail } from "../src/utils/helpers.js" const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -18,15 +27,32 @@ if (!process.env.ROOT_DIR) { } const buildConfigWithMemoryDB = async () => { - if (process.env.NODE_ENV === 'test') { + // Use in-memory MongoDB for development and testing + if (process.env.NODE_ENV === 'test' || process.env.USE_MEMORY_DB === 'true' || !process.env.DATABASE_URI) { + console.log('šŸš€ Starting MongoDB in-memory database...') + const memoryDB = await MongoMemoryReplSet.create({ replSet: { - count: 3, - dbName: 'payloadmemory', + count: 1, // Single instance for dev (faster startup) + dbName: process.env.NODE_ENV === 'test' ? 'payloadmemory' : 'payload-mailing-dev', + storageEngine: 'wiredTiger', }, }) - process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true` + const uri = `${memoryDB.getUri()}&retryWrites=true` + process.env.DATABASE_URI = uri + + console.log('āœ… MongoDB in-memory database started') + console.log(`šŸ“Š Database URI: ${uri.replace(/mongodb:\/\/[^@]*@/, 'mongodb://***@')}`) + + // Graceful shutdown + process.on('SIGINT', async () => { + console.log('šŸ›‘ Stopping MongoDB in-memory database...') + await memoryDB.stop() + process.exit(0) + }) + } else { + console.log(`šŸ”— Using external MongoDB: ${process.env.DATABASE_URI?.replace(/mongodb:\/\/[^@]*@/, 'mongodb://***@')}`) } return buildConfig({ @@ -36,6 +62,52 @@ const buildConfigWithMemoryDB = async () => { }, }, collections: [ + { + slug: 'users', + auth: true, + fields: [ + { + name: 'firstName', + type: 'text', + }, + { + name: 'lastName', + type: 'text', + }, + ], + hooks: { + afterChange: [ + async ({ doc, operation, req, previousDoc }) => { + // Only send welcome email on user creation, not updates + if (operation === 'create' && doc.email) { + try { + console.log('šŸ“§ Queuing welcome email for new user:', doc.email) + + // Queue the welcome email using template slug + const emailId = await sendEmail(req.payload, { + templateSlug: 'welcome-email', + to: doc.email, + variables: { + firstName: doc.firstName || doc.email?.split('@')?.[0], + siteName: 'PayloadCMS Mailing Demo', + createdAt: new Date().toISOString(), + isPremium: false, + dashboardUrl: 'http://localhost:3000/admin', + }, + }) + + console.log('āœ… Welcome email queued successfully. Email ID:', emailId) + } catch (error) { + console.error('āŒ Error queuing welcome email:', error) + // Don't throw - we don't want to fail user creation if email fails + } + } + + return doc + }, + ], + }, + }, { slug: 'posts', fields: [], @@ -58,9 +130,93 @@ const buildConfigWithMemoryDB = async () => { await seed(payload) }, plugins: [ - tempProject({ - collections: { - posts: true, + mailingPlugin({ + defaultFrom: 'noreply@test.com', + initOrder: 'after', + transport: { + host: 'localhost', + port: 1025, // MailHog port for dev + secure: false, + auth: { + user: 'test', + pass: 'test', + }, + }, + retryAttempts: 3, + retryDelay: 60000, // 1 minute for dev + queue: 'email-queue', + + // Optional: Custom rich text editor configuration + // Comment out to use default lexical editor + richTextEditor: lexicalEditor({ + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + // Example: Add custom features for email templates + FixedToolbarFeature(), + InlineToolbarFeature(), + HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3'] }), + HorizontalRuleFeature(), + // You can add more features like: + // BlocksFeature({ blocks: [...] }), + // LinkFeature({ ... }), + // etc. + ], + }), + + emailWrapper: (email) => { + // Example: wrap email content in a custom layout + const wrappedHtml = ` + + + + + + ${email.subject} + + + +
+
+

My Company

+
+
+ ${email.html} +
+ +
+ + + ` + + const wrappedText = ` +MY COMPANY +========== + +${email.text || email.html?.replace(/<[^>]*>/g, '')} + +--- +This email was sent from My Company. +If you have questions, contact support@mycompany.com + ` + + return { + ...email, + html: wrappedHtml, + text: wrappedText.trim(), + } + }, + + // Called after mailing plugin is fully initialized + onReady: async (payload) => { + await seedUser(payload) }, }), ], diff --git a/dev/seed.ts b/dev/seed.ts index 8e731f1..d297517 100644 --- a/dev/seed.ts +++ b/dev/seed.ts @@ -3,8 +3,214 @@ import type { Payload } from 'payload' import { devUser } from './helpers/credentials.js' export const seed = async (payload: Payload) => { + // Create example email template + const { totalDocs: templateCount } = await payload.count({ + collection: 'email-templates' as const, + }) + + if (templateCount === 0) { + // Simple welcome email template + await payload.create({ + collection: 'email-templates' as const, + data: { + name: 'Welcome Email', + slug: 'welcome-email', + subject: 'Welcome to {{siteName}}, {{firstName}}!', + content: { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Welcome {{firstName}}! šŸŽ‰', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + tag: 'h1', + type: 'heading', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: "We're thrilled to have you join {{siteName}}! This email demonstrates how easy it is to create beautiful emails using PayloadCMS's rich text editor with Handlebars variables.", + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 1, + mode: 'normal', + style: '', + text: 'What you can do:', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + }, + { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Create beautiful emails with rich text formatting', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'listitem', + value: 1, + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Use the emailWrapper hook to add custom layouts', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'listitem', + value: 2, + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Queue and schedule emails effortlessly', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'listitem', + value: 3, + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + listType: 'bullet', + start: 1, + tag: 'ul', + type: 'list', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Get started by exploring the admin panel and creating your own email templates. Your account was created on {{formatDate createdAt "long"}}.', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'Best regards,', + type: 'text', + version: 1, + }, + { + type: 'linebreak', + version: 1, + }, + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'The {{siteName}} Team', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }, + }, + }) + + console.log('āœ… Example email template created successfully') + } +} + +export const seedUser = async (payload: Payload) => { + // Create dev user if not exists - called after mailing plugin is initialized const { totalDocs } = await payload.count({ - collection: 'users', + collection: 'users' as const, where: { email: { equals: devUser.email, @@ -14,7 +220,7 @@ export const seed = async (payload: Payload) => { if (!totalDocs) { await payload.create({ - collection: 'users', + collection: 'users' as const, data: devUser, }) } diff --git a/dev/start-dev.js b/dev/start-dev.js new file mode 100755 index 0000000..c1d326b --- /dev/null +++ b/dev/start-dev.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +// Development startup script for PayloadCMS Mailing Plugin +// This ensures proper environment setup and provides helpful information + +console.log('šŸš€ PayloadCMS Mailing Plugin - Development Mode') +console.log('=' .repeat(50)) + +// Set development environment +process.env.NODE_ENV = process.env.NODE_ENV || 'development' + +// Enable in-memory MongoDB by default for development +if (!process.env.DATABASE_URI) { + process.env.USE_MEMORY_DB = 'true' + console.log('šŸ“¦ Using in-memory MongoDB (no installation required)') +} else { + console.log(`šŸ”— Using external MongoDB: ${process.env.DATABASE_URI}`) +} + +console.log('') +console.log('šŸ”§ Starting development server...') +console.log('šŸ“§ Mailing plugin configured with test transport') +console.log('šŸŽÆ Test interface will be available at: /mailing-test') +console.log('') + +// Import and start Next.js +import('next/dist/cli/next-dev.js') + .then(({ nextDev }) => { + nextDev([]) + }) + .catch((error) => { + console.error('āŒ Failed to start development server:', error) + process.exit(1) + }) + +// Handle graceful shutdown +process.on('SIGTERM', () => { + console.log('\nšŸ›‘ Shutting down development server...') + process.exit(0) +}) + +process.on('SIGINT', () => { + console.log('\nšŸ›‘ Shutting down development server...') + process.exit(0) +}) \ No newline at end of file diff --git a/dev/test-plugin.mjs b/dev/test-plugin.mjs new file mode 100644 index 0000000..6d7cf34 --- /dev/null +++ b/dev/test-plugin.mjs @@ -0,0 +1,40 @@ +// Simple test to verify plugin can be imported and initialized +import { mailingPlugin, sendEmail, scheduleEmail } 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) + +// Test plugin configuration +try { + const testConfig = { + collections: [], + db: null, + secret: 'test' + } + + const pluginFn = mailingPlugin({ + defaultFrom: 'test@example.com', + transport: { + host: 'localhost', + port: 1025, + secure: false, + auth: { user: 'test', pass: 'test' } + } + }) + + const configWithPlugin = pluginFn(testConfig) + console.log('āœ… Plugin configuration works') + console.log('āœ… Collections added:', configWithPlugin.collections?.length > testConfig.collections.length) + console.log('āœ… Jobs configured:', !!configWithPlugin.jobs) + +} catch (error) { + console.error('āŒ Plugin configuration error:', error.message) +} + +console.log('\nšŸŽ‰ PayloadCMS Mailing Plugin is ready for development!') +console.log('\nNext steps:') +console.log('1. Run: npm run dev (in dev directory)') +console.log('2. Open: http://localhost:3000/admin') +console.log('3. Test: http://localhost:3000/mailing-test') \ No newline at end of file diff --git a/dev/tsconfig.json b/dev/tsconfig.json index 63d1a1f..4f53f0d 100644 --- a/dev/tsconfig.json +++ b/dev/tsconfig.json @@ -31,5 +31,10 @@ }, "noEmit": true, "emitDeclarationOnly": false, + "plugins": [ + { + "name": "next" + } + ] } } diff --git a/package.json b/package.json index 8563366..51e0b50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-mailing", - "version": "1.0.0", + "version": "0.0.1", "description": "Template-based email system with scheduling and job processing for PayloadCMS", "type": "module", "main": "dist/index.js", @@ -13,12 +13,13 @@ } }, "scripts": { - "build": "npm run copyfiles && npm run build:types && npm run build:swc", - "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths", - "build:types": "tsc --outDir dist --rootDir ./src", + "build": "npm run clean && npm run build:tsc", + "build:tsc": "tsc --project ./tsconfig.build.json", "clean": "rimraf {dist,*.tsbuildinfo}", "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/", "dev": "next dev dev --turbo", + "dev:memory": "cd dev && USE_MEMORY_DB=true npm run dev", + "dev:start": "cd dev && node start-dev.js", "dev:generate-importmap": "npm run dev:payload generate:importmap", "dev:generate-types": "npm run dev:payload generate:types", "dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload", @@ -38,7 +39,7 @@ "scheduling", "jobs", "mailing", - "handlebars" + "richtext" ], "author": "XTR Development", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0768059..d2f46f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8950,7 +8950,7 @@ snapshots: eslint: 9.35.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.35.0)(typescript@5.9.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0))(eslint@9.35.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.35.0)(typescript@5.9.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0) eslint-plugin-react: 7.37.5(eslint@9.35.0) eslint-plugin-react-hooks: 5.2.0(eslint@9.35.0) @@ -8984,7 +8984,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.35.0)(typescript@5.9.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0) eslint-plugin-import-x: 4.6.1(eslint@9.35.0)(typescript@5.9.2) transitivePeerDependencies: - supports-color @@ -9041,7 +9041,7 @@ snapshots: - typescript optional: true - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.35.0)(typescript@5.9.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0)(typescript@5.9.2))(eslint@9.35.0))(eslint@9.35.0))(eslint@9.35.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 diff --git a/src/collections/EmailTemplates.ts b/src/collections/EmailTemplates.ts index 77fbc61..6095abf 100644 --- a/src/collections/EmailTemplates.ts +++ b/src/collections/EmailTemplates.ts @@ -1,6 +1,7 @@ -import { CollectionConfig } from 'payload/types' +import type { CollectionConfig, RichTextField } from 'payload' +import { lexicalEditor } from '@payloadcms/richtext-lexical' -const EmailTemplates: CollectionConfig = { +export const createEmailTemplatesCollection = (editor?: RichTextField['editor']): CollectionConfig => ({ slug: 'email-templates', admin: { useAsTitle: 'name', @@ -22,84 +23,50 @@ const EmailTemplates: CollectionConfig = { description: 'A descriptive name for this email template', }, }, + { + name: 'slug', + type: 'text', + required: true, + unique: true, + admin: { + description: 'Unique identifier for this template (e.g., "welcome-email", "password-reset")', + }, + hooks: { + beforeChange: [ + ({ value }) => { + if (value) { + return value.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') + } + return value + }, + ], + }, + }, { name: 'subject', type: 'text', required: true, admin: { - description: 'Email subject line (supports Handlebars variables)', + description: 'Email subject line. You can use Handlebars variables like {{firstName}} or {{siteName}}.', }, }, { - name: 'htmlTemplate', - type: 'textarea', + name: 'content', + type: 'richText', required: true, + editor: editor || lexicalEditor({ + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + ], + }), admin: { - description: 'HTML email template (supports Handlebars syntax)', - rows: 10, - }, - }, - { - name: 'textTemplate', - type: 'textarea', - admin: { - description: 'Plain text email template (supports Handlebars syntax)', - rows: 8, - }, - }, - { - name: 'variables', - type: 'array', - admin: { - description: 'Define variables that can be used in this template', - }, - fields: [ - { - name: 'name', - type: 'text', - required: true, - admin: { - description: 'Variable name (e.g., "firstName", "orderTotal")', - }, - }, - { - name: 'type', - type: 'select', - required: true, - options: [ - { label: 'Text', value: 'text' }, - { label: 'Number', value: 'number' }, - { label: 'Boolean', value: 'boolean' }, - { label: 'Date', value: 'date' }, - ], - defaultValue: 'text', - }, - { - name: 'required', - type: 'checkbox', - defaultValue: false, - admin: { - description: 'Is this variable required when sending emails?', - }, - }, - { - name: 'description', - type: 'text', - admin: { - description: 'Optional description of what this variable represents', - }, - }, - ], - }, - { - name: 'previewData', - type: 'json', - admin: { - description: 'Sample data for previewing this template (JSON format)', + description: 'Email content with rich text formatting. Supports Handlebars variables like {{firstName}} and helpers like {{formatDate createdAt "long"}}. Content is converted to HTML and plain text automatically.', }, }, ], timestamps: true, -} +}) +// Default export for backward compatibility +const EmailTemplates = createEmailTemplatesCollection() export default EmailTemplates \ No newline at end of file diff --git a/src/collections/EmailOutbox.ts b/src/collections/Emails.ts similarity index 83% rename from src/collections/EmailOutbox.ts rename to src/collections/Emails.ts index cfcab94..7f0ec6c 100644 --- a/src/collections/EmailOutbox.ts +++ b/src/collections/Emails.ts @@ -1,11 +1,12 @@ -import { CollectionConfig } from 'payload/types' +import type { CollectionConfig } from 'payload' -const EmailOutbox: CollectionConfig = { - slug: 'email-outbox', +const Emails: CollectionConfig = { + slug: 'emails', admin: { useAsTitle: 'subject', defaultColumns: ['subject', 'to', 'status', 'scheduledAt', 'sentAt'], group: 'Mailing', + description: 'Email delivery and status tracking', }, access: { read: () => true, @@ -17,7 +18,7 @@ const EmailOutbox: CollectionConfig = { { name: 'template', type: 'relationship', - relationTo: 'email-templates', + relationTo: 'email-templates' as const, admin: { description: 'Email template used (optional if custom content provided)', }, @@ -26,22 +27,25 @@ const EmailOutbox: CollectionConfig = { name: 'to', type: 'text', required: true, + hasMany: true, admin: { - description: 'Recipient email address(es), comma-separated', + description: 'Recipient email addresses', }, }, { name: 'cc', type: 'text', + hasMany: true, admin: { - description: 'CC email address(es), comma-separated', + description: 'CC email addresses', }, }, { name: 'bcc', type: 'text', + hasMany: true, admin: { - description: 'BCC email address(es), comma-separated', + description: 'BCC email addresses', }, }, { @@ -161,20 +165,20 @@ const EmailOutbox: CollectionConfig = { }, ], timestamps: true, - indexes: [ - { - fields: { - status: 1, - scheduledAt: 1, - }, - }, - { - fields: { - priority: -1, - createdAt: 1, - }, - }, - ], + // indexes: [ + // { + // fields: { + // status: 1, + // scheduledAt: 1, + // }, + // }, + // { + // fields: { + // priority: -1, + // createdAt: 1, + // }, + // }, + // ], } -export default EmailOutbox \ No newline at end of file +export default Emails diff --git a/src/index.ts b/src/index.ts index 844ca6d..226aed5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,25 +1,23 @@ // Main plugin export -export { default as mailingPlugin } from './plugin' -export { mailingPlugin } from './plugin' +export { mailingPlugin, default as mailingPluginDefault } from './plugin.js' // Types -export * from './types' +export * from './types/index.js' // Services -export { MailingService } from './services/MailingService' +export { MailingService } from './services/MailingService.js' // Collections -export { default as EmailTemplates } from './collections/EmailTemplates' -export { default as EmailOutbox } from './collections/EmailOutbox' +export { default as EmailTemplates, createEmailTemplatesCollection } from './collections/EmailTemplates.js' +export { default as Emails } from './collections/Emails.js' -// Jobs -export * from './jobs' +// Jobs are integrated into the plugin configuration // Utility functions for developers export { getMailing, sendEmail, scheduleEmail, - processOutbox, + processEmails, retryFailedEmails, -} from './utils/helpers' \ No newline at end of file +} from './utils/helpers.js' \ No newline at end of file diff --git a/src/jobs/index.ts b/src/jobs/index.ts index 0c535b5..ecc47f2 100644 --- a/src/jobs/index.ts +++ b/src/jobs/index.ts @@ -1,20 +1,19 @@ -import { Job } from 'payload/jobs' -import { processOutboxJob, ProcessOutboxJobData } from './processOutboxJob' -import { MailingService } from '../services/MailingService' +import { processEmailsJob, ProcessEmailsJobData } from './processEmailsJob.js' +import { MailingService } from '../services/MailingService.js' -export const createMailingJobs = (mailingService: MailingService): Job[] => { +export const createMailingJobs = (mailingService: MailingService): any[] => { return [ { - slug: 'processOutbox', - handler: async ({ job, req }) => { - return processOutboxJob( - job as { data: ProcessOutboxJobData }, + slug: 'processEmails', + handler: async ({ job, req }: { job: any; req: any }) => { + return processEmailsJob( + job as { data: ProcessEmailsJobData }, { req, mailingService } ) }, - interfaceName: 'ProcessOutboxJob', + interfaceName: 'ProcessEmailsJob', }, ] } -export * from './processOutboxJob' \ No newline at end of file +export * from './processEmailsJob.js' \ No newline at end of file diff --git a/src/jobs/processOutboxJob.ts b/src/jobs/processEmailsJob.ts similarity index 61% rename from src/jobs/processOutboxJob.ts rename to src/jobs/processEmailsJob.ts index 4af8884..5a15d5c 100644 --- a/src/jobs/processOutboxJob.ts +++ b/src/jobs/processEmailsJob.ts @@ -1,21 +1,21 @@ -import { PayloadRequest } from 'payload/types' -import { MailingService } from '../services/MailingService' +import type { PayloadRequest } from 'payload' +import { MailingService } from '../services/MailingService.js' -export interface ProcessOutboxJobData { - type: 'process-outbox' | 'retry-failed' +export interface ProcessEmailsJobData { + type: 'process-emails' | 'retry-failed' } -export const processOutboxJob = async ( - job: { data: ProcessOutboxJobData }, +export const processEmailsJob = async ( + job: { data: ProcessEmailsJobData }, context: { req: PayloadRequest; mailingService: MailingService } ) => { const { mailingService } = context const { type } = job.data try { - if (type === 'process-outbox') { - await mailingService.processOutbox() - console.log('Outbox processing completed successfully') + if (type === 'process-emails') { + await mailingService.processEmails() + console.log('Email processing completed successfully') } else if (type === 'retry-failed') { await mailingService.retryFailedEmails() console.log('Failed email retry completed successfully') @@ -26,10 +26,10 @@ export const processOutboxJob = async ( } } -export const scheduleOutboxJob = async ( +export const scheduleEmailsJob = async ( payload: any, queueName: string, - jobType: 'process-outbox' | 'retry-failed', + jobType: 'process-emails' | 'retry-failed', delay?: number ) => { if (!payload.jobs) { @@ -40,7 +40,7 @@ export const scheduleOutboxJob = async ( try { await payload.jobs.queue({ queue: queueName, - task: 'processOutbox', + task: 'processEmails', input: { type: jobType }, waitUntil: delay ? new Date(Date.now() + delay) : undefined, }) diff --git a/src/plugin.ts b/src/plugin.ts index dbd44a3..085fe9b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,26 +1,57 @@ -import { Config } from 'payload/config' -import { MailingPluginConfig, MailingContext } from './types' -import { MailingService } from './services/MailingService' -import { createMailingJobs } from './jobs' -import EmailTemplates from './collections/EmailTemplates' -import EmailOutbox from './collections/EmailOutbox' -import { scheduleOutboxJob } from './jobs/processOutboxJob' +import type { Config } from 'payload' +import { MailingPluginConfig, MailingContext } from './types/index.js' +import { MailingService } from './services/MailingService.js' +import { createEmailTemplatesCollection } from './collections/EmailTemplates.js' +import Emails from './collections/Emails.js' export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Config): Config => { - const templatesSlug = pluginConfig.collections?.templates || 'email-templates' - const outboxSlug = pluginConfig.collections?.outbox || 'email-outbox' const queueName = pluginConfig.queue || 'default' - // Update collection slugs if custom ones are provided + // Handle templates collection configuration + const templatesConfig = pluginConfig.collections?.templates + const templatesSlug = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates' + const templatesOverrides = typeof templatesConfig === 'object' ? templatesConfig : {} + + // Create base templates collection with custom editor if provided + const baseTemplatesCollection = createEmailTemplatesCollection(pluginConfig.richTextEditor) + const templatesCollection = { - ...EmailTemplates, + ...baseTemplatesCollection, slug: templatesSlug, + ...templatesOverrides, + // Ensure admin config is properly merged + admin: { + ...baseTemplatesCollection.admin, + ...templatesOverrides.admin, + }, + // Ensure access config is properly merged + access: { + ...baseTemplatesCollection.access, + ...templatesOverrides.access, + }, } - const outboxCollection = { - ...EmailOutbox, - slug: outboxSlug, - fields: EmailOutbox.fields.map(field => { + // Handle emails collection configuration + const emailsConfig = pluginConfig.collections?.emails + const emailsSlug = typeof emailsConfig === 'string' ? emailsConfig : 'emails' + const emailsOverrides = typeof emailsConfig === 'object' ? emailsConfig : {} + + const emailsCollection = { + ...Emails, + slug: emailsSlug, + ...emailsOverrides, + // Ensure admin config is properly merged + admin: { + ...Emails.admin, + ...emailsOverrides.admin, + }, + // Ensure access config is properly merged + access: { + ...Emails.access, + ...emailsOverrides.access, + }, + // Update relationship fields to point to correct templates collection + fields: (emailsOverrides.fields || Emails.fields).map((field: any) => { if (field.name === 'template' && field.type === 'relationship') { return { ...field, @@ -36,63 +67,77 @@ export const mailingPlugin = (pluginConfig: MailingPluginConfig) => (config: Con collections: [ ...(config.collections || []), templatesCollection, - outboxCollection, + emailsCollection, ], jobs: { ...(config.jobs || {}), tasks: [ ...(config.jobs?.tasks || []), - // Jobs will be added via onInit hook + { + slug: 'process-email-queue', + handler: async ({ job, req }: { job: any; req: any }) => { + try { + const mailingService = new MailingService((req as any).payload, pluginConfig) + + console.log('šŸ”„ Processing email queue (pending + failed emails)...') + + // Process pending emails first + await mailingService.processEmails() + + // Then retry failed emails + await mailingService.retryFailedEmails() + + return { + output: { + success: true, + message: 'Email queue processed successfully (pending and failed emails)' + } + } + } catch (error) { + console.error('āŒ Error processing email queue:', error) + return { + output: { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + }, + interfaceName: 'ProcessEmailQueueJob', + }, ], }, - onInit: async (payload) => { - // Call original onInit if it exists - if (config.onInit) { + onInit: async (payload: any) => { + if (pluginConfig.initOrder === 'after' && config.onInit) { await config.onInit(payload) } // Initialize mailing service const mailingService = new MailingService(payload, pluginConfig) - - // Add mailing jobs - const mailingJobs = createMailingJobs(mailingService) - if (payload.jobs) { - mailingJobs.forEach(job => { - payload.jobs.addTask(job) - }) - } - - // Schedule periodic outbox processing (every 5 minutes) - const schedulePeriodicJob = async () => { - await scheduleOutboxJob(payload, queueName, 'process-outbox', 5 * 60 * 1000) // 5 minutes - setTimeout(schedulePeriodicJob, 5 * 60 * 1000) // Schedule next run - } - - // Schedule periodic retry job (every 30 minutes) - const scheduleRetryJob = async () => { - await scheduleOutboxJob(payload, queueName, 'retry-failed', 30 * 60 * 1000) // 30 minutes - setTimeout(scheduleRetryJob, 30 * 60 * 1000) // Schedule next run - } - - // Start periodic jobs if jobs are enabled - if (payload.jobs) { - setTimeout(schedulePeriodicJob, 5 * 60 * 1000) // Start after 5 minutes - setTimeout(scheduleRetryJob, 15 * 60 * 1000) // Start after 15 minutes - } // Add mailing context to payload for developer access ;(payload as any).mailing = { + payload, service: mailingService, config: pluginConfig, collections: { templates: templatesSlug, - outbox: outboxSlug, + emails: emailsSlug, }, } as MailingContext console.log('PayloadCMS Mailing Plugin initialized successfully') + + // Call onReady callback if provided + if (pluginConfig.onReady) { + await pluginConfig.onReady(payload) + } + + if (pluginConfig.initOrder !== 'after' && config.onInit) { + await config.onInit(payload) + } }, } } -export default mailingPlugin \ No newline at end of file +export default mailingPlugin diff --git a/src/services/MailingService.ts b/src/services/MailingService.ts index 926bb2b..3f80713 100644 --- a/src/services/MailingService.ts +++ b/src/services/MailingService.ts @@ -6,22 +6,28 @@ import { SendEmailOptions, MailingService as IMailingService, EmailTemplate, - OutboxEmail, - MailingTransportConfig -} from '../types' + QueuedEmail, + MailingTransportConfig, + EmailObject +} from '../types/index.js' +import { serializeRichTextToHTML, serializeRichTextToText } from '../utils/richTextSerializer.js' export class MailingService implements IMailingService { private payload: Payload private config: MailingPluginConfig - private transporter: Transporter + private transporter!: Transporter private templatesCollection: string - private outboxCollection: string + private emailsCollection: string constructor(payload: Payload, config: MailingPluginConfig) { this.payload = payload this.config = config - this.templatesCollection = config.collections?.templates || 'email-templates' - this.outboxCollection = config.collections?.outbox || 'email-outbox' + + const templatesConfig = config.collections?.templates + this.templatesCollection = typeof templatesConfig === 'string' ? templatesConfig : 'email-templates' + + const emailsConfig = config.collections?.emails + this.emailsCollection = typeof emailsConfig === 'string' ? emailsConfig : 'emails' this.initializeTransporter() this.registerHandlebarsHelpers() @@ -32,7 +38,7 @@ export class MailingService implements IMailingService { if ('sendMail' in this.config.transport) { this.transporter = this.config.transport } else { - this.transporter = nodemailer.createTransporter(this.config.transport as MailingTransportConfig) + this.transporter = nodemailer.createTransport(this.config.transport as MailingTransportConfig) } } else { throw new Error('Email transport configuration is required') @@ -64,7 +70,7 @@ export class MailingService implements IMailingService { }).format(amount) }) - Handlebars.registerHelper('ifEquals', function(arg1: any, arg2: any, options: any) { + Handlebars.registerHelper('ifEquals', function(this: any, arg1: any, arg2: any, options: any) { return (arg1 === arg2) ? options.fn(this) : options.inverse(this) }) @@ -75,29 +81,34 @@ export class MailingService implements IMailingService { } async sendEmail(options: SendEmailOptions): Promise { - const outboxId = await this.scheduleEmail({ + const emailId = await this.scheduleEmail({ ...options, scheduledAt: new Date() }) - await this.processOutboxItem(outboxId) + await this.processEmailItem(emailId) - return outboxId + return emailId } async scheduleEmail(options: SendEmailOptions): Promise { let html = options.html || '' let text = options.text || '' let subject = options.subject || '' + let templateId: string | undefined = undefined - if (options.templateId) { - const template = await this.getTemplate(options.templateId) + if (options.templateSlug) { + const template = await this.getTemplateBySlug(options.templateSlug) + if (template) { + templateId = template.id const variables = options.variables || {} - - html = this.renderTemplate(template.htmlTemplate, variables) - text = template.textTemplate ? this.renderTemplate(template.textTemplate, variables) : '' - subject = this.renderTemplate(template.subject, variables) + const renderedContent = await this.renderEmailTemplate(template, variables) + html = renderedContent.html + text = renderedContent.text + subject = this.renderHandlebarsTemplate(template.subject, variables) + } else { + throw new Error(`Email template not found: ${options.templateSlug}`) } } @@ -109,11 +120,11 @@ export class MailingService implements IMailingService { throw new Error('Email HTML content is required') } - const outboxData = { - template: options.templateId || undefined, - to: Array.isArray(options.to) ? options.to.join(', ') : options.to, - cc: options.cc ? (Array.isArray(options.cc) ? options.cc.join(', ') : options.cc) : undefined, - bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc.join(', ') : options.bcc) : undefined, + const queueData = { + template: templateId, + to: Array.isArray(options.to) ? options.to : [options.to], + cc: options.cc ? (Array.isArray(options.cc) ? options.cc : [options.cc]) : undefined, + bcc: options.bcc ? (Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : undefined, from: options.from || this.config.defaultFrom, replyTo: options.replyTo, subject: subject || options.subject, @@ -127,18 +138,18 @@ export class MailingService implements IMailingService { } const result = await this.payload.create({ - collection: this.outboxCollection, - data: outboxData, + collection: this.emailsCollection as any, + data: queueData, }) return result.id as string } - async processOutbox(): Promise { + async processEmails(): Promise { const currentTime = new Date().toISOString() const { docs: pendingEmails } = await this.payload.find({ - collection: this.outboxCollection, + collection: this.emailsCollection as any, where: { and: [ { @@ -167,7 +178,7 @@ export class MailingService implements IMailingService { }) for (const email of pendingEmails) { - await this.processOutboxItem(email.id) + await this.processEmailItem(String(email.id)) } } @@ -177,7 +188,7 @@ export class MailingService implements IMailingService { const retryTime = new Date(Date.now() - retryDelay).toISOString() const { docs: failedEmails } = await this.payload.find({ - collection: this.outboxCollection, + collection: this.emailsCollection as any, where: { and: [ { @@ -210,15 +221,15 @@ export class MailingService implements IMailingService { }) for (const email of failedEmails) { - await this.processOutboxItem(email.id) + await this.processEmailItem(String(email.id)) } } - private async processOutboxItem(outboxId: string): Promise { + private async processEmailItem(emailId: string): Promise { try { await this.payload.update({ - collection: this.outboxCollection, - id: outboxId, + collection: this.emailsCollection as any, + id: emailId, data: { status: 'processing', lastAttemptAt: new Date().toISOString(), @@ -226,11 +237,11 @@ export class MailingService implements IMailingService { }) const email = await this.payload.findByID({ - collection: this.outboxCollection, - id: outboxId, - }) as OutboxEmail + collection: this.emailsCollection as any, + id: emailId, + }) as QueuedEmail - const mailOptions = { + let emailObject: EmailObject = { from: email.from || this.config.defaultFrom, to: email.to, cc: email.cc || undefined, @@ -239,13 +250,30 @@ export class MailingService implements IMailingService { subject: email.subject, html: email.html, text: email.text || undefined, + variables: email.variables, + } + + // Apply emailWrapper hook if configured + if (this.config.emailWrapper) { + emailObject = await this.config.emailWrapper(emailObject) + } + + const mailOptions = { + from: emailObject.from || this.config.defaultFrom, + to: emailObject.to, + cc: emailObject.cc || undefined, + bcc: emailObject.bcc || undefined, + replyTo: emailObject.replyTo || undefined, + subject: emailObject.subject, + html: emailObject.html, + text: emailObject.text || undefined, } await this.transporter.sendMail(mailOptions) await this.payload.update({ - collection: this.outboxCollection, - id: outboxId, + collection: this.emailsCollection as any, + id: emailId, data: { status: 'sent', sentAt: new Date().toISOString(), @@ -254,12 +282,12 @@ export class MailingService implements IMailingService { }) } catch (error) { - const attempts = await this.incrementAttempts(outboxId) + const attempts = await this.incrementAttempts(emailId) const maxAttempts = this.config.retryAttempts || 3 await this.payload.update({ - collection: this.outboxCollection, - id: outboxId, + collection: this.emailsCollection as any, + id: emailId, data: { status: attempts >= maxAttempts ? 'failed' : 'pending', error: error instanceof Error ? error.message : 'Unknown error', @@ -268,22 +296,22 @@ export class MailingService implements IMailingService { }) if (attempts >= maxAttempts) { - console.error(`Email ${outboxId} failed permanently after ${attempts} attempts:`, error) + console.error(`Email ${emailId} failed permanently after ${attempts} attempts:`, error) } } } - private async incrementAttempts(outboxId: string): Promise { + private async incrementAttempts(emailId: string): Promise { const email = await this.payload.findByID({ - collection: this.outboxCollection, - id: outboxId, - }) as OutboxEmail + collection: this.emailsCollection as any, + id: emailId, + }) as QueuedEmail const newAttempts = (email.attempts || 0) + 1 await this.payload.update({ - collection: this.outboxCollection, - id: outboxId, + collection: this.emailsCollection as any, + id: emailId, data: { attempts: newAttempts, }, @@ -292,26 +320,49 @@ export class MailingService implements IMailingService { return newAttempts } - private async getTemplate(templateId: string): Promise { + private async getTemplateBySlug(templateSlug: string): Promise { try { - const template = await this.payload.findByID({ - collection: this.templatesCollection, - id: templateId, + const { docs } = await this.payload.find({ + collection: this.templatesCollection as any, + where: { + slug: { + equals: templateSlug, + }, + }, + limit: 1, }) - return template as EmailTemplate + + return docs.length > 0 ? docs[0] as EmailTemplate : null } catch (error) { - console.error(`Template ${templateId} not found:`, error) + console.error(`Template with slug '${templateSlug}' not found:`, error) return null } } - private renderTemplate(template: string, variables: Record): string { + private renderHandlebarsTemplate(template: string, variables: Record): string { try { const compiled = Handlebars.compile(template) return compiled(variables) } catch (error) { - console.error('Template rendering error:', error) + console.error('Handlebars template rendering error:', error) return template } } + + private async renderEmailTemplate(template: EmailTemplate, variables: Record = {}): Promise<{ html: string; text: string }> { + if (!template.content) { + return { html: '', text: '' } + } + + // Serialize richtext to HTML and text + let html = serializeRichTextToHTML(template.content) + let text = serializeRichTextToText(template.content) + + // Apply Handlebars variables to the rendered content + html = this.renderHandlebarsTemplate(html, variables) + text = this.renderHandlebarsTemplate(text, variables) + + return { html, text } + } + } \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 623bdf7..eb30afb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,16 +1,35 @@ import { Payload } from 'payload' +import type { CollectionConfig, RichTextField } from 'payload' import { Transporter } from 'nodemailer' +export interface EmailObject { + to: string | string[] + cc?: string | string[] + bcc?: string | string[] + from?: string + replyTo?: string + subject: string + html: string + text?: string + variables?: Record +} + +export type EmailWrapperHook = (email: EmailObject) => EmailObject | Promise + export interface MailingPluginConfig { collections?: { - templates?: string - outbox?: string + templates?: string | Partial + emails?: string | Partial } defaultFrom?: string transport?: Transporter | MailingTransportConfig queue?: string retryAttempts?: number retryDelay?: number + emailWrapper?: EmailWrapperHook + richTextEditor?: RichTextField['editor'] + onReady?: (payload: any) => Promise + initOrder?: 'before' | 'after' } export interface MailingTransportConfig { @@ -26,27 +45,20 @@ export interface MailingTransportConfig { export interface EmailTemplate { id: string name: string + slug: string subject: string - htmlTemplate: string - textTemplate?: string - variables?: TemplateVariable[] + content: any // Lexical editor state createdAt: string updatedAt: string } -export interface TemplateVariable { - name: string - type: 'text' | 'number' | 'boolean' | 'date' - required: boolean - description?: string -} -export interface OutboxEmail { +export interface QueuedEmail { id: string - templateId?: string - to: string | string[] - cc?: string | string[] - bcc?: string | string[] + template?: string + to: string[] + cc?: string[] + bcc?: string[] from?: string replyTo?: string subject: string @@ -65,7 +77,7 @@ export interface OutboxEmail { } export interface SendEmailOptions { - templateId?: string + templateSlug?: string to: string | string[] cc?: string | string[] bcc?: string | string[] @@ -82,7 +94,7 @@ export interface SendEmailOptions { export interface MailingService { sendEmail(options: SendEmailOptions): Promise scheduleEmail(options: SendEmailOptions): Promise - processOutbox(): Promise + processEmails(): Promise retryFailedEmails(): Promise } @@ -90,4 +102,4 @@ export interface MailingContext { payload: Payload config: MailingPluginConfig service: MailingService -} \ No newline at end of file +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 5678074..38212f6 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,5 +1,5 @@ import { Payload } from 'payload' -import { SendEmailOptions } from '../types' +import { SendEmailOptions } from '../types/index.js' export const getMailing = (payload: Payload) => { const mailing = (payload as any).mailing @@ -19,9 +19,9 @@ export const scheduleEmail = async (payload: Payload, options: SendEmailOptions) return mailing.service.scheduleEmail(options) } -export const processOutbox = async (payload: Payload): Promise => { +export const processEmails = async (payload: Payload): Promise => { const mailing = getMailing(payload) - return mailing.service.processOutbox() + return mailing.service.processEmails() } export const retryFailedEmails = async (payload: Payload): Promise => { diff --git a/src/utils/richTextSerializer.ts b/src/utils/richTextSerializer.ts new file mode 100644 index 0000000..f8a0391 --- /dev/null +++ b/src/utils/richTextSerializer.ts @@ -0,0 +1,150 @@ +// Using any type for now since Lexical types have import issues +// import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical' +type SerializedEditorState = any + +/** + * Converts Lexical richtext content to HTML + */ +export function serializeRichTextToHTML(richTextData: SerializedEditorState): string { + if (!richTextData?.root?.children) { + return '' + } + + return serializeNodesToHTML(richTextData.root.children) +} + +/** + * Converts Lexical richtext content to plain text + */ +export function serializeRichTextToText(richTextData: SerializedEditorState): string { + if (!richTextData?.root?.children) { + return '' + } + + return serializeNodesToText(richTextData.root.children) +} + +function serializeNodesToHTML(nodes: any[]): string { + return nodes.map(node => serializeNodeToHTML(node)).join('') +} + +function serializeNodeToHTML(node: any): string { + if (!node) return '' + + switch (node.type) { + case 'paragraph': + const children = node.children ? serializeNodesToHTML(node.children) : '' + return `

${children}

` + + case 'heading': + const headingChildren = node.children ? serializeNodesToHTML(node.children) : '' + const tag = node.tag || 'h1' + return `<${tag}>${headingChildren}` + + case 'text': + let text = node.text || '' + + // Apply text formatting + 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 + } + + return text + + case 'linebreak': + return '
' + + case 'list': + const listChildren = node.children ? serializeNodesToHTML(node.children) : '' + const listTag = node.listType === 'number' ? 'ol' : 'ul' + return `<${listTag}>${listChildren}` + + case 'listitem': + const listItemChildren = node.children ? serializeNodesToHTML(node.children) : '' + return `
  • ${listItemChildren}
  • ` + + case 'quote': + const quoteChildren = node.children ? serializeNodesToHTML(node.children) : '' + return `
    ${quoteChildren}
    ` + + case 'link': + const linkChildren = node.children ? serializeNodesToHTML(node.children) : '' + const url = node.url || '#' + const target = node.newTab ? ' target="_blank" rel="noopener noreferrer"' : '' + return `${linkChildren}` + + case 'horizontalrule': + return '
    ' + + default: + // Handle unknown nodes by processing children + if (node.children) { + return serializeNodesToHTML(node.children) + } + return '' + } +} + +function serializeNodesToText(nodes: any[]): string { + return nodes.map(node => serializeNodeToText(node)).join('') +} + +function serializeNodeToText(node: any): string { + if (!node) return '' + + switch (node.type) { + case 'paragraph': + const children = node.children ? serializeNodesToText(node.children) : '' + return `${children}\n\n` + + case 'heading': + const headingChildren = node.children ? serializeNodesToText(node.children) : '' + return `${headingChildren}\n\n` + + case 'text': + return node.text || '' + + case 'linebreak': + return '\n' + + case 'list': + const listChildren = node.children ? serializeNodesToText(node.children) : '' + return `${listChildren}\n` + + case 'listitem': + const listItemChildren = node.children ? serializeNodesToText(node.children) : '' + return `• ${listItemChildren}\n` + + case 'quote': + const quoteChildren = node.children ? serializeNodesToText(node.children) : '' + return `> ${quoteChildren}\n\n` + + case 'link': + const linkChildren = node.children ? serializeNodesToText(node.children) : '' + const url = node.url || '' + return `${linkChildren} (${url})` + + case 'horizontalrule': + return '---\n\n' + + default: + // Handle unknown nodes by processing children + if (node.children) { + return serializeNodesToText(node.children) + } + return '' + } +} + +/** + * Applies Handlebars variables to richtext-generated HTML/text + */ +export function applyVariablesToContent(content: string, variables: Record): string { + // This function can be extended to handle more complex variable substitution + // For now, it works with the Handlebars rendering that happens later + return content +} \ No newline at end of file diff --git a/test-slug-system.js b/test-slug-system.js new file mode 100644 index 0000000..12e4017 --- /dev/null +++ b/test-slug-system.js @@ -0,0 +1,56 @@ +// Test script to verify slug-based template system +import { getPayload } from 'payload' +import configPromise from './dev/payload.config.js' +import { sendEmail } from './dist/utils/helpers.js' + +async function testSlugSystem() { + console.log('šŸ”„ Testing slug-based template system...\n') + + try { + const config = await configPromise + const payload = await getPayload({ config }) + + console.log('šŸ“ Sending email using template slug "welcome-email"') + + const emailId = await sendEmail(payload, { + templateSlug: 'welcome-email', + to: 'test-slug@example.com', + variables: { + firstName: 'SlugTest', + siteName: 'Slug Demo Site', + createdAt: new Date().toISOString(), + isPremium: true, + dashboardUrl: 'http://localhost:3000/admin', + }, + }) + + console.log('āœ… Email queued successfully with ID:', emailId) + + // Check if email was queued with templateSlug + const email = await payload.findByID({ + collection: 'emails', + id: emailId, + }) + + console.log('\nšŸ“§ Email details:') + console.log('- ID:', email.id) + console.log('- To:', email.to) + console.log('- Subject:', email.subject) + console.log('- Template Slug:', email.templateSlug) + console.log('- Status:', email.status) + console.log('- Subject contains personalized data:', email.subject.includes('SlugTest')) + + if (email.templateSlug === 'welcome-email') { + console.log('\nāœ… Slug-based template system working correctly!') + } else { + console.log('\nāŒ Template slug not stored correctly') + } + + process.exit(0) + } catch (error) { + console.error('āŒ Error:', error) + process.exit(1) + } +} + +testSlugSystem() \ No newline at end of file diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..99b1f5c --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "node", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "dev", + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 06cf857..520b4ed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, - "emitDeclarationOnly": true, + "emitDeclarationOnly": false, "target": "ES2022", "composite": true, "declaration": true,