From 1f3978e89cc476e6a68993acbd609faee4fdc4dd Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Mon, 1 Dec 2025 14:06:09 +0100 Subject: [PATCH] Added support for to and from name --- .../src/__tests__/integration/actions.test.ts | 160 +++++++++++++++++- apps/api/src/controllers/Actions.ts | 98 +++++++++-- apps/api/src/jobs/email-processor.ts | 16 +- apps/api/src/services/EmailService.ts | 2 + apps/api/src/services/SESService.ts | 33 ++-- apps/smtp/src/server.ts | 51 +++++- apps/wiki/next-env.d.ts | 2 +- apps/wiki/openapi.json | 115 ++++++++++++- docker/docker-compose.dev.yml | 5 +- .../migration.sql | 1 + packages/db/prisma/schema.prisma | 1 + packages/shared/src/schemas/index.ts | 30 +++- 12 files changed, 470 insertions(+), 44 deletions(-) rename packages/db/prisma/migrations/{20251130203158_init => 20251201125842_init}/migration.sql (99%) 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(),