Added support for to and from name

This commit is contained in:
Dries Augustyns
2025-12-01 14:06:09 +01:00
parent 2dd6af8ff7
commit 1f3978e89c
12 changed files with 470 additions and 44 deletions
@@ -94,7 +94,7 @@ describe('Actions API Integration Tests', () => {
}); });
it('should validate required fields for /v1/track', () => { it('should validate required fields for /v1/track', () => {
const result = ActionSchemas.track.safeParse({ const result = ActionSchemas.track.safeParse({
email: '[email protected]', email: '[email protected]',
@@ -106,6 +106,164 @@ describe('Actions API Integration Tests', () => {
expect(result.error.errors.some(e => e.path.includes('event'))).toBe(true); 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: '[email protected]',
subject: 'Test',
body: 'Test',
from: '[email protected]',
});
expect(result.success).toBe(true);
});
it('should accept from as object with name and email', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
subject: 'Test',
body: 'Test',
from: {
name: 'John Doe',
email: '[email protected]',
},
});
expect(result.success).toBe(true);
});
it('should accept from as object with only email', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
subject: 'Test',
body: 'Test',
from: {
email: '[email protected]',
},
});
expect(result.success).toBe(true);
});
it('should reject from object with invalid email', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
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: '[email protected]',
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: '[email protected]',
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: '[email protected]',
},
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: '[email protected]',
},
subject: 'Test',
body: 'Test',
});
expect(result.success).toBe(true);
});
it('should accept to as array of strings', () => {
const result = ActionSchemas.send.safeParse({
to: ['[email protected]', '[email protected]'],
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: '[email protected]'},
{name: 'John Smith', email: '[email protected]'},
],
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: ['[email protected]', {name: 'John Smith', email: '[email protected]'}],
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);
});
}); });
// ======================================== // ========================================
+85 -13
View File
@@ -87,32 +87,68 @@ export class Actions {
* Send transactional email(s) * Send transactional email(s)
* *
* Request body: * Request body:
* - to: string | string[] (required) - Recipient email(s) * - to: string | object | array (required) - Recipient email(s)
* - String: "[email protected]"
* - Object: {name: "Jane Doe", email: "[email protected]"}
* - Array: ["[email protected]", {name: "Jane", email: "[email protected]"}]
* - subject: string (required) - Email subject * - subject: string (required) - Email subject
* - body: string (required) - Email HTML body * - body: string (required) - Email HTML body
* - subscribed: boolean (optional, default: false) - Contact subscription status * - subscribed: boolean (optional, default: false) - Contact subscription status
* - name: string (optional) - Sender name * - name: string (optional) - Sender name (alternative to from.name)
* - from: string (optional) - Sender email (must be from verified domain) * - from: string | object (optional) - Sender email or {name, email} object (must be from verified domain)
* - reply: string (optional) - Reply-to email * - reply: string (optional) - Reply-to email
* - headers: object (optional) - Additional email headers * - headers: object (optional) - Additional email headers
* - data: object (optional) - Contact data and template variables * - data: object (optional) - Contact data and template variables
* - Simple values are saved to contact (persistent) * - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only used for this email (non-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: * Response:
* - success: boolean * - success: boolean
* - data: object with emails array and timestamp * - data: object with emails array and timestamp
* *
* Example: * Examples:
*
* Simple format (backward compatible):
* { * {
* to: "[email protected]", * to: "[email protected]",
* subject: "Password Reset", * subject: "Password Reset",
* body: "<p>Reset code: {{resetCode}}</p><p>Hello {{firstName}}!</p>", * body: "<p>Reset code: {{resetCode}}</p><p>Hello {{firstName}}!</p>",
* from: "[email protected]",
* name: "My App",
* data: { * data: {
* firstName: "John", // Persistent - saved to contact * firstName: "John", // Persistent - saved to contact
* resetCode: {value: "ABC123", persistent: false} // Non-persistent - this email only * resetCode: {value: "ABC123", persistent: false} // Non-persistent - this email only
* } * }
* } * }
*
* Object format (recommended):
* {
* to: {
* name: "Jane Doe",
* email: "[email protected]"
* },
* subject: "Password Reset",
* body: "<p>Reset code: {{resetCode}}</p>",
* from: {
* name: "My App",
* email: "[email protected]"
* }
* }
*
* Multiple recipients with names:
* {
* to: [
* {name: "Jane Doe", email: "[email protected]"},
* {name: "John Smith", email: "[email protected]"}
* ],
* subject: "Newsletter",
* body: "<p>Hello {{name}}!</p>",
* from: {name: "Newsletter", email: "[email protected]"}
* }
*/ */
@Post('send') @Post('send')
@Middleware([requireSecretKey]) @Middleware([requireSecretKey])
@@ -123,13 +159,36 @@ export class Actions {
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} = const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
ActionSchemas.send.parse(req.body); ActionSchemas.send.parse(req.body);
// Normalize recipients to array // Normalize recipients to array and parse email/name
const recipients = Array.isArray(to) ? to : [to]; 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 // Fetch template if provided
let emailSubject = subject; let emailSubject = subject;
let emailBody = body; let emailBody = body;
let emailFrom = from;
let emailFromName = name;
let emailReplyTo = reply; let emailReplyTo = reply;
let templateId: string | undefined; let templateId: string | undefined;
@@ -148,8 +207,15 @@ export class Actions {
// Use template values, allow overrides from request // Use template values, allow overrides from request
emailSubject = subject || templateRecord.subject; emailSubject = subject || templateRecord.subject;
emailBody = body || templateRecord.body; 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; emailReplyTo = reply || templateRecord.replyTo || undefined;
templateId = templateRecord.id; templateId = templateRecord.id;
} }
@@ -168,12 +234,17 @@ export class Actions {
const emailResults = []; const emailResults = [];
// Process each recipient // 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<string, unknown> | undefined), name: recipient.name}
: (data as Record<string, unknown> | undefined);
// Create or update contact with metadata // Create or update contact with metadata
const contact = await ContactService.upsert( const contact = await ContactService.upsert(
auth.projectId, auth.projectId,
recipientEmail, recipient.email,
data as Record<string, unknown> | undefined, recipientData,
subscribed, subscribed,
); );
@@ -214,6 +285,7 @@ export class Actions {
body: renderedBody, body: renderedBody,
from: senderEmail, from: senderEmail,
fromName: emailFromName, fromName: emailFromName,
toName: recipient.name,
replyTo: replyToEmail, replyTo: replyToEmail,
headers: headers || undefined, headers: headers || undefined,
attachments: attachments || undefined, attachments: attachments || undefined,
+11 -5
View File
@@ -74,10 +74,15 @@ export function createEmailWorker() {
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
}); });
// Parse from email (format: "Name <[email protected]>" or just "[email protected]") // Use fromName from database if available, otherwise fall back to project name
const fromMatch = /(.*?)<(.+?)>/.exec(email.from) || [null, email.from, email.from]; // The 'from' field in the database is just the email address
const fromName = fromMatch[1]?.trim() || email.project.name; const fromName = email.fromName || email.project.name;
const fromEmail = fromMatch[2]?.trim() || email.from; 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 // Send via AWS SES
const result = await sendRawEmail({ const result = await sendRawEmail({
@@ -85,13 +90,14 @@ export function createEmailWorker() {
name: fromName, name: fromName,
email: fromEmail, email: fromEmail,
}, },
to: [email.contact.email], to: typeof recipient === 'string' ? [recipient] : [{name: recipient.name, email: recipient.email}],
content: { content: {
subject: formattedEmail.subject, subject: formattedEmail.subject,
html: compiledHtml, html: compiledHtml,
}, },
reply: email.replyTo || undefined, reply: email.replyTo || undefined,
tracking: email.project.trackingEnabled, // Use project's tracking preference 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 // Mark as sent with SES message ID
+2
View File
@@ -23,6 +23,7 @@ interface SendEmailParams {
body: string; body: string;
from: string; from: string;
fromName?: string; fromName?: string;
toName?: string;
replyTo?: string; replyTo?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
attachments?: Attachment[]; attachments?: Attachment[];
@@ -85,6 +86,7 @@ export class EmailService {
body: params.body, body: params.body,
from: params.from, from: params.from,
fromName: params.fromName, fromName: params.fromName,
toName: params.toName,
replyTo: params.replyTo, replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
+23 -10
View File
@@ -6,7 +6,7 @@ import {
AWS_SES_SECRET_ACCESS_KEY, AWS_SES_SECRET_ACCESS_KEY,
DASHBOARD_URI, DASHBOARD_URI,
SES_CONFIGURATION_SET, SES_CONFIGURATION_SET,
SES_CONFIGURATION_SET_NO_TRACKING, SES_CONFIGURATION_SET_NO_TRACKING
} from '../app/constants.js'; } from '../app/constants.js';
/** /**
@@ -26,7 +26,7 @@ interface SendRawEmailParams {
name: string; name: string;
email: string; email: string;
}; };
to: string[]; to: string[] | {name?: string; email: string}[];
content: { content: {
subject: string; subject: string;
html: string; html: string;
@@ -102,9 +102,23 @@ export async function sendRawEmail({
const boundary = `----=_NextPart_${Math.random().toString(36).substring(2)}`; const boundary = `----=_NextPart_${Math.random().toString(36).substring(2)}`;
const mixedBoundary = attachments?.length ? `----=_MixedPart_${Math.random().toString(36).substring(2)}` : null; 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 // Build raw MIME message
const rawMessage = `From: ${from.name} <${from.email}> const rawMessage = `From: ${from.name} <${from.email}>
To: ${to.join(', ')} To: ${toHeader}
Reply-To: ${reply || from.email} Reply-To: ${reply || from.email}
Subject: ${content.subject} Subject: ${content.subject}
MIME-Version: 1.0 MIME-Version: 1.0
@@ -131,17 +145,16 @@ Content-Transfer-Encoding: 7bit
${breakLongLines(content.html, 500)} ${breakLongLines(content.html, 500)}
--${boundary}-- --${boundary}--
${ ${
attachments?.length attachments && attachments.length > 0
? attachments ? '\n' +
attachments
.map( .map(
attachment => ` attachment => `--${mixedBoundary}
--${mixedBoundary}
Content-Type: ${attachment.contentType} Content-Type: ${attachment.contentType}
Content-Transfer-Encoding: base64 Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="${attachment.filename}" Content-Disposition: attachment; filename="${attachment.filename}"
${breakLongLines(attachment.content, 76, true)} ${breakLongLines(attachment.content, 76, true)}`,
`,
) )
.join('\n') .join('\n')
: '' : ''
@@ -149,7 +162,7 @@ ${breakLongLines(attachment.content, 76, true)}
// Send via SES // Send via SES
const response = await ses.sendRawEmail({ const response = await ses.sendRawEmail({
Destinations: to, Destinations: destinations,
ConfigurationSetName: tracking ? SES_CONFIGURATION_SET : SES_CONFIGURATION_SET_NO_TRACKING, ConfigurationSetName: tracking ? SES_CONFIGURATION_SET : SES_CONFIGURATION_SET_NO_TRACKING,
RawMessage: { RawMessage: {
Data: new TextEncoder().encode(rawMessage), Data: new TextEncoder().encode(rawMessage),
+47 -4
View File
@@ -285,7 +285,43 @@ function handleData(
const fromAddress = parsed.from?.value[0]?.address ?? session.address; const fromAddress = parsed.from?.value[0]?.address ?? session.address;
const fromName = parsed.from?.value[0]?.name; 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<string, string>();
// 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 // Parse attachments
const attachments = parsed.attachments const attachments = parsed.attachments
@@ -360,8 +396,7 @@ function handleData(
'Authorization': `Bearer ${session.user}`, 'Authorization': `Bearer ${session.user}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
from: fromAddress, from: from,
name: fromName,
to: recipients, to: recipients,
subject: parsed.subject, subject: parsed.subject,
body: bodyContent, body: bodyContent,
@@ -376,7 +411,15 @@ function handleData(
return callback(new Error(`Failed to send email: ${response.statusText}`)); 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(); callback();
} catch (error) { } catch (error) {
signale.error('Relay error:', error); signale.error('Relay error:', error);
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+108 -7
View File
@@ -192,17 +192,54 @@
"oneOf": [ "oneOf": [
{ {
"type": "string", "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", "type": "array",
"items": { "items": {
"type": "string", "oneOf": [
"format": "email" {
} "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": { "subject": {
"type": "string", "type": "string",
@@ -217,9 +254,34 @@
"description": "Template identifier to use" "description": "Template identifier to use"
}, },
"from": { "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", "type": "string",
"format": "email", "description": "Sender display name (alternative to using from.name)"
"description": "Custom from address (requires verified domain)"
}, },
"subscribed": { "subscribed": {
"type": "boolean", "type": "boolean",
@@ -282,6 +344,45 @@
} }
} }
}, },
"withNames": {
"summary": "Email with recipient and sender names",
"value": {
"to": {
"name": "Jane Doe",
"email": "[email protected]"
},
"from": {
"name": "My Company",
"email": "[email protected]"
},
"subject": "Welcome to Our Service",
"body": "<h1>Welcome {{name}}!</h1><p>We're glad to have you.</p>",
"data": {
"name": "Jane"
}
}
},
"multipleRecipients": {
"summary": "Multiple recipients with names",
"value": {
"to": [
{
"name": "Jane Doe",
"email": "[email protected]"
},
{
"name": "John Smith",
"email": "[email protected]"
}
],
"from": {
"name": "Newsletter",
"email": "[email protected]"
},
"subject": "Monthly Update",
"body": "<h1>Hello {{name}}!</h1>"
}
},
"withTemplate": { "withTemplate": {
"summary": "Using template", "summary": "Using template",
"value": { "value": {
+4 -1
View File
@@ -7,6 +7,8 @@ services:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_DB: postgres POSTGRES_DB: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
redis: redis:
image: redis image: redis
@@ -30,4 +32,5 @@ services:
volumes: volumes:
redis_data: redis_data:
minio_data: minio_data:
postgres_data:
@@ -256,6 +256,7 @@ CREATE TABLE "workflow_step_executions" (
CREATE TABLE "emails" ( CREATE TABLE "emails" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"contactId" TEXT NOT NULL, "contactId" TEXT NOT NULL,
"toName" TEXT,
"subject" TEXT NOT NULL, "subject" TEXT NOT NULL,
"body" TEXT NOT NULL, "body" TEXT NOT NULL,
"from" TEXT NOT NULL, "from" TEXT NOT NULL,
+1
View File
@@ -490,6 +490,7 @@ model Email {
// Recipient // Recipient
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
contactId String contactId String
toName String? // Recipient display name for To header
// Content (denormalized for history) // Content (denormalized for history)
subject String subject String
+28 -2
View File
@@ -307,13 +307,39 @@ export const ActionSchemas = {
}), }),
send: z send: z
.object({ .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(), subject: z.string().min(1).max(998).optional(),
body: z.string().min(1).optional(), body: z.string().min(1).optional(),
template: uuid.optional(), template: uuid.optional(),
subscribed: z.boolean().optional().default(false), subscribed: z.boolean().optional().default(false),
name: z.string().optional(), 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(), reply: email.optional(),
headers: z.record(z.string().max(998)).optional(), headers: z.record(z.string().max(998)).optional(),
data: jsonSchema.optional(), data: jsonSchema.optional(),