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', () => {
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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:
|
||||||
+1
@@ -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,
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user