Added support for to and from name
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
|
||||
@@ -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: "<p>Reset code: {{resetCode}}</p><p>Hello {{firstName}}!</p>",
|
||||
* 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: "<p>Reset code: {{resetCode}}</p>",
|
||||
* 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: "<p>Hello {{name}}!</p>",
|
||||
* 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<string, unknown> | undefined), name: recipient.name}
|
||||
: (data as Record<string, unknown> | undefined);
|
||||
|
||||
// Create or update contact with metadata
|
||||
const contact = await ContactService.upsert(
|
||||
auth.projectId,
|
||||
recipientEmail,
|
||||
data as Record<string, unknown> | 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,
|
||||
|
||||
@@ -74,10 +74,15 @@ export function createEmailWorker() {
|
||||
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
|
||||
});
|
||||
|
||||
// Parse from email (format: "Name <email@domain.com>" 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
|
||||
|
||||
@@ -23,6 +23,7 @@ interface SendEmailParams {
|
||||
body: string;
|
||||
from: string;
|
||||
fromName?: string;
|
||||
toName?: string;
|
||||
replyTo?: string;
|
||||
headers?: Record<string, string>;
|
||||
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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user