Skip to content

Commit 15e5327

Browse files
authored
feat: add In-Reply-To option (#165)
1 parent ff32ff9 commit 15e5327

File tree

14 files changed

+760
-598
lines changed

14 files changed

+760
-598
lines changed

apps/docs/api-reference/openapi.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,10 @@
605605
"scheduledAt": {
606606
"type": "string",
607607
"format": "date-time"
608+
},
609+
"inReplyToId": {
610+
"type": "string",
611+
"nullable": true
608612
}
609613
},
610614
"required": [
@@ -758,6 +762,10 @@
758762
"scheduledAt": {
759763
"type": "string",
760764
"format": "date-time"
765+
},
766+
"inReplyToId": {
767+
"type": "string",
768+
"nullable": true
761769
}
762770
},
763771
"required": [

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"@aws-sdk/client-sns": "^3.797.0",
2424
"@aws-sdk/s3-request-presigner": "^3.797.0",
2525
"@hono/swagger-ui": "^0.5.1",
26-
"@hono/zod-openapi": "^0.19.5",
26+
"@hono/zod-openapi": "^0.10.0",
2727
"@hookform/resolvers": "^5.0.1",
2828
"@isaacs/ttlcache": "^1.4.1",
2929
"@prisma/client": "^6.6.0",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Email" ADD COLUMN "inReplyToId" TEXT;

apps/web/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ model Email {
245245
attachments String?
246246
campaignId String?
247247
contactId String?
248+
inReplyToId String?
248249
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
249250
emailEvents EmailEvent[]
250251

apps/web/src/server/aws/ses.ts

Lines changed: 42 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -103,119 +103,36 @@ export async function getDomainIdentity(domain: string, region: string) {
103103
return response;
104104
}
105105

106-
export async function sendEmailThroughSes({
107-
to,
108-
from,
109-
subject,
110-
cc,
111-
bcc,
112-
text,
113-
html,
114-
replyTo,
115-
region,
116-
configurationSetName,
117-
unsubUrl,
118-
isBulk,
119-
}: Partial<EmailContent> & {
120-
region: string;
121-
configurationSetName: string;
122-
cc?: string[];
123-
bcc?: string[];
124-
replyTo?: string[];
125-
to?: string[];
126-
isBulk?: boolean;
127-
}) {
128-
const sesClient = getSesClient(region);
129-
const command = new SendEmailCommand({
130-
FromEmailAddress: from,
131-
ReplyToAddresses: replyTo ? replyTo : undefined,
132-
Destination: {
133-
ToAddresses: to,
134-
CcAddresses: cc,
135-
BccAddresses: bcc,
136-
},
137-
Content: {
138-
// EmailContent
139-
Simple: {
140-
// Message
141-
Subject: {
142-
// Content
143-
Data: subject, // required
144-
Charset: "UTF-8",
145-
},
146-
Body: {
147-
// Body
148-
Text: text
149-
? {
150-
Data: text, // required
151-
Charset: "UTF-8",
152-
}
153-
: undefined,
154-
Html: html
155-
? {
156-
Data: html, // required
157-
Charset: "UTF-8",
158-
}
159-
: undefined,
160-
},
161-
Headers: [
162-
// Spread in any unsubscribe headers if unsubUrl is defined
163-
...(unsubUrl
164-
? [
165-
{ Name: "List-Unsubscribe", Value: `<${unsubUrl}>` },
166-
{
167-
Name: "List-Unsubscribe-Post",
168-
Value: "List-Unsubscribe=One-Click",
169-
},
170-
]
171-
: []),
172-
// Spread in the precedence header if present
173-
...(isBulk ? [{ Name: "Precedence", Value: "bulk" }] : []),
174-
{
175-
Name: "X-Entity-Ref-ID",
176-
Value: nanoid(),
177-
},
178-
],
179-
},
180-
},
181-
ConfigurationSetName: configurationSetName,
182-
});
183-
184-
try {
185-
const response = await sesClient.send(command);
186-
console.log("Email sent! Message ID:", response.MessageId);
187-
return response.MessageId;
188-
} catch (error) {
189-
console.error("Failed to send email", error);
190-
throw error;
191-
}
192-
}
193-
194-
// Need to improve this. Use some kinda library to do this
195-
export async function sendEmailWithAttachments({
106+
export async function sendRawEmail({
196107
to,
197108
from,
198109
subject,
199110
replyTo,
200111
cc,
201112
bcc,
202113
// eslint-disable-next-line no-unused-vars
203-
text,
114+
text, // text is not used directly in raw email but kept for interface consistency
204115
html,
205116
attachments,
206117
region,
207118
configurationSetName,
119+
unsubUrl,
120+
isBulk,
121+
inReplyToMessageId,
208122
}: Partial<EmailContent> & {
209123
region: string;
210124
configurationSetName: string;
211-
attachments: { filename: string; content: string }[];
125+
attachments?: { filename: string; content: string }[]; // Made attachments optional
212126
cc?: string[];
213127
bcc?: string[];
214128
replyTo?: string[];
215129
to?: string[];
130+
unsubUrl?: string;
131+
isBulk?: boolean;
132+
inReplyToMessageId?: string;
216133
}) {
217134
const sesClient = getSesClient(region);
218-
const boundary = "NextPart";
135+
const boundary = `NextPart`;
219136
let rawEmail = `From: ${from}\n`;
220137
rawEmail += `To: ${Array.isArray(to) ? to.join(", ") : to}\n`;
221138
rawEmail += cc && cc.length ? `Cc: ${cc.join(", ")}\n` : "";
@@ -224,19 +141,37 @@ export async function sendEmailWithAttachments({
224141
replyTo && replyTo.length ? `Reply-To: ${replyTo.join(", ")}\n` : "";
225142
rawEmail += `Subject: ${subject}\n`;
226143
rawEmail += `MIME-Version: 1.0\n`;
144+
145+
// Add headers
146+
if (unsubUrl) {
147+
rawEmail += `List-Unsubscribe: <${unsubUrl}>\n`;
148+
rawEmail += `List-Unsubscribe-Post: List-Unsubscribe=One-Click\n`;
149+
}
150+
if (isBulk) {
151+
rawEmail += `Precedence: bulk\n`;
152+
}
153+
if (inReplyToMessageId) {
154+
rawEmail += `In-Reply-To: <${inReplyToMessageId}@email.amazonses.com>\n`;
155+
rawEmail += `References: <${inReplyToMessageId}@email.amazonses.com>\n`;
156+
}
157+
rawEmail += `X-Entity-Ref-ID: ${nanoid()}\n`;
158+
227159
rawEmail += `Content-Type: multipart/mixed; boundary="${boundary}"\n\n`;
228160
rawEmail += `--${boundary}\n`;
229161
rawEmail += `Content-Type: text/html; charset="UTF-8"\n\n`;
230162
rawEmail += `${html}\n\n`;
231-
for (const attachment of attachments) {
232-
const content = attachment.content; // Convert buffer to base64
233-
const mimeType =
234-
mime.lookup(attachment.filename) || "application/octet-stream";
235-
rawEmail += `--${boundary}\n`;
236-
rawEmail += `Content-Type: ${mimeType}; name="${attachment.filename}"\n`;
237-
rawEmail += `Content-Disposition: attachment; filename="${attachment.filename}"\n`;
238-
rawEmail += `Content-Transfer-Encoding: base64\n\n`;
239-
rawEmail += `${content}\n\n`;
163+
164+
if (attachments && attachments.length > 0) {
165+
for (const attachment of attachments) {
166+
const content = attachment.content; // Assumes content is base64
167+
const mimeType =
168+
mime.lookup(attachment.filename) || "application/octet-stream";
169+
rawEmail += `--${boundary}\n`;
170+
rawEmail += `Content-Type: ${mimeType}; name="${attachment.filename}"\n`;
171+
rawEmail += `Content-Disposition: attachment; filename="${attachment.filename}"\n`;
172+
rawEmail += `Content-Transfer-Encoding: base64\n\n`;
173+
rawEmail += `${content}\n\n`;
174+
}
240175
}
241176

242177
rawEmail += `--${boundary}--`;
@@ -252,11 +187,13 @@ export async function sendEmailWithAttachments({
252187

253188
try {
254189
const response = await sesClient.send(command);
255-
console.log("Email with attachments sent! Message ID:", response.MessageId);
190+
console.log("Email sent! Message ID:", response.MessageId);
256191
return response.MessageId;
257192
} catch (error) {
258-
console.error("Failed to send email with attachments", error);
259-
throw new Error("Failed to send email with attachments");
193+
console.error("Failed to send email", error);
194+
// It's better to throw the original error or a new error with more context
195+
// throw new Error("Failed to send email");
196+
throw error;
260197
}
261198
}
262199

apps/web/src/server/public-api/api/emails/send-email.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { createRoute, z } from "@hono/zod-openapi";
22
import { PublicAPIApp } from "~/server/public-api/hono";
3-
import { getTeamFromToken } from "~/server/public-api/auth";
43
import { sendEmail } from "~/server/service/email-service";
54
import { emailSchema } from "../../schemas/email-schema";
65

apps/web/src/server/public-api/hono.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ export function getApp() {
7070

7171
let currentRequests: number;
7272
let ttl: number;
73-
let isNewKey = false;
7473

7574
try {
7675
// Increment the key. If the key does not exist, it is created and set to 1.

apps/web/src/server/public-api/schemas/email-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const emailSchema = z
2929
.max(10) // Limit attachments array size if desired
3030
.optional(),
3131
scheduledAt: z.string().datetime({ offset: true }).optional(), // Ensure ISO 8601 format with offset
32+
inReplyToId: z.string().optional().nullable(),
3233
})
3334
.refine(
3435
(data) => !!data.subject || !!data.templateId,

apps/web/src/server/service/email-queue-service.ts

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { EmailAttachment } from "~/types";
44
import { convert as htmlToText } from "html-to-text";
55
import { getConfigurationSetName } from "~/utils/ses-utils";
66
import { db } from "../db";
7-
import { sendEmailThroughSes, sendEmailWithAttachments } from "../aws/ses";
7+
import { sendRawEmail } from "../aws/ses";
88
import { getRedis } from "../redis";
99
import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
1010
import { Prisma } from "@prisma/client";
@@ -331,34 +331,37 @@ async function executeEmail(
331331
? htmlToText(email.html)
332332
: undefined;
333333

334+
let inReplyToMessageId: string | undefined = undefined;
335+
336+
if (email.inReplyToId) {
337+
const replyEmail = await db.email.findUnique({
338+
where: {
339+
id: email.inReplyToId,
340+
},
341+
});
342+
343+
if (replyEmail && replyEmail.sesEmailId) {
344+
inReplyToMessageId = replyEmail.sesEmailId;
345+
}
346+
}
347+
334348
try {
335-
const messageId = attachments.length
336-
? await sendEmailWithAttachments({
337-
to: email.to,
338-
from: email.from,
339-
subject: email.subject,
340-
replyTo: email.replyTo ?? undefined,
341-
bcc: email.bcc,
342-
cc: email.cc,
343-
text,
344-
html: email.html ?? undefined,
345-
region: domain?.region ?? env.AWS_DEFAULT_REGION,
346-
configurationSetName,
347-
attachments,
348-
})
349-
: await sendEmailThroughSes({
350-
to: email.to,
351-
from: email.from,
352-
subject: email.subject,
353-
replyTo: email.replyTo ?? undefined,
354-
text,
355-
html: email.html ?? undefined,
356-
region: domain?.region ?? env.AWS_DEFAULT_REGION,
357-
configurationSetName,
358-
attachments,
359-
unsubUrl,
360-
isBulk,
361-
});
349+
const messageId = await sendRawEmail({
350+
to: email.to,
351+
from: email.from,
352+
subject: email.subject,
353+
replyTo: email.replyTo ?? undefined,
354+
bcc: email.bcc,
355+
cc: email.cc,
356+
text,
357+
html: email.html ?? undefined,
358+
region: domain?.region ?? env.AWS_DEFAULT_REGION,
359+
configurationSetName,
360+
attachments: attachments.length > 0 ? attachments : undefined,
361+
unsubUrl,
362+
isBulk,
363+
inReplyToMessageId,
364+
});
362365

363366
// Delete attachments after sending the email
364367
await db.email.update({

apps/web/src/server/service/email-service.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export async function sendEmail(
6363
bcc,
6464
scheduledAt,
6565
apiKeyId,
66+
inReplyToId,
6667
} = emailContent;
6768
let subject = subjectFromApiCall;
6869
let html = htmlFromApiCall;
@@ -99,6 +100,22 @@ export async function sendEmail(
99100
}
100101
}
101102

103+
if (inReplyToId) {
104+
const email = await db.email.findUnique({
105+
where: {
106+
id: inReplyToId,
107+
teamId,
108+
},
109+
});
110+
111+
if (!email) {
112+
throw new UnsendApiError({
113+
code: "BAD_REQUEST",
114+
message: '"inReplyTo" is invalid',
115+
});
116+
}
117+
}
118+
102119
if (!text && !html) {
103120
throw new UnsendApiError({
104121
code: "BAD_REQUEST",
@@ -131,6 +148,7 @@ export async function sendEmail(
131148
scheduledAt: scheduledAtDate,
132149
latestStatus: scheduledAtDate ? "SCHEDULED" : "QUEUED",
133150
apiId: apiKeyId,
151+
inReplyToId,
134152
},
135153
});
136154

0 commit comments

Comments
 (0)