diff --git a/apps/api/src/__tests__/integration/actions.test.ts b/apps/api/src/__tests__/integration/actions.test.ts
index 921e9b6..b627c65 100644
--- a/apps/api/src/__tests__/integration/actions.test.ts
+++ b/apps/api/src/__tests__/integration/actions.test.ts
@@ -94,7 +94,7 @@ describe('Actions API Integration Tests', () => {
});
it('should validate required fields for /v1/track', () => {
-
+
const result = ActionSchemas.track.safeParse({
email: 'test@example.com',
@@ -106,6 +106,164 @@ describe('Actions API Integration Tests', () => {
expect(result.error.errors.some(e => e.path.includes('event'))).toBe(true);
}
});
+
+ it('should accept from as string (backward compatible)', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: 'test@example.com',
+ subject: 'Test',
+ body: 'Test',
+ from: 'sender@example.com',
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should accept from as object with name and email', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: 'test@example.com',
+ subject: 'Test',
+ body: 'Test',
+ from: {
+ name: 'John Doe',
+ email: 'sender@example.com',
+ },
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should accept from as object with only email', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: 'test@example.com',
+ subject: 'Test',
+ body: 'Test',
+ from: {
+ email: 'sender@example.com',
+ },
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should reject from object with invalid email', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: 'test@example.com',
+ subject: 'Test',
+ body: 'Test',
+ from: {
+ name: 'John Doe',
+ email: 'not-an-email',
+ },
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ it('should reject from object missing email field', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: 'test@example.com',
+ subject: 'Test',
+ body: 'Test',
+ from: {
+ name: 'John Doe',
+ },
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ it('should accept to as string (backward compatible)', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: 'test@example.com',
+ subject: 'Test',
+ body: 'Test',
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should accept to as object with name and email', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: {
+ name: 'Jane Doe',
+ email: 'test@example.com',
+ },
+ subject: 'Test',
+ body: 'Test',
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should accept to as object with only email', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: {
+ email: 'test@example.com',
+ },
+ subject: 'Test',
+ body: 'Test',
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should accept to as array of strings', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: ['test1@example.com', 'test2@example.com'],
+ subject: 'Test',
+ body: 'Test',
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should accept to as array of objects with name and email', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: [
+ {name: 'Jane Doe', email: 'test1@example.com'},
+ {name: 'John Smith', email: 'test2@example.com'},
+ ],
+ subject: 'Test',
+ body: 'Test',
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should accept to as mixed array of strings and objects', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: ['test1@example.com', {name: 'John Smith', email: 'test2@example.com'}],
+ subject: 'Test',
+ body: 'Test',
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it('should reject to object with invalid email', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: {
+ name: 'Jane Doe',
+ email: 'not-an-email',
+ },
+ subject: 'Test',
+ body: 'Test',
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ it('should reject to object missing email field', () => {
+ const result = ActionSchemas.send.safeParse({
+ to: {
+ name: 'Jane Doe',
+ },
+ subject: 'Test',
+ body: 'Test',
+ });
+
+ expect(result.success).toBe(false);
+ });
});
// ========================================
diff --git a/apps/api/src/controllers/Actions.ts b/apps/api/src/controllers/Actions.ts
index bce0e80..55d5ea2 100644
--- a/apps/api/src/controllers/Actions.ts
+++ b/apps/api/src/controllers/Actions.ts
@@ -87,32 +87,68 @@ export class Actions {
* Send transactional email(s)
*
* Request body:
- * - to: string | string[] (required) - Recipient email(s)
+ * - to: string | object | array (required) - Recipient email(s)
+ * - String: "user@example.com"
+ * - Object: {name: "Jane Doe", email: "user@example.com"}
+ * - Array: ["user1@example.com", {name: "Jane", email: "user2@example.com"}]
* - subject: string (required) - Email subject
* - body: string (required) - Email HTML body
* - subscribed: boolean (optional, default: false) - Contact subscription status
- * - name: string (optional) - Sender name
- * - from: string (optional) - Sender email (must be from verified domain)
+ * - name: string (optional) - Sender name (alternative to from.name)
+ * - from: string | object (optional) - Sender email or {name, email} object (must be from verified domain)
* - reply: string (optional) - Reply-to email
* - headers: object (optional) - Additional email headers
* - data: object (optional) - Contact data and template variables
* - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only used for this email (non-persistent)
+ * - attachments: array (optional) - Email attachments (max 10, 10MB total)
+ * - filename: string (required) - Attachment filename
+ * - content: string (required) - Base64 encoded file content
+ * - contentType: string (required) - MIME type (e.g., "application/pdf")
*
* Response:
* - success: boolean
* - data: object with emails array and timestamp
*
- * Example:
+ * Examples:
+ *
+ * Simple format (backward compatible):
* {
* to: "user@example.com",
* subject: "Password Reset",
* body: "
Reset code: {{resetCode}}
Hello {{firstName}}!
",
+ * from: "noreply@example.com",
+ * name: "My App",
* data: {
* firstName: "John", // Persistent - saved to contact
* resetCode: {value: "ABC123", persistent: false} // Non-persistent - this email only
* }
* }
+ *
+ * Object format (recommended):
+ * {
+ * to: {
+ * name: "Jane Doe",
+ * email: "user@example.com"
+ * },
+ * subject: "Password Reset",
+ * body: "Reset code: {{resetCode}}
",
+ * from: {
+ * name: "My App",
+ * email: "noreply@example.com"
+ * }
+ * }
+ *
+ * Multiple recipients with names:
+ * {
+ * to: [
+ * {name: "Jane Doe", email: "jane@example.com"},
+ * {name: "John Smith", email: "john@example.com"}
+ * ],
+ * subject: "Newsletter",
+ * body: "Hello {{name}}!
",
+ * from: {name: "Newsletter", email: "news@example.com"}
+ * }
*/
@Post('send')
@Middleware([requireSecretKey])
@@ -123,13 +159,36 @@ export class Actions {
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
ActionSchemas.send.parse(req.body);
- // Normalize recipients to array
- const recipients = Array.isArray(to) ? to : [to];
+ // Normalize recipients to array and parse email/name
+ type Recipient = {email: string; name?: string};
+ const recipients: Recipient[] = (Array.isArray(to) ? to : [to]).map(recipient => {
+ if (typeof recipient === 'string') {
+ return {email: recipient};
+ } else {
+ return {email: recipient.email, name: recipient.name};
+ }
+ });
+
+ // Parse 'from' field - can be string or object {name, email}
+ let emailFrom: string | undefined;
+ let emailFromName: string | undefined;
+
+ if (typeof from === 'string') {
+ // Backward compatible: from is just an email string
+ emailFrom = from;
+ emailFromName = name; // Use separate 'name' field if provided
+ } else if (from && typeof from === 'object') {
+ // New format: from is an object with {name, email}
+ emailFrom = from.email;
+ emailFromName = from.name || name; // Prefer from.name, fallback to separate 'name' field
+ } else {
+ // No 'from' provided
+ emailFromName = name;
+ }
+
// Fetch template if provided
let emailSubject = subject;
let emailBody = body;
- let emailFrom = from;
- let emailFromName = name;
let emailReplyTo = reply;
let templateId: string | undefined;
@@ -148,8 +207,15 @@ export class Actions {
// Use template values, allow overrides from request
emailSubject = subject || templateRecord.subject;
emailBody = body || templateRecord.body;
- emailFrom = from || templateRecord.from;
- emailFromName = name || templateRecord.fromName || undefined;
+
+ // Handle from field - if not already set and template has a from, use it
+ if (!emailFrom && templateRecord.from) {
+ emailFrom = templateRecord.from;
+ }
+ if (!emailFromName && templateRecord.fromName) {
+ emailFromName = templateRecord.fromName;
+ }
+
emailReplyTo = reply || templateRecord.replyTo || undefined;
templateId = templateRecord.id;
}
@@ -168,12 +234,17 @@ export class Actions {
const emailResults = [];
// Process each recipient
- for (const recipientEmail of recipients) {
+ for (const recipient of recipients) {
+ // Merge recipient name with data if provided
+ const recipientData = recipient.name
+ ? {...(data as Record | undefined), name: recipient.name}
+ : (data as Record | undefined);
+
// Create or update contact with metadata
const contact = await ContactService.upsert(
auth.projectId,
- recipientEmail,
- data as Record | undefined,
+ recipient.email,
+ recipientData,
subscribed,
);
@@ -214,6 +285,7 @@ export class Actions {
body: renderedBody,
from: senderEmail,
fromName: emailFromName,
+ toName: recipient.name,
replyTo: replyToEmail,
headers: headers || undefined,
attachments: attachments || undefined,
diff --git a/apps/api/src/jobs/email-processor.ts b/apps/api/src/jobs/email-processor.ts
index b809778..6dfccea 100644
--- a/apps/api/src/jobs/email-processor.ts
+++ b/apps/api/src/jobs/email-processor.ts
@@ -74,10 +74,15 @@ export function createEmailWorker() {
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
});
- // Parse from email (format: "Name " or just "email@domain.com")
- const fromMatch = /(.*?)<(.+?)>/.exec(email.from) || [null, email.from, email.from];
- const fromName = fromMatch[1]?.trim() || email.project.name;
- const fromEmail = fromMatch[2]?.trim() || email.from;
+ // Use fromName from database if available, otherwise fall back to project name
+ // The 'from' field in the database is just the email address
+ const fromName = email.fromName || email.project.name;
+ const fromEmail = email.from;
+
+ // Build recipient with name if available
+ const recipient: {name?: string; email: string} | string = email.toName
+ ? {name: email.toName, email: email.contact.email}
+ : email.contact.email;
// Send via AWS SES
const result = await sendRawEmail({
@@ -85,13 +90,14 @@ export function createEmailWorker() {
name: fromName,
email: fromEmail,
},
- to: [email.contact.email],
+ to: typeof recipient === 'string' ? [recipient] : [{name: recipient.name, email: recipient.email}],
content: {
subject: formattedEmail.subject,
html: compiledHtml,
},
reply: email.replyTo || undefined,
tracking: email.project.trackingEnabled, // Use project's tracking preference
+ attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
});
// Mark as sent with SES message ID
diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts
index 4b7b1ec..58d384b 100644
--- a/apps/api/src/services/EmailService.ts
+++ b/apps/api/src/services/EmailService.ts
@@ -23,6 +23,7 @@ interface SendEmailParams {
body: string;
from: string;
fromName?: string;
+ toName?: string;
replyTo?: string;
headers?: Record;
attachments?: Attachment[];
@@ -85,6 +86,7 @@ export class EmailService {
body: params.body,
from: params.from,
fromName: params.fromName,
+ toName: params.toName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
diff --git a/apps/api/src/services/SESService.ts b/apps/api/src/services/SESService.ts
index 6a6a551..281504b 100644
--- a/apps/api/src/services/SESService.ts
+++ b/apps/api/src/services/SESService.ts
@@ -6,7 +6,7 @@ import {
AWS_SES_SECRET_ACCESS_KEY,
DASHBOARD_URI,
SES_CONFIGURATION_SET,
- SES_CONFIGURATION_SET_NO_TRACKING,
+ SES_CONFIGURATION_SET_NO_TRACKING
} from '../app/constants.js';
/**
@@ -26,7 +26,7 @@ interface SendRawEmailParams {
name: string;
email: string;
};
- to: string[];
+ to: string[] | {name?: string; email: string}[];
content: {
subject: string;
html: string;
@@ -102,9 +102,23 @@ export async function sendRawEmail({
const boundary = `----=_NextPart_${Math.random().toString(36).substring(2)}`;
const mixedBoundary = attachments?.length ? `----=_MixedPart_${Math.random().toString(36).substring(2)}` : null;
+ // Format To header with names if provided
+ const toHeader = to
+ .map(recipient => {
+ if (typeof recipient === 'string') {
+ return recipient;
+ } else {
+ return recipient.name ? `${recipient.name} <${recipient.email}>` : recipient.email;
+ }
+ })
+ .join(', ');
+
+ // Extract just email addresses for Destinations (SES requirement)
+ const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
+
// Build raw MIME message
const rawMessage = `From: ${from.name} <${from.email}>
-To: ${to.join(', ')}
+To: ${toHeader}
Reply-To: ${reply || from.email}
Subject: ${content.subject}
MIME-Version: 1.0
@@ -131,17 +145,16 @@ Content-Transfer-Encoding: 7bit
${breakLongLines(content.html, 500)}
--${boundary}--
${
- attachments?.length
- ? attachments
+ attachments && attachments.length > 0
+ ? '\n' +
+ attachments
.map(
- attachment => `
---${mixedBoundary}
+ attachment => `--${mixedBoundary}
Content-Type: ${attachment.contentType}
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="${attachment.filename}"
-${breakLongLines(attachment.content, 76, true)}
-`,
+${breakLongLines(attachment.content, 76, true)}`,
)
.join('\n')
: ''
@@ -149,7 +162,7 @@ ${breakLongLines(attachment.content, 76, true)}
// Send via SES
const response = await ses.sendRawEmail({
- Destinations: to,
+ Destinations: destinations,
ConfigurationSetName: tracking ? SES_CONFIGURATION_SET : SES_CONFIGURATION_SET_NO_TRACKING,
RawMessage: {
Data: new TextEncoder().encode(rawMessage),
diff --git a/apps/smtp/src/server.ts b/apps/smtp/src/server.ts
index da0d296..46b6d75 100644
--- a/apps/smtp/src/server.ts
+++ b/apps/smtp/src/server.ts
@@ -285,7 +285,43 @@ function handleData(
const fromAddress = parsed.from?.value[0]?.address ?? session.address;
const fromName = parsed.from?.value[0]?.name;
- const recipients = session.envelope.rcptTo.map((to: SMTPServerAddress) => to.address);
+
+ // Build from object with name if available
+ const from = fromName ? {name: fromName, email: fromAddress} : fromAddress;
+
+ // Parse recipients with names from To/CC headers
+ // Build a map of email -> name from the parsed headers
+ const recipientNameMap = new Map();
+
+ // Helper to extract addresses from AddressObject
+ const extractAddresses = (addressObj: any) => {
+ if (!addressObj) return [];
+ // Handle both single AddressObject and array
+ const addresses = Array.isArray(addressObj) ? addressObj : [addressObj];
+ return addresses.flatMap((obj: any) => obj.value || []);
+ };
+
+ if (parsed.to) {
+ for (const addr of extractAddresses(parsed.to)) {
+ if (addr.address && addr.name) {
+ recipientNameMap.set(addr.address.toLowerCase(), addr.name);
+ }
+ }
+ }
+
+ if (parsed.cc) {
+ for (const addr of extractAddresses(parsed.cc)) {
+ if (addr.address && addr.name) {
+ recipientNameMap.set(addr.address.toLowerCase(), addr.name);
+ }
+ }
+ }
+
+ // Map SMTP recipients to objects with names when available
+ const recipients = session.envelope.rcptTo.map((to: SMTPServerAddress) => {
+ const name = recipientNameMap.get(to.address.toLowerCase());
+ return name ? {name, email: to.address} : to.address;
+ });
// Parse attachments
const attachments = parsed.attachments
@@ -360,8 +396,7 @@ function handleData(
'Authorization': `Bearer ${session.user}`,
},
body: JSON.stringify({
- from: fromAddress,
- name: fromName,
+ from: from,
to: recipients,
subject: parsed.subject,
body: bodyContent,
@@ -376,7 +411,15 @@ function handleData(
return callback(new Error(`Failed to send email: ${response.statusText}`));
}
- signale.success(`Email relayed: ${fromAddress} → ${recipients.join(', ')}`);
+ // Build log message showing recipients (with names if available)
+ const recipientList = session.envelope.rcptTo
+ .map((to: SMTPServerAddress) => {
+ const name = recipientNameMap.get(to.address.toLowerCase());
+ return name ? `${name} <${to.address}>` : to.address;
+ })
+ .join(', ');
+
+ signale.success(`Email relayed: ${fromName ? `${fromName} <${fromAddress}>` : fromAddress} → ${recipientList}`);
callback();
} catch (error) {
signale.error('Relay error:', error);
diff --git a/apps/wiki/next-env.d.ts b/apps/wiki/next-env.d.ts
index 9edff1c..c4b7818 100644
--- a/apps/wiki/next-env.d.ts
+++ b/apps/wiki/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/wiki/openapi.json b/apps/wiki/openapi.json
index e481c89..82ee4c9 100644
--- a/apps/wiki/openapi.json
+++ b/apps/wiki/openapi.json
@@ -192,17 +192,54 @@
"oneOf": [
{
"type": "string",
- "format": "email"
+ "format": "email",
+ "description": "Simple email address"
+ },
+ {
+ "type": "object",
+ "required": ["email"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Recipient display name"
+ },
+ "email": {
+ "type": "string",
+ "format": "email",
+ "description": "Recipient email address"
+ }
+ },
+ "description": "Recipient with name and email"
},
{
"type": "array",
"items": {
- "type": "string",
- "format": "email"
- }
+ "oneOf": [
+ {
+ "type": "string",
+ "format": "email"
+ },
+ {
+ "type": "object",
+ "required": ["email"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Recipient display name"
+ },
+ "email": {
+ "type": "string",
+ "format": "email",
+ "description": "Recipient email address"
+ }
+ }
+ }
+ ]
+ },
+ "description": "Array of recipients (strings or objects)"
}
],
- "description": "Recipient email(s)"
+ "description": "Recipient email(s). Can be a string, an object with {name, email}, or an array of either."
},
"subject": {
"type": "string",
@@ -217,9 +254,34 @@
"description": "Template identifier to use"
},
"from": {
+ "oneOf": [
+ {
+ "type": "string",
+ "format": "email",
+ "description": "Simple email address"
+ },
+ {
+ "type": "object",
+ "required": ["email"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Sender display name"
+ },
+ "email": {
+ "type": "string",
+ "format": "email",
+ "description": "Sender email address"
+ }
+ },
+ "description": "Sender with name and email"
+ }
+ ],
+ "description": "Custom from address (requires verified domain). Can be a string or an object with {name, email}."
+ },
+ "name": {
"type": "string",
- "format": "email",
- "description": "Custom from address (requires verified domain)"
+ "description": "Sender display name (alternative to using from.name)"
},
"subscribed": {
"type": "boolean",
@@ -282,6 +344,45 @@
}
}
},
+ "withNames": {
+ "summary": "Email with recipient and sender names",
+ "value": {
+ "to": {
+ "name": "Jane Doe",
+ "email": "jane@example.com"
+ },
+ "from": {
+ "name": "My Company",
+ "email": "hello@mycompany.com"
+ },
+ "subject": "Welcome to Our Service",
+ "body": "Welcome {{name}}!
We're glad to have you.
",
+ "data": {
+ "name": "Jane"
+ }
+ }
+ },
+ "multipleRecipients": {
+ "summary": "Multiple recipients with names",
+ "value": {
+ "to": [
+ {
+ "name": "Jane Doe",
+ "email": "jane@example.com"
+ },
+ {
+ "name": "John Smith",
+ "email": "john@example.com"
+ }
+ ],
+ "from": {
+ "name": "Newsletter",
+ "email": "news@mycompany.com"
+ },
+ "subject": "Monthly Update",
+ "body": "Hello {{name}}!
"
+ }
+ },
"withTemplate": {
"summary": "Using template",
"value": {
diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml
index aa02ea1..3f97738 100644
--- a/docker/docker-compose.dev.yml
+++ b/docker/docker-compose.dev.yml
@@ -7,6 +7,8 @@ services:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: postgres
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
redis:
image: redis
@@ -30,4 +32,5 @@ services:
volumes:
redis_data:
- minio_data:
\ No newline at end of file
+ minio_data:
+ postgres_data:
\ No newline at end of file
diff --git a/packages/db/prisma/migrations/20251130203158_init/migration.sql b/packages/db/prisma/migrations/20251201125842_init/migration.sql
similarity index 99%
rename from packages/db/prisma/migrations/20251130203158_init/migration.sql
rename to packages/db/prisma/migrations/20251201125842_init/migration.sql
index 4745dd0..4f97b76 100644
--- a/packages/db/prisma/migrations/20251130203158_init/migration.sql
+++ b/packages/db/prisma/migrations/20251201125842_init/migration.sql
@@ -256,6 +256,7 @@ CREATE TABLE "workflow_step_executions" (
CREATE TABLE "emails" (
"id" TEXT NOT NULL,
"contactId" TEXT NOT NULL,
+ "toName" TEXT,
"subject" TEXT NOT NULL,
"body" TEXT NOT NULL,
"from" TEXT NOT NULL,
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 70659e2..709acef 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -490,6 +490,7 @@ model Email {
// Recipient
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
contactId String
+ toName String? // Recipient display name for To header
// Content (denormalized for history)
subject String
diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts
index 6bfcc7c..bb92f4e 100644
--- a/packages/shared/src/schemas/index.ts
+++ b/packages/shared/src/schemas/index.ts
@@ -307,13 +307,39 @@ export const ActionSchemas = {
}),
send: z
.object({
- to: z.union([email, z.array(email)]),
+ to: z.union([
+ email, // Simple email string (backward compatible)
+ z.object({
+ // Object with name and email
+ name: z.string().optional(),
+ email: email,
+ }),
+ z.array(
+ z.union([
+ email, // Array of email strings
+ z.object({
+ // Array of objects with name and email
+ name: z.string().optional(),
+ email: email,
+ }),
+ ]),
+ ),
+ ]),
subject: z.string().min(1).max(998).optional(),
body: z.string().min(1).optional(),
template: uuid.optional(),
subscribed: z.boolean().optional().default(false),
name: z.string().optional(),
- from: email.optional(),
+ from: z
+ .union([
+ email, // Simple email string (backward compatible)
+ z.object({
+ // Object with name and email
+ name: z.string().optional(),
+ email: email,
+ }),
+ ])
+ .optional(),
reply: email.optional(),
headers: z.record(z.string().max(998)).optional(),
data: jsonSchema.optional(),