From 45559804b0f91cb1085518ed809db7ca11fc69c4 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sat, 13 Sep 2025 21:51:52 +0200 Subject: [PATCH] Refactor email handling for improved type safety and extensibility - Replace `BaseEmailData` with `Email` type for stricter type validation - Update `sendEmail` API to support new typed structure and template integration - Migrate to `@xtr-dev/payload-mailing` for enhanced email sending capabilities - Remove unnecessary null checks and redundant code in email scheduling logic - Regenerate PayloadCMS types for aligning with revised schema changes - Update dev scripts and imports for seamless compatibility with the new email module --- dev/payload-types.ts | 85 +++++++-- dev/payload.config.ts | 24 +-- dev/tsconfig.json | 6 +- package.json | 7 +- src/payload-types.ts | 431 ++++++++++++++++++++++++++++++++++++++++++ src/sendEmail.ts | 24 +-- 6 files changed, 523 insertions(+), 54 deletions(-) create mode 100644 src/payload-types.ts diff --git a/dev/payload-types.ts b/dev/payload-types.ts index 17a1f13..6c034a3 100644 --- a/dev/payload-types.ts +++ b/dev/payload-types.ts @@ -100,7 +100,8 @@ export interface Config { }; jobs: { tasks: { - 'process-email-queue': ProcessEmailQueueJob; + processEmails: ProcessEmailsJob; + 'send-email': TaskSendEmail; inline: { input: unknown; output: unknown; @@ -232,21 +233,17 @@ export interface Email { */ template?: (string | null) | EmailTemplate; /** - * Template slug used for this email + * Recipient email addresses */ - templateSlug?: string | null; + to: string[]; /** - * Recipient email address(es), comma-separated + * CC email addresses */ - to: string; + cc?: string[] | null; /** - * CC email address(es), comma-separated + * BCC email addresses */ - cc?: string | null; - /** - * BCC email address(es), comma-separated - */ - bcc?: string | null; + bcc?: string[] | null; /** * Sender email address (optional, uses default if not provided) */ @@ -362,7 +359,7 @@ export interface PayloadJob { | { executedAt: string; completedAt: string; - taskSlug: 'inline' | 'process-email-queue'; + taskSlug: 'inline' | 'processEmails' | 'send-email'; taskID: string; input?: | { @@ -395,7 +392,7 @@ export interface PayloadJob { id?: string | null; }[] | null; - taskSlug?: ('inline' | 'process-email-queue') | null; + taskSlug?: ('inline' | 'processEmails' | 'send-email') | null; queue?: string | null; waitUntil?: string | null; processing?: boolean | null; @@ -542,7 +539,6 @@ export interface EmailTemplatesSelect { */ export interface EmailsSelect { template?: T; - templateSlug?: T; to?: T; cc?: T; bcc?: T; @@ -627,12 +623,69 @@ export interface PayloadMigrationsSelect { } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "ProcessEmailQueueJob". + * via the `definition` "ProcessEmailsJob". */ -export interface ProcessEmailQueueJob { +export interface ProcessEmailsJob { input?: unknown; output?: unknown; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "TaskSend-email". + */ +export interface TaskSendEmail { + input: { + /** + * Use a template (leave empty for direct email) + */ + templateSlug?: string | null; + /** + * JSON object with variables for template rendering + */ + variables?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + /** + * Email subject (required if not using template) + */ + subject?: string | null; + /** + * HTML email content (required if not using template) + */ + html?: string | null; + /** + * Plain text email content (optional) + */ + text?: string | null; + /** + * Comma-separated list of email addresses + */ + to: string; + /** + * Optional comma-separated list of CC email addresses + */ + cc?: string | null; + /** + * Optional comma-separated list of BCC email addresses + */ + bcc?: string | null; + /** + * Optional date/time to schedule email for future delivery + */ + scheduledAt?: string | null; + /** + * Email priority (1 = highest, 10 = lowest) + */ + priority?: number | null; + }; + 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 8e96282..a01890d 100644 --- a/dev/payload.config.ts +++ b/dev/payload.config.ts @@ -1,12 +1,10 @@ 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' @@ -17,7 +15,7 @@ import { fileURLToPath } from 'url' import { testEmailAdapter } from './helpers/testEmailAdapter.js' import { seed, seedUser } from './seed.js' import mailingPlugin from "../src/plugin.js" -import { sendEmail } from "../src/utils/helpers.js" +import {sendEmail} from "@xtr-dev/payload-mailing" const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -85,15 +83,19 @@ const buildConfigWithMemoryDB = async () => { // 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', + template: { + slug: 'welcome-email', + variables: { + firstName: doc.firstName || doc.email?.split('@')?.[0], + siteName: 'PayloadCMS Mailing Demo', + createdAt: new Date().toISOString(), + isPremium: false, + dashboardUrl: 'http://localhost:3000/admin', + }, }, + data: { + to: doc.email, + } }) console.log('✅ Welcome email queued successfully. Email ID:', emailId) diff --git a/dev/tsconfig.json b/dev/tsconfig.json index 4f53f0d..549da40 100644 --- a/dev/tsconfig.json +++ b/dev/tsconfig.json @@ -19,13 +19,13 @@ "@payload-config": [ "./payload.config.ts" ], - "temp-project": [ + "@xtr-dev/payload-mailing": [ "../src/index.ts" ], - "temp-project/client": [ + "@xtr-dev/payload-mailing/client": [ "../src/exports/client.ts" ], - "temp-project/rsc": [ + "@xtr-dev/payload-mailing/rsc": [ "../src/exports/rsc.ts" ] }, diff --git a/package.json b/package.json index 0910a4e..fabfbc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-mailing", - "version": "0.1.7", + "version": "0.1.8", "description": "Template-based email system with scheduling and job processing for PayloadCMS", "type": "module", "main": "dist/index.js", @@ -23,8 +23,9 @@ "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", - "generate:importmap": "npm run dev:generate-importmap", - "generate:types": "npm run dev:generate-types", + "payload": "cross-env NODE_OPTIONS=--no-deprecation payload", + "generate:importmap": "npm run payload generate:importmap", + "generate:types": "npm run payload generate:types", "lint": "eslint", "lint:fix": "eslint ./src --fix", "prepublishOnly": "npm run clean && npm run build", diff --git a/src/payload-types.ts b/src/payload-types.ts new file mode 100644 index 0000000..08838c9 --- /dev/null +++ b/src/payload-types.ts @@ -0,0 +1,431 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * 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: { + emails: Email; + 'email-templates': EmailTemplate; + users: User; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + emails: EmailsSelect | EmailsSelect; + 'email-templates': EmailTemplatesSelect | EmailTemplatesSelect; + users: UsersSelect | UsersSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + db: { + defaultIDType: number; + }; + globals: {}; + globalsSelect: {}; + locale: null; + user: User & { + collection: 'users'; + }; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * Email delivery and status tracking + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "emails". + */ +export interface Email { + id: number; + /** + * Email template used (optional if custom content provided) + */ + template?: (number | null) | EmailTemplate; + /** + * Recipient email addresses + */ + to: string[]; + /** + * CC email addresses + */ + cc?: string[] | null; + /** + * BCC email addresses + */ + 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` "email-templates". + */ +export interface EmailTemplate { + id: number; + /** + * 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; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: number; + 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` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: number; + document?: + | ({ + relationTo: 'emails'; + value: number | Email; + } | null) + | ({ + relationTo: 'email-templates'; + value: number | EmailTemplate; + } | null) + | ({ + relationTo: 'users'; + value: number | User; + } | null); + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: number | User; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: number; + user: { + relationTo: 'users'; + value: number | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: number; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "emails_select". + */ +export interface EmailsSelect { + template?: 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` "email-templates_select". + */ +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". + */ +export interface UsersSelect { + 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` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/src/sendEmail.ts b/src/sendEmail.ts index ce360a7..8aa12ce 100644 --- a/src/sendEmail.ts +++ b/src/sendEmail.ts @@ -1,22 +1,9 @@ import { Payload } from 'payload' import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js' - -// Base type for email data that all emails must have -// Compatible with PayloadCMS generated types that include null -export interface BaseEmailData { - to: string | string[] - cc?: string | string[] | null - bcc?: string | string[] | null - subject?: string | null - html?: string | null - text?: string | null - scheduledAt?: string | Date | null - priority?: number | null - [key: string]: any -} +import {Email} from "./payload-types.js" // Options for sending emails -export interface SendEmailOptions { +export interface SendEmailOptions { // Template-based email template?: { slug: string @@ -48,7 +35,7 @@ export interface SendEmailOptions { * }) * ``` */ -export const sendEmail = async ( +export const sendEmail = async ( payload: Payload, options: SendEmailOptions ): Promise => { @@ -94,11 +81,6 @@ export const sendEmail = async ( emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[]) } - // Convert scheduledAt to ISO string if it's a Date - if (emailData.scheduledAt instanceof Date) { - emailData.scheduledAt = emailData.scheduledAt.toISOString() - } - // Create the email in the collection const email = await payload.create({ collection: collectionSlug as any,