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/payload.config.ts b/payload.config.ts new file mode 100644 index 0000000..8753c9f --- /dev/null +++ b/payload.config.ts @@ -0,0 +1,31 @@ +/** + * This config is only used to generate types. + */ + +import { BaseDatabaseAdapter, buildConfig, Payload} from 'payload' +import Emails from "./src/collections/Emails.js" +import {createEmailTemplatesCollection} from "./src/collections/EmailTemplates.js" +import path from "path" +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export default buildConfig({ + collections: [ + Emails, + createEmailTemplatesCollection() + ], + db: { + allowIDOnCreate: undefined, + defaultIDType: 'number', + init: function (args: { payload: Payload; }): BaseDatabaseAdapter { + throw new Error('Function not implemented.'); + }, + name: undefined + }, + secret: '', + typescript: { + outputFile: path.resolve(__dirname, 'src/payload-types.ts'), + } +}); diff --git a/src/index.ts b/src/index.ts index 3606914..bb6cd5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ export { mailingJobs, sendEmailJob } from './jobs/index.js' export type { SendEmailTaskInput } from './jobs/sendEmailTask.js' // Main email sending function -export { sendEmail, type BaseEmailData, type SendEmailOptions } from './sendEmail.js' +export { sendEmail, type SendEmailOptions } from './sendEmail.js' export { default as sendEmailDefault } from './sendEmail.js' // Utility functions for developers diff --git a/src/jobs/sendEmailTask.ts b/src/jobs/sendEmailTask.ts index 474867a..55cd15c 100644 --- a/src/jobs/sendEmailTask.ts +++ b/src/jobs/sendEmailTask.ts @@ -1,4 +1,5 @@ -import { sendEmail, type BaseEmailData } from '../sendEmail.js' +import { sendEmail } from '../sendEmail.js' +import { Email } from '../payload-types.js' export interface SendEmailTaskInput { // Template mode fields @@ -153,7 +154,7 @@ export const sendEmailJob = { }) // Use the sendEmail helper to create the email - const email = await sendEmail(payload, sendEmailOptions) + const email = await sendEmail(payload, sendEmailOptions) return { output: { 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..aeb28bd 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 => { @@ -79,8 +66,17 @@ export const sendEmail = async ( throw new Error('Field "to" is required for sending emails') } - if (!emailData.subject || !emailData.html) { - throw new Error('Fields "subject" and "html" are required when not using a template') + // Validate required fields based on whether template was used + if (options.template) { + // When using template, subject and html should have been set by renderTemplate + if (!emailData.subject || !emailData.html) { + throw new Error(`Template rendering failed: template "${options.template.slug}" did not provide required subject and html content`) + } + } else { + // When not using template, user must provide subject and html directly + if (!emailData.subject || !emailData.html) { + throw new Error('Fields "subject" and "html" are required when sending direct emails without a template') + } } // Process email addresses using shared validation (handle null values) @@ -94,18 +90,13 @@ 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 + // Create the email in the collection with proper typing const email = await payload.create({ - collection: collectionSlug as any, - data: emailData as any + collection: collectionSlug, + data: emailData }) - return email as unknown as T + return email as T } export default sendEmail diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 4c6b005..2c0f192 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -15,9 +15,20 @@ export const parseAndValidateEmails = (emails: string | string[] | null | undefi emailList = emails.split(',').map(email => email.trim()).filter(Boolean) } - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - const invalidEmails = emailList.filter(email => !emailRegex.test(email)) + // RFC 5322 compliant email validation + const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + const invalidEmails = emailList.filter(email => { + // Check basic format + if (!emailRegex.test(email)) return true + // Check for common invalid patterns + if (email.includes('..') || email.startsWith('.') || email.endsWith('.')) return true + if (email.includes('@.') || email.includes('.@')) return true + // Check domain has at least one dot + const parts = email.split('@') + if (parts.length !== 2 || !parts[1].includes('.')) return true + return false + }) + if (invalidEmails.length > 0) { throw new Error(`Invalid email addresses: ${invalidEmails.join(', ')}`) }