feat: add In-Reply-To option (#165)
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
"@aws-sdk/client-sns": "^3.797.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.797.0",
|
||||
"@hono/swagger-ui": "^0.5.1",
|
||||
"@hono/zod-openapi": "^0.19.5",
|
||||
"@hono/zod-openapi": "^0.10.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@isaacs/ttlcache": "^1.4.1",
|
||||
"@prisma/client": "^6.6.0",
|
||||
|
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Email" ADD COLUMN "inReplyToId" TEXT;
|
@@ -245,6 +245,7 @@ model Email {
|
||||
attachments String?
|
||||
campaignId String?
|
||||
contactId String?
|
||||
inReplyToId String?
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
emailEvents EmailEvent[]
|
||||
|
||||
|
@@ -103,96 +103,7 @@ export async function getDomainIdentity(domain: string, region: string) {
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function sendEmailThroughSes({
|
||||
to,
|
||||
from,
|
||||
subject,
|
||||
cc,
|
||||
bcc,
|
||||
text,
|
||||
html,
|
||||
replyTo,
|
||||
region,
|
||||
configurationSetName,
|
||||
unsubUrl,
|
||||
isBulk,
|
||||
}: Partial<EmailContent> & {
|
||||
region: string;
|
||||
configurationSetName: string;
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
replyTo?: string[];
|
||||
to?: string[];
|
||||
isBulk?: boolean;
|
||||
}) {
|
||||
const sesClient = getSesClient(region);
|
||||
const command = new SendEmailCommand({
|
||||
FromEmailAddress: from,
|
||||
ReplyToAddresses: replyTo ? replyTo : undefined,
|
||||
Destination: {
|
||||
ToAddresses: to,
|
||||
CcAddresses: cc,
|
||||
BccAddresses: bcc,
|
||||
},
|
||||
Content: {
|
||||
// EmailContent
|
||||
Simple: {
|
||||
// Message
|
||||
Subject: {
|
||||
// Content
|
||||
Data: subject, // required
|
||||
Charset: "UTF-8",
|
||||
},
|
||||
Body: {
|
||||
// Body
|
||||
Text: text
|
||||
? {
|
||||
Data: text, // required
|
||||
Charset: "UTF-8",
|
||||
}
|
||||
: undefined,
|
||||
Html: html
|
||||
? {
|
||||
Data: html, // required
|
||||
Charset: "UTF-8",
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
Headers: [
|
||||
// Spread in any unsubscribe headers if unsubUrl is defined
|
||||
...(unsubUrl
|
||||
? [
|
||||
{ Name: "List-Unsubscribe", Value: `<${unsubUrl}>` },
|
||||
{
|
||||
Name: "List-Unsubscribe-Post",
|
||||
Value: "List-Unsubscribe=One-Click",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Spread in the precedence header if present
|
||||
...(isBulk ? [{ Name: "Precedence", Value: "bulk" }] : []),
|
||||
{
|
||||
Name: "X-Entity-Ref-ID",
|
||||
Value: nanoid(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
ConfigurationSetName: configurationSetName,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await sesClient.send(command);
|
||||
console.log("Email sent! Message ID:", response.MessageId);
|
||||
return response.MessageId;
|
||||
} catch (error) {
|
||||
console.error("Failed to send email", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Need to improve this. Use some kinda library to do this
|
||||
export async function sendEmailWithAttachments({
|
||||
export async function sendRawEmail({
|
||||
to,
|
||||
from,
|
||||
subject,
|
||||
@@ -200,22 +111,28 @@ export async function sendEmailWithAttachments({
|
||||
cc,
|
||||
bcc,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
text,
|
||||
text, // text is not used directly in raw email but kept for interface consistency
|
||||
html,
|
||||
attachments,
|
||||
region,
|
||||
configurationSetName,
|
||||
unsubUrl,
|
||||
isBulk,
|
||||
inReplyToMessageId,
|
||||
}: Partial<EmailContent> & {
|
||||
region: string;
|
||||
configurationSetName: string;
|
||||
attachments: { filename: string; content: string }[];
|
||||
attachments?: { filename: string; content: string }[]; // Made attachments optional
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
replyTo?: string[];
|
||||
to?: string[];
|
||||
unsubUrl?: string;
|
||||
isBulk?: boolean;
|
||||
inReplyToMessageId?: string;
|
||||
}) {
|
||||
const sesClient = getSesClient(region);
|
||||
const boundary = "NextPart";
|
||||
const boundary = `NextPart`;
|
||||
let rawEmail = `From: ${from}\n`;
|
||||
rawEmail += `To: ${Array.isArray(to) ? to.join(", ") : to}\n`;
|
||||
rawEmail += cc && cc.length ? `Cc: ${cc.join(", ")}\n` : "";
|
||||
@@ -224,19 +141,37 @@ export async function sendEmailWithAttachments({
|
||||
replyTo && replyTo.length ? `Reply-To: ${replyTo.join(", ")}\n` : "";
|
||||
rawEmail += `Subject: ${subject}\n`;
|
||||
rawEmail += `MIME-Version: 1.0\n`;
|
||||
|
||||
// Add headers
|
||||
if (unsubUrl) {
|
||||
rawEmail += `List-Unsubscribe: <${unsubUrl}>\n`;
|
||||
rawEmail += `List-Unsubscribe-Post: List-Unsubscribe=One-Click\n`;
|
||||
}
|
||||
if (isBulk) {
|
||||
rawEmail += `Precedence: bulk\n`;
|
||||
}
|
||||
if (inReplyToMessageId) {
|
||||
rawEmail += `In-Reply-To: <${inReplyToMessageId}@email.amazonses.com>\n`;
|
||||
rawEmail += `References: <${inReplyToMessageId}@email.amazonses.com>\n`;
|
||||
}
|
||||
rawEmail += `X-Entity-Ref-ID: ${nanoid()}\n`;
|
||||
|
||||
rawEmail += `Content-Type: multipart/mixed; boundary="${boundary}"\n\n`;
|
||||
rawEmail += `--${boundary}\n`;
|
||||
rawEmail += `Content-Type: text/html; charset="UTF-8"\n\n`;
|
||||
rawEmail += `${html}\n\n`;
|
||||
for (const attachment of attachments) {
|
||||
const content = attachment.content; // Convert buffer to base64
|
||||
const mimeType =
|
||||
mime.lookup(attachment.filename) || "application/octet-stream";
|
||||
rawEmail += `--${boundary}\n`;
|
||||
rawEmail += `Content-Type: ${mimeType}; name="${attachment.filename}"\n`;
|
||||
rawEmail += `Content-Disposition: attachment; filename="${attachment.filename}"\n`;
|
||||
rawEmail += `Content-Transfer-Encoding: base64\n\n`;
|
||||
rawEmail += `${content}\n\n`;
|
||||
|
||||
if (attachments && attachments.length > 0) {
|
||||
for (const attachment of attachments) {
|
||||
const content = attachment.content; // Assumes content is base64
|
||||
const mimeType =
|
||||
mime.lookup(attachment.filename) || "application/octet-stream";
|
||||
rawEmail += `--${boundary}\n`;
|
||||
rawEmail += `Content-Type: ${mimeType}; name="${attachment.filename}"\n`;
|
||||
rawEmail += `Content-Disposition: attachment; filename="${attachment.filename}"\n`;
|
||||
rawEmail += `Content-Transfer-Encoding: base64\n\n`;
|
||||
rawEmail += `${content}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
rawEmail += `--${boundary}--`;
|
||||
@@ -252,11 +187,13 @@ export async function sendEmailWithAttachments({
|
||||
|
||||
try {
|
||||
const response = await sesClient.send(command);
|
||||
console.log("Email with attachments sent! Message ID:", response.MessageId);
|
||||
console.log("Email sent! Message ID:", response.MessageId);
|
||||
return response.MessageId;
|
||||
} catch (error) {
|
||||
console.error("Failed to send email with attachments", error);
|
||||
throw new Error("Failed to send email with attachments");
|
||||
console.error("Failed to send email", error);
|
||||
// It's better to throw the original error or a new error with more context
|
||||
// throw new Error("Failed to send email");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { PublicAPIApp } from "~/server/public-api/hono";
|
||||
import { getTeamFromToken } from "~/server/public-api/auth";
|
||||
import { sendEmail } from "~/server/service/email-service";
|
||||
import { emailSchema } from "../../schemas/email-schema";
|
||||
|
||||
|
@@ -70,7 +70,6 @@ export function getApp() {
|
||||
|
||||
let currentRequests: number;
|
||||
let ttl: number;
|
||||
let isNewKey = false;
|
||||
|
||||
try {
|
||||
// Increment the key. If the key does not exist, it is created and set to 1.
|
||||
|
@@ -29,6 +29,7 @@ export const emailSchema = z
|
||||
.max(10) // Limit attachments array size if desired
|
||||
.optional(),
|
||||
scheduledAt: z.string().datetime({ offset: true }).optional(), // Ensure ISO 8601 format with offset
|
||||
inReplyToId: z.string().optional().nullable(),
|
||||
})
|
||||
.refine(
|
||||
(data) => !!data.subject || !!data.templateId,
|
||||
|
@@ -4,7 +4,7 @@ import { EmailAttachment } from "~/types";
|
||||
import { convert as htmlToText } from "html-to-text";
|
||||
import { getConfigurationSetName } from "~/utils/ses-utils";
|
||||
import { db } from "../db";
|
||||
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses";
|
||||
import { sendRawEmail } from "../aws/ses";
|
||||
import { getRedis } from "../redis";
|
||||
import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
|
||||
import { Prisma } from "@prisma/client";
|
||||
@@ -331,34 +331,37 @@ async function executeEmail(
|
||||
? htmlToText(email.html)
|
||||
: undefined;
|
||||
|
||||
let inReplyToMessageId: string | undefined = undefined;
|
||||
|
||||
if (email.inReplyToId) {
|
||||
const replyEmail = await db.email.findUnique({
|
||||
where: {
|
||||
id: email.inReplyToId,
|
||||
},
|
||||
});
|
||||
|
||||
if (replyEmail && replyEmail.sesEmailId) {
|
||||
inReplyToMessageId = replyEmail.sesEmailId;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const messageId = attachments.length
|
||||
? await sendEmailWithAttachments({
|
||||
to: email.to,
|
||||
from: email.from,
|
||||
subject: email.subject,
|
||||
replyTo: email.replyTo ?? undefined,
|
||||
bcc: email.bcc,
|
||||
cc: email.cc,
|
||||
text,
|
||||
html: email.html ?? undefined,
|
||||
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
||||
configurationSetName,
|
||||
attachments,
|
||||
})
|
||||
: await sendEmailThroughSes({
|
||||
to: email.to,
|
||||
from: email.from,
|
||||
subject: email.subject,
|
||||
replyTo: email.replyTo ?? undefined,
|
||||
text,
|
||||
html: email.html ?? undefined,
|
||||
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
||||
configurationSetName,
|
||||
attachments,
|
||||
unsubUrl,
|
||||
isBulk,
|
||||
});
|
||||
const messageId = await sendRawEmail({
|
||||
to: email.to,
|
||||
from: email.from,
|
||||
subject: email.subject,
|
||||
replyTo: email.replyTo ?? undefined,
|
||||
bcc: email.bcc,
|
||||
cc: email.cc,
|
||||
text,
|
||||
html: email.html ?? undefined,
|
||||
region: domain?.region ?? env.AWS_DEFAULT_REGION,
|
||||
configurationSetName,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
unsubUrl,
|
||||
isBulk,
|
||||
inReplyToMessageId,
|
||||
});
|
||||
|
||||
// Delete attachments after sending the email
|
||||
await db.email.update({
|
||||
|
@@ -63,6 +63,7 @@ export async function sendEmail(
|
||||
bcc,
|
||||
scheduledAt,
|
||||
apiKeyId,
|
||||
inReplyToId,
|
||||
} = emailContent;
|
||||
let subject = subjectFromApiCall;
|
||||
let html = htmlFromApiCall;
|
||||
@@ -99,6 +100,22 @@ export async function sendEmail(
|
||||
}
|
||||
}
|
||||
|
||||
if (inReplyToId) {
|
||||
const email = await db.email.findUnique({
|
||||
where: {
|
||||
id: inReplyToId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
throw new UnsendApiError({
|
||||
code: "BAD_REQUEST",
|
||||
message: '"inReplyTo" is invalid',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!text && !html) {
|
||||
throw new UnsendApiError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -131,6 +148,7 @@ export async function sendEmail(
|
||||
scheduledAt: scheduledAtDate,
|
||||
latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED",
|
||||
apiId: apiKeyId,
|
||||
inReplyToId,
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -3,7 +3,7 @@ export type EmailContent = {
|
||||
from: string;
|
||||
subject?: string;
|
||||
templateId?: string;
|
||||
variables?: Record<string, string>,
|
||||
variables?: Record<string, string>;
|
||||
text?: string;
|
||||
html?: string;
|
||||
replyTo?: string | string[];
|
||||
@@ -12,6 +12,7 @@ export type EmailContent = {
|
||||
attachments?: Array<EmailAttachment>;
|
||||
unsubUrl?: string;
|
||||
scheduledAt?: string;
|
||||
inReplyToId?: string | null;
|
||||
};
|
||||
|
||||
export type EmailAttachment = {
|
||||
|
Reference in New Issue
Block a user