Merge pull request #24 from xtr-dev/dev

Dev
This commit is contained in:
Bas
2025-09-13 22:00:51 +02:00
committed by GitHub
10 changed files with 587 additions and 66 deletions

View File

@@ -100,7 +100,8 @@ export interface Config {
}; };
jobs: { jobs: {
tasks: { tasks: {
'process-email-queue': ProcessEmailQueueJob; processEmails: ProcessEmailsJob;
'send-email': TaskSendEmail;
inline: { inline: {
input: unknown; input: unknown;
output: unknown; output: unknown;
@@ -232,21 +233,17 @@ export interface Email {
*/ */
template?: (string | null) | EmailTemplate; 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?: string[] | null;
/**
* BCC email address(es), comma-separated
*/
bcc?: string | null;
/** /**
* Sender email address (optional, uses default if not provided) * Sender email address (optional, uses default if not provided)
*/ */
@@ -362,7 +359,7 @@ export interface PayloadJob {
| { | {
executedAt: string; executedAt: string;
completedAt: string; completedAt: string;
taskSlug: 'inline' | 'process-email-queue'; taskSlug: 'inline' | 'processEmails' | 'send-email';
taskID: string; taskID: string;
input?: input?:
| { | {
@@ -395,7 +392,7 @@ export interface PayloadJob {
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
taskSlug?: ('inline' | 'process-email-queue') | null; taskSlug?: ('inline' | 'processEmails' | 'send-email') | null;
queue?: string | null; queue?: string | null;
waitUntil?: string | null; waitUntil?: string | null;
processing?: boolean | null; processing?: boolean | null;
@@ -542,7 +539,6 @@ export interface EmailTemplatesSelect<T extends boolean = true> {
*/ */
export interface EmailsSelect<T extends boolean = true> { export interface EmailsSelect<T extends boolean = true> {
template?: T; template?: T;
templateSlug?: T;
to?: T; to?: T;
cc?: T; cc?: T;
bcc?: T; bcc?: T;
@@ -627,12 +623,69 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
} }
/** /**
* This interface was referenced by `Config`'s JSON-Schema * 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; input?: unknown;
output?: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth". * via the `definition` "auth".

View File

@@ -1,12 +1,10 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb' import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical' import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { import {
BlocksFeature,
FixedToolbarFeature, FixedToolbarFeature,
HeadingFeature, HeadingFeature,
HorizontalRuleFeature, HorizontalRuleFeature,
InlineToolbarFeature, InlineToolbarFeature,
lexicalHTML,
} from '@payloadcms/richtext-lexical' } from '@payloadcms/richtext-lexical'
import { MongoMemoryReplSet } from 'mongodb-memory-server' import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path' import path from 'path'
@@ -17,7 +15,7 @@ import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter.js' import { testEmailAdapter } from './helpers/testEmailAdapter.js'
import { seed, seedUser } from './seed.js' import { seed, seedUser } from './seed.js'
import mailingPlugin from "../src/plugin.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 filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
@@ -85,15 +83,19 @@ const buildConfigWithMemoryDB = async () => {
// Queue the welcome email using template slug // Queue the welcome email using template slug
const emailId = await sendEmail(req.payload, { const emailId = await sendEmail(req.payload, {
templateSlug: 'welcome-email', template: {
to: doc.email, slug: 'welcome-email',
variables: { variables: {
firstName: doc.firstName || doc.email?.split('@')?.[0], firstName: doc.firstName || doc.email?.split('@')?.[0],
siteName: 'PayloadCMS Mailing Demo', siteName: 'PayloadCMS Mailing Demo',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
isPremium: false, isPremium: false,
dashboardUrl: 'http://localhost:3000/admin', dashboardUrl: 'http://localhost:3000/admin',
},
}, },
data: {
to: doc.email,
}
}) })
console.log('✅ Welcome email queued successfully. Email ID:', emailId) console.log('✅ Welcome email queued successfully. Email ID:', emailId)

View File

@@ -19,13 +19,13 @@
"@payload-config": [ "@payload-config": [
"./payload.config.ts" "./payload.config.ts"
], ],
"temp-project": [ "@xtr-dev/payload-mailing": [
"../src/index.ts" "../src/index.ts"
], ],
"temp-project/client": [ "@xtr-dev/payload-mailing/client": [
"../src/exports/client.ts" "../src/exports/client.ts"
], ],
"temp-project/rsc": [ "@xtr-dev/payload-mailing/rsc": [
"../src/exports/rsc.ts" "../src/exports/rsc.ts"
] ]
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-mailing", "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", "description": "Template-based email system with scheduling and job processing for PayloadCMS",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -23,8 +23,9 @@
"dev:generate-importmap": "npm run dev:payload generate:importmap", "dev:generate-importmap": "npm run dev:payload generate:importmap",
"dev:generate-types": "npm run dev:payload generate:types", "dev:generate-types": "npm run dev:payload generate:types",
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload", "dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
"generate:importmap": "npm run dev:generate-importmap", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"generate:types": "npm run dev:generate-types", "generate:importmap": "npm run payload generate:importmap",
"generate:types": "npm run payload generate:types",
"lint": "eslint", "lint": "eslint",
"lint:fix": "eslint ./src --fix", "lint:fix": "eslint ./src --fix",
"prepublishOnly": "npm run clean && npm run build", "prepublishOnly": "npm run clean && npm run build",

31
payload.config.ts Normal file
View File

@@ -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'),
}
});

View File

@@ -16,7 +16,7 @@ export { mailingJobs, sendEmailJob } from './jobs/index.js'
export type { SendEmailTaskInput } from './jobs/sendEmailTask.js' export type { SendEmailTaskInput } from './jobs/sendEmailTask.js'
// Main email sending function // 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' export { default as sendEmailDefault } from './sendEmail.js'
// Utility functions for developers // Utility functions for developers

View File

@@ -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 { export interface SendEmailTaskInput {
// Template mode fields // Template mode fields
@@ -153,7 +154,7 @@ export const sendEmailJob = {
}) })
// Use the sendEmail helper to create the email // Use the sendEmail helper to create the email
const email = await sendEmail<BaseEmailData>(payload, sendEmailOptions) const email = await sendEmail<Email>(payload, sendEmailOptions)
return { return {
output: { output: {

431
src/payload-types.ts Normal file
View File

@@ -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<false> | EmailsSelect<true>;
'email-templates': EmailTemplatesSelect<false> | EmailTemplatesSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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<T extends boolean = true> {
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 {}
}

View File

@@ -1,22 +1,9 @@
import { Payload } from 'payload' import { Payload } from 'payload'
import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js' import { getMailing, renderTemplate, parseAndValidateEmails } from './utils/helpers.js'
import {Email} from "./payload-types.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
}
// Options for sending emails // Options for sending emails
export interface SendEmailOptions<T extends BaseEmailData = BaseEmailData> { export interface SendEmailOptions<T extends Email = Email> {
// Template-based email // Template-based email
template?: { template?: {
slug: string slug: string
@@ -48,7 +35,7 @@ export interface SendEmailOptions<T extends BaseEmailData = BaseEmailData> {
* }) * })
* ``` * ```
*/ */
export const sendEmail = async <T extends BaseEmailData = BaseEmailData>( export const sendEmail = async <T extends Email = Email>(
payload: Payload, payload: Payload,
options: SendEmailOptions<T> options: SendEmailOptions<T>
): Promise<T> => { ): Promise<T> => {
@@ -79,8 +66,17 @@ export const sendEmail = async <T extends BaseEmailData = BaseEmailData>(
throw new Error('Field "to" is required for sending emails') throw new Error('Field "to" is required for sending emails')
} }
if (!emailData.subject || !emailData.html) { // Validate required fields based on whether template was used
throw new Error('Fields "subject" and "html" are required when not using a template') 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) // Process email addresses using shared validation (handle null values)
@@ -94,18 +90,13 @@ export const sendEmail = async <T extends BaseEmailData = BaseEmailData>(
emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[]) emailData.bcc = parseAndValidateEmails(emailData.bcc as string | string[])
} }
// Convert scheduledAt to ISO string if it's a Date // Create the email in the collection with proper typing
if (emailData.scheduledAt instanceof Date) {
emailData.scheduledAt = emailData.scheduledAt.toISOString()
}
// Create the email in the collection
const email = await payload.create({ const email = await payload.create({
collection: collectionSlug as any, collection: collectionSlug,
data: emailData as any data: emailData
}) })
return email as unknown as T return email as T
} }
export default sendEmail export default sendEmail

View File

@@ -15,9 +15,20 @@ export const parseAndValidateEmails = (emails: string | string[] | null | undefi
emailList = emails.split(',').map(email => email.trim()).filter(Boolean) emailList = emails.split(',').map(email => email.trim()).filter(Boolean)
} }
// Basic email validation // RFC 5322 compliant email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ 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 => !emailRegex.test(email)) 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) { if (invalidEmails.length > 0) {
throw new Error(`Invalid email addresses: ${invalidEmails.join(', ')}`) throw new Error(`Invalid email addresses: ${invalidEmails.join(', ')}`)
} }