diff --git a/apps/web/__tests__/e2e/flows/helpers/email.ts b/apps/web/__tests__/e2e/flows/helpers/email.ts index 0f3dc39f1a..1f6999f198 100644 --- a/apps/web/__tests__/e2e/flows/helpers/email.ts +++ b/apps/web/__tests__/e2e/flows/helpers/email.ts @@ -91,6 +91,7 @@ export async function sendTestReply(options: { threadId, headerMessageId: originalMessage.headers["message-id"] || "", references: originalMessage.headers.references, + messageId: originalMessageId, // Needed for Outlook's createReply API }, }); diff --git a/apps/web/__tests__/e2e/flows/outbound-tracking.test.ts b/apps/web/__tests__/e2e/flows/outbound-tracking.test.ts index b976a4bc06..05e783bbe1 100644 --- a/apps/web/__tests__/e2e/flows/outbound-tracking.test.ts +++ b/apps/web/__tests__/e2e/flows/outbound-tracking.test.ts @@ -230,15 +230,21 @@ describe.skipIf(!shouldRunFlowTests())("Outbound Message Tracking", () => { where: { emailAccountId: outlook.id, threadId: receivedMessage.threadId, + resolved: false, }, }); logStep("ThreadTracker before reply", { + id: trackerBeforeReply?.id, exists: !!trackerBeforeReply, resolved: trackerBeforeReply?.resolved, type: trackerBeforeReply?.type, }); + // Store the tracker ID to verify it gets resolved + const originalTrackerId = trackerBeforeReply?.id; + expect(originalTrackerId).toBeDefined(); + // ======================================== // Send reply // ======================================== @@ -260,21 +266,20 @@ describe.skipIf(!shouldRunFlowTests())("Outbound Message Tracking", () => { // Wait for outbound processing to mark tracker as resolved await new Promise((resolve) => setTimeout(resolve, 10_000)); - // Verify the thread is now marked as "replied to" - const threadTracker = await prisma.threadTracker.findFirst({ - where: { - emailAccountId: outlook.id, - threadId: receivedMessage.threadId, - }, + // Verify the ORIGINAL tracker is now resolved + // Note: A new AWAITING_REPLY tracker may be created, so we must check + // the specific tracker that existed before the reply + const resolvedTracker = await prisma.threadTracker.findUnique({ + where: { id: originalTrackerId! }, }); - // Thread tracker should exist and be marked as resolved after reply - expect(threadTracker).toBeDefined(); - expect(threadTracker?.resolved).toBe(true); + expect(resolvedTracker).toBeDefined(); + expect(resolvedTracker?.resolved).toBe(true); - logStep("Reply tracking found", { - resolved: threadTracker?.resolved, - type: threadTracker?.type, + logStep("Original tracker now resolved", { + id: resolvedTracker?.id, + resolved: resolvedTracker?.resolved, + type: resolvedTracker?.type, }); }, TIMEOUTS.FULL_CYCLE, diff --git a/apps/web/utils/email/threading.test.ts b/apps/web/utils/email/threading.test.ts new file mode 100644 index 0000000000..e8ce708e3c --- /dev/null +++ b/apps/web/utils/email/threading.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { buildThreadingHeaders } from "./threading"; + +describe("buildThreadingHeaders", () => { + it("returns empty strings when headerMessageId is empty", () => { + const result = buildThreadingHeaders({ headerMessageId: "" }); + expect(result).toEqual({ inReplyTo: "", references: "" }); + }); + + it("returns empty strings when headerMessageId is falsy", () => { + const result = buildThreadingHeaders({ + headerMessageId: undefined as unknown as string, + }); + expect(result).toEqual({ inReplyTo: "", references: "" }); + }); + + it("uses headerMessageId for both fields when no references provided", () => { + const messageId = ""; + const result = buildThreadingHeaders({ headerMessageId: messageId }); + + expect(result).toEqual({ + inReplyTo: messageId, + references: messageId, + }); + }); + + it("appends headerMessageId to existing references (RFC 5322)", () => { + const messageId = ""; + const existingRefs = " "; + + const result = buildThreadingHeaders({ + headerMessageId: messageId, + references: existingRefs, + }); + + expect(result).toEqual({ + inReplyTo: messageId, + references: " ", + }); + }); + + it("handles references with trailing whitespace", () => { + const messageId = ""; + const existingRefs = " "; // trailing spaces + + const result = buildThreadingHeaders({ + headerMessageId: messageId, + references: existingRefs, + }); + + // .trim() should clean up the result + expect(result.references).toBe( + " ".trim(), + ); + }); + + it("handles empty string references", () => { + const messageId = ""; + const result = buildThreadingHeaders({ + headerMessageId: messageId, + references: "", + }); + + // Empty string is falsy, so should use headerMessageId only + expect(result).toEqual({ + inReplyTo: messageId, + references: messageId, + }); + }); +}); diff --git a/apps/web/utils/email/threading.ts b/apps/web/utils/email/threading.ts new file mode 100644 index 0000000000..57fa36189f --- /dev/null +++ b/apps/web/utils/email/threading.ts @@ -0,0 +1,20 @@ +/** + * Build RFC 5322 compliant email threading headers. + * References = parent's References + parent's Message-ID + * https://datatracker.ietf.org/doc/html/rfc5322#appendix-A.2 + */ +export function buildThreadingHeaders(options: { + headerMessageId: string; + references?: string; +}): { inReplyTo: string; references: string } { + if (!options.headerMessageId) { + return { inReplyTo: "", references: "" }; + } + + return { + inReplyTo: options.headerMessageId, + references: options.references + ? `${options.references} ${options.headerMessageId}`.trim() + : options.headerMessageId, + }; +} diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index fcacbbc210..f51f493e66 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -127,6 +127,7 @@ export interface EmailProvider { threadId: string; headerMessageId: string; references?: string; + messageId?: string; // Platform-specific message ID (Graph ID for Outlook) }; to: string; cc?: string; diff --git a/apps/web/utils/gmail/mail.ts b/apps/web/utils/gmail/mail.ts index b7c980af54..28d220e0e0 100644 --- a/apps/web/utils/gmail/mail.ts +++ b/apps/web/utils/gmail/mail.ts @@ -21,6 +21,7 @@ import { mergeAndDedupeRecipients, } from "@/utils/email/reply-all"; import { formatReplySubject } from "@/utils/email/subject"; +import { buildThreadingHeaders } from "@/utils/email/threading"; import { ensureEmailSendingEnabled } from "@/utils/mail"; const logger = createScopedLogger("gmail/mail"); @@ -31,6 +32,7 @@ export const sendEmailBody = z.object({ threadId: z.string(), headerMessageId: z.string(), // this is different to the gmail message id and looks something like <123...abc@mail.example.com> references: z.string().optional(), // for threading + messageId: z.string().optional(), // platform-specific message ID (Graph ID for Outlook) }) .optional(), to: z.string(), @@ -91,10 +93,10 @@ const createRawMailMessage = async ( ], attachments, // https://datatracker.ietf.org/doc/html/rfc2822#appendix-A.2 - references: replyToEmail - ? `${replyToEmail.references || ""} ${replyToEmail.headerMessageId}`.trim() - : "", - inReplyTo: replyToEmail ? replyToEmail.headerMessageId : "", + ...buildThreadingHeaders({ + headerMessageId: replyToEmail?.headerMessageId || "", + references: replyToEmail?.references, + }), headers: { "X-Mailer": "Inbox Zero Web", }, diff --git a/apps/web/utils/outlook/mail.ts b/apps/web/utils/outlook/mail.ts index 5b7d8f2bfe..85011d01fe 100644 --- a/apps/web/utils/outlook/mail.ts +++ b/apps/web/utils/outlook/mail.ts @@ -10,7 +10,6 @@ import { buildReplyAllRecipients, mergeAndDedupeRecipients, } from "@/utils/email/reply-all"; -import { formatReplySubject } from "@/utils/email/subject"; import { withOutlookRetry } from "@/utils/outlook/retry"; import { extractEmailAddress, extractNameFromEmail } from "@/utils/email"; import { ensureEmailSendingEnabled } from "@/utils/mail"; @@ -26,8 +25,6 @@ interface OutlookMessageRequest { ccRecipients?: { emailAddress: { address: string } }[]; bccRecipients?: { emailAddress: { address: string } }[]; replyTo?: { emailAddress: { address: string } }[]; - conversationId?: string; - isDraft?: boolean; } type SentEmailResult = Pick; @@ -39,6 +36,13 @@ export async function sendEmailWithHtml( ): Promise { ensureEmailSendingEnabled(); + // For replies with a message ID, use createReply for proper threading + // Microsoft Graph's sendMail doesn't support In-Reply-To/References headers + if (body.replyToEmail?.messageId) { + return sendReplyUsingCreateReply(client, body, logger); + } + + // For new emails (no reply context), use sendMail const message: OutlookMessageRequest = { subject: body.subject, body: { @@ -57,10 +61,6 @@ export async function sendEmailWithHtml( : {}), }; - if (body.replyToEmail?.threadId) { - message.conversationId = body.replyToEmail.threadId; - } - await withOutlookRetry( () => client.getClient().api("/me/sendMail").post({ @@ -70,13 +70,10 @@ export async function sendEmailWithHtml( logger, ); - // /me/sendMail returns 202 with no body, so we can't get the sent message ID. - // Graph doesn't support filtering by internetMessageHeaders, so we can't query for it. - // conversationId (threadId) is preserved - that's what matters for reply tracking. - // Empty id means auto-expand won't work in EmailThread, but we don't show that for Outlook. + // sendMail returns 202 with no body, so we can't get the sent message ID return { id: "", - conversationId: message.conversationId, + conversationId: body.replyToEmail?.threadId, }; } @@ -95,40 +92,48 @@ export async function replyToEmail( reply: string, logger: Logger, ) { + ensureEmailSendingEnabled(); + const { html } = createOutlookReplyContent({ textContent: reply, message, }); - // Only replying to the original sender - const replyMessage = { - subject: formatReplySubject(message.headers.subject), - body: { - contentType: "html", - content: html, - }, - toRecipients: [ - { - emailAddress: { - address: message.headers["reply-to"] || message.headers.from, - }, - }, - ], - conversationId: message.threadId, - }; - - ensureEmailSendingEnabled(); + // Use createReply to create a properly threaded draft + // Microsoft Graph's sendMail doesn't support setting In-Reply-To/References headers + // Only createReply/createReplyAll endpoints ensure proper threading + const replyDraft: Message = await withOutlookRetry( + () => + client.getClient().api(`/me/messages/${message.id}/createReply`).post({}), + logger, + ); - // Send the email immediately using the sendMail endpoint - const result = await withOutlookRetry( + // Update the draft with our content + await withOutlookRetry( () => - client.getClient().api("/me/sendMail").post({ - message: replyMessage, - saveToSentItems: true, - }), + client + .getClient() + .api(`/me/messages/${replyDraft.id}`) + .patch({ + body: { + contentType: "html", + content: html, + }, + }), logger, ); - return result; + + // Send the draft + await withOutlookRetry( + () => client.getClient().api(`/me/messages/${replyDraft.id}/send`).post({}), + logger, + ); + + // Draft ID is no longer valid after /send; Graph doesn't return sent message ID + return { + id: "", + conversationId: replyDraft.conversationId, + }; } export async function forwardEmail( @@ -333,3 +338,56 @@ function convertTextToHtmlParagraphs(text?: string | null): string { return `${htmlContent}`; } + +async function sendReplyUsingCreateReply( + client: OutlookClient, + body: SendEmailBody, + logger: Logger, +): Promise { + const originalMessageId = body.replyToEmail!.messageId!; + + // Use createReply to create a properly threaded draft + const replyDraft: Message = await withOutlookRetry( + () => + client + .getClient() + .api(`/me/messages/${originalMessageId}/createReply`) + .post({}), + logger, + ); + + // Update the draft with our content and recipients + await withOutlookRetry( + () => + client + .getClient() + .api(`/me/messages/${replyDraft.id}`) + .patch({ + subject: body.subject, + body: { + contentType: "html", + content: body.messageHtml, + }, + toRecipients: [{ emailAddress: { address: body.to } }], + ...(body.cc + ? { ccRecipients: [{ emailAddress: { address: body.cc } }] } + : {}), + ...(body.bcc + ? { bccRecipients: [{ emailAddress: { address: body.bcc } }] } + : {}), + }), + logger, + ); + + // Send the draft + await withOutlookRetry( + () => client.getClient().api(`/me/messages/${replyDraft.id}/send`).post({}), + logger, + ); + + // Draft ID is no longer valid after /send; Graph doesn't return sent message ID + return { + id: "", + conversationId: replyDraft.conversationId, + }; +}