feat: add In-Reply-To option (#165)

This commit is contained in:
KM Koushik
2025-05-25 20:44:13 +10:00
committed by GitHub
parent ff32ff9302
commit 15e5327024
14 changed files with 754 additions and 592 deletions

View File

@@ -605,6 +605,10 @@
"scheduledAt": { "scheduledAt": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
},
"inReplyToId": {
"type": "string",
"nullable": true
} }
}, },
"required": [ "required": [
@@ -758,6 +762,10 @@
"scheduledAt": { "scheduledAt": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
},
"inReplyToId": {
"type": "string",
"nullable": true
} }
}, },
"required": [ "required": [

View File

@@ -23,7 +23,7 @@
"@aws-sdk/client-sns": "^3.797.0", "@aws-sdk/client-sns": "^3.797.0",
"@aws-sdk/s3-request-presigner": "^3.797.0", "@aws-sdk/s3-request-presigner": "^3.797.0",
"@hono/swagger-ui": "^0.5.1", "@hono/swagger-ui": "^0.5.1",
"@hono/zod-openapi": "^0.19.5", "@hono/zod-openapi": "^0.10.0",
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.0.1",
"@isaacs/ttlcache": "^1.4.1", "@isaacs/ttlcache": "^1.4.1",
"@prisma/client": "^6.6.0", "@prisma/client": "^6.6.0",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Email" ADD COLUMN "inReplyToId" TEXT;

View File

@@ -245,6 +245,7 @@ model Email {
attachments String? attachments String?
campaignId String? campaignId String?
contactId String? contactId String?
inReplyToId String?
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
emailEvents EmailEvent[] emailEvents EmailEvent[]

View File

@@ -103,96 +103,7 @@ export async function getDomainIdentity(domain: string, region: string) {
return response; return response;
} }
export async function sendEmailThroughSes({ export async function sendRawEmail({
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({
to, to,
from, from,
subject, subject,
@@ -200,22 +111,28 @@ export async function sendEmailWithAttachments({
cc, cc,
bcc, bcc,
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
text, text, // text is not used directly in raw email but kept for interface consistency
html, html,
attachments, attachments,
region, region,
configurationSetName, configurationSetName,
unsubUrl,
isBulk,
inReplyToMessageId,
}: Partial<EmailContent> & { }: Partial<EmailContent> & {
region: string; region: string;
configurationSetName: string; configurationSetName: string;
attachments: { filename: string; content: string }[]; attachments?: { filename: string; content: string }[]; // Made attachments optional
cc?: string[]; cc?: string[];
bcc?: string[]; bcc?: string[];
replyTo?: string[]; replyTo?: string[];
to?: string[]; to?: string[];
unsubUrl?: string;
isBulk?: boolean;
inReplyToMessageId?: string;
}) { }) {
const sesClient = getSesClient(region); const sesClient = getSesClient(region);
const boundary = "NextPart"; const boundary = `NextPart`;
let rawEmail = `From: ${from}\n`; let rawEmail = `From: ${from}\n`;
rawEmail += `To: ${Array.isArray(to) ? to.join(", ") : to}\n`; rawEmail += `To: ${Array.isArray(to) ? to.join(", ") : to}\n`;
rawEmail += cc && cc.length ? `Cc: ${cc.join(", ")}\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` : ""; replyTo && replyTo.length ? `Reply-To: ${replyTo.join(", ")}\n` : "";
rawEmail += `Subject: ${subject}\n`; rawEmail += `Subject: ${subject}\n`;
rawEmail += `MIME-Version: 1.0\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 += `Content-Type: multipart/mixed; boundary="${boundary}"\n\n`;
rawEmail += `--${boundary}\n`; rawEmail += `--${boundary}\n`;
rawEmail += `Content-Type: text/html; charset="UTF-8"\n\n`; rawEmail += `Content-Type: text/html; charset="UTF-8"\n\n`;
rawEmail += `${html}\n\n`; rawEmail += `${html}\n\n`;
for (const attachment of attachments) {
const content = attachment.content; // Convert buffer to base64 if (attachments && attachments.length > 0) {
const mimeType = for (const attachment of attachments) {
mime.lookup(attachment.filename) || "application/octet-stream"; const content = attachment.content; // Assumes content is base64
rawEmail += `--${boundary}\n`; const mimeType =
rawEmail += `Content-Type: ${mimeType}; name="${attachment.filename}"\n`; mime.lookup(attachment.filename) || "application/octet-stream";
rawEmail += `Content-Disposition: attachment; filename="${attachment.filename}"\n`; rawEmail += `--${boundary}\n`;
rawEmail += `Content-Transfer-Encoding: base64\n\n`; rawEmail += `Content-Type: ${mimeType}; name="${attachment.filename}"\n`;
rawEmail += `${content}\n\n`; rawEmail += `Content-Disposition: attachment; filename="${attachment.filename}"\n`;
rawEmail += `Content-Transfer-Encoding: base64\n\n`;
rawEmail += `${content}\n\n`;
}
} }
rawEmail += `--${boundary}--`; rawEmail += `--${boundary}--`;
@@ -252,11 +187,13 @@ export async function sendEmailWithAttachments({
try { try {
const response = await sesClient.send(command); 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; return response.MessageId;
} catch (error) { } catch (error) {
console.error("Failed to send email with attachments", error); console.error("Failed to send email", error);
throw new Error("Failed to send email with attachments"); // It's better to throw the original error or a new error with more context
// throw new Error("Failed to send email");
throw error;
} }
} }

View File

@@ -1,6 +1,5 @@
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono"; import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth";
import { sendEmail } from "~/server/service/email-service"; import { sendEmail } from "~/server/service/email-service";
import { emailSchema } from "../../schemas/email-schema"; import { emailSchema } from "../../schemas/email-schema";

View File

@@ -70,7 +70,6 @@ export function getApp() {
let currentRequests: number; let currentRequests: number;
let ttl: number; let ttl: number;
let isNewKey = false;
try { try {
// Increment the key. If the key does not exist, it is created and set to 1. // Increment the key. If the key does not exist, it is created and set to 1.

View File

@@ -29,6 +29,7 @@ export const emailSchema = z
.max(10) // Limit attachments array size if desired .max(10) // Limit attachments array size if desired
.optional(), .optional(),
scheduledAt: z.string().datetime({ offset: true }).optional(), // Ensure ISO 8601 format with offset scheduledAt: z.string().datetime({ offset: true }).optional(), // Ensure ISO 8601 format with offset
inReplyToId: z.string().optional().nullable(),
}) })
.refine( .refine(
(data) => !!data.subject || !!data.templateId, (data) => !!data.subject || !!data.templateId,

View File

@@ -4,7 +4,7 @@ import { EmailAttachment } from "~/types";
import { convert as htmlToText } from "html-to-text"; import { convert as htmlToText } from "html-to-text";
import { getConfigurationSetName } from "~/utils/ses-utils"; import { getConfigurationSetName } from "~/utils/ses-utils";
import { db } from "../db"; import { db } from "../db";
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses"; import { sendRawEmail } from "../aws/ses";
import { getRedis } from "../redis"; import { getRedis } from "../redis";
import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants"; import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
@@ -331,34 +331,37 @@ async function executeEmail(
? htmlToText(email.html) ? htmlToText(email.html)
: undefined; : 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 { try {
const messageId = attachments.length const messageId = await sendRawEmail({
? await sendEmailWithAttachments({ to: email.to,
to: email.to, from: email.from,
from: email.from, subject: email.subject,
subject: email.subject, replyTo: email.replyTo ?? undefined,
replyTo: email.replyTo ?? undefined, bcc: email.bcc,
bcc: email.bcc, cc: email.cc,
cc: email.cc, text,
text, html: email.html ?? undefined,
html: email.html ?? undefined, region: domain?.region ?? env.AWS_DEFAULT_REGION,
region: domain?.region ?? env.AWS_DEFAULT_REGION, configurationSetName,
configurationSetName, attachments: attachments.length > 0 ? attachments : undefined,
attachments, unsubUrl,
}) isBulk,
: await sendEmailThroughSes({ inReplyToMessageId,
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,
});
// Delete attachments after sending the email // Delete attachments after sending the email
await db.email.update({ await db.email.update({

View File

@@ -63,6 +63,7 @@ export async function sendEmail(
bcc, bcc,
scheduledAt, scheduledAt,
apiKeyId, apiKeyId,
inReplyToId,
} = emailContent; } = emailContent;
let subject = subjectFromApiCall; let subject = subjectFromApiCall;
let html = htmlFromApiCall; 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) { if (!text && !html) {
throw new UnsendApiError({ throw new UnsendApiError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -131,6 +148,7 @@ export async function sendEmail(
scheduledAt: scheduledAtDate, scheduledAt: scheduledAtDate,
latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED", latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED",
apiId: apiKeyId, apiId: apiKeyId,
inReplyToId,
}, },
}); });

View File

@@ -3,7 +3,7 @@ export type EmailContent = {
from: string; from: string;
subject?: string; subject?: string;
templateId?: string; templateId?: string;
variables?: Record<string, string>, variables?: Record<string, string>;
text?: string; text?: string;
html?: string; html?: string;
replyTo?: string | string[]; replyTo?: string | string[];
@@ -12,6 +12,7 @@ export type EmailContent = {
attachments?: Array<EmailAttachment>; attachments?: Array<EmailAttachment>;
unsubUrl?: string; unsubUrl?: string;
scheduledAt?: string; scheduledAt?: string;
inReplyToId?: string | null;
}; };
export type EmailAttachment = { export type EmailAttachment = {

View File

@@ -1,6 +1,6 @@
{ {
"name": "unsend", "name": "unsend",
"version": "1.5.0", "version": "1.5.1",
"description": "", "description": "",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",

File diff suppressed because it is too large Load Diff

16
pnpm-lock.yaml generated
View File

@@ -147,8 +147,8 @@ importers:
specifier: ^0.5.1 specifier: ^0.5.1
version: 0.5.1(hono@4.7.7) version: 0.5.1(hono@4.7.7)
'@hono/zod-openapi': '@hono/zod-openapi':
specifier: ^0.19.5 specifier: ^0.10.0
version: 0.19.5(hono@4.7.7)(zod@3.24.3) version: 0.10.1(hono@4.7.7)(zod@3.24.3)
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.0.1(react-hook-form@7.56.1) version: 5.0.1(react-hook-form@7.56.1)
@@ -2435,21 +2435,21 @@ packages:
hono: 4.7.7 hono: 4.7.7
dev: false dev: false
/@hono/zod-openapi@0.19.5(hono@4.7.7)(zod@3.24.3): /@hono/zod-openapi@0.10.1(hono@4.7.7)(zod@3.24.3):
resolution: {integrity: sha512-n2RqdZL7XIaWPwBNygctG/1eySyRtSBnS7l+pIsP3f2JW5P2l7Smm6SLluscrGwB5l2C2fxbfvhWoC6Ig+SxXw==} resolution: {integrity: sha512-IIenwxruTH7wJ2cLPVfSg8j7bMv7F3+W69+mOOs8KsqSiBgNxwqUhkjm5ilQ1xL/Y/GHT2mB5AEBfvWb31Biiw==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
peerDependencies: peerDependencies:
hono: '>=4.3.6' hono: '>=3.11.3'
zod: 3.* zod: 3.*
dependencies: dependencies:
'@asteasolutions/zod-to-openapi': 7.3.0(zod@3.24.3) '@asteasolutions/zod-to-openapi': 7.3.0(zod@3.24.3)
'@hono/zod-validator': 0.4.3(hono@4.7.7)(zod@3.24.3) '@hono/zod-validator': 0.2.1(hono@4.7.7)(zod@3.24.3)
hono: 4.7.7 hono: 4.7.7
zod: 3.24.3 zod: 3.24.3
dev: false dev: false
/@hono/zod-validator@0.4.3(hono@4.7.7)(zod@3.24.3): /@hono/zod-validator@0.2.1(hono@4.7.7)(zod@3.24.3):
resolution: {integrity: sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ==} resolution: {integrity: sha512-HFoxln7Q6JsE64qz2WBS28SD33UB2alp3aRKmcWnNLDzEL1BLsWfbdX6e1HIiUprHYTIXf5y7ax8eYidKUwyaA==}
peerDependencies: peerDependencies:
hono: '>=3.9.0' hono: '>=3.9.0'
zod: ^3.19.1 zod: ^3.19.1