diff --git a/apps/web/__tests__/e2e/outlook-draft-read-status.test.ts b/apps/web/__tests__/e2e/outlook-draft-read-status.test.ts new file mode 100644 index 0000000000..484c5cb7ec --- /dev/null +++ b/apps/web/__tests__/e2e/outlook-draft-read-status.test.ts @@ -0,0 +1,98 @@ +/** + * E2E test to verify our Outlook draft implementation doesn't mark emails as read + * + * Microsoft Graph's createReplyAll endpoint has an undocumented side effect: + * it marks the original message as read. Our implementation works around this + * by restoring the original read status after creating the draft. + * + * Usage: pnpm test-e2e outlook-draft-read-status + * Make sure TEST_OUTLOOK_EMAIL=you@email.com is set in .env.test + */ + +import { beforeAll, describe, expect, test, vi } from "vitest"; +import prisma from "@/utils/prisma"; +import { createEmailProvider } from "@/utils/email/provider"; +import { findOldMessage } from "@/__tests__/e2e/helpers"; +import type { EmailProvider } from "@/utils/email/types"; + +const RUN_E2E_TESTS = process.env.RUN_E2E_TESTS; +const TEST_OUTLOOK_EMAIL = process.env.TEST_OUTLOOK_EMAIL; + +vi.mock("server-only", () => ({})); + +describe.skipIf(!RUN_E2E_TESTS)( + "Outlook Draft Read Status Preservation", + () => { + let provider: EmailProvider; + let emailAccountEmail: string; + + beforeAll(async () => { + if (!TEST_OUTLOOK_EMAIL) { + console.warn("Set TEST_OUTLOOK_EMAIL env var to run these tests"); + return; + } + + const emailAccount = await prisma.emailAccount.findFirst({ + where: { + email: TEST_OUTLOOK_EMAIL, + account: { provider: "microsoft" }, + }, + include: { account: true }, + }); + + if (!emailAccount) { + throw new Error(`No Outlook account found for ${TEST_OUTLOOK_EMAIL}`); + } + + provider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: "microsoft", + }); + + emailAccountEmail = emailAccount.email; + }); + + test("should preserve unread status when creating draft reply", async () => { + if (!provider) { + throw new Error("Email provider not initialized"); + } + + const testMessage = await findOldMessage(provider, 7); + const originalMessage = await provider.getMessage(testMessage.messageId); + const wasOriginallyUnread = + originalMessage.labelIds?.includes("UNREAD") ?? false; + + let draftId: string | undefined; + + try { + // Mark as unread for the test + await provider.markReadThread(testMessage.threadId, false); + + // Verify unread status before creating draft + const beforeDraft = await provider.getMessage(testMessage.messageId); + expect(beforeDraft.labelIds).toContain("UNREAD"); + + // Create draft reply - our implementation should NOT mark the original as read + const draftResult = await provider.draftEmail( + beforeDraft, + { content: "Test draft for read status verification" }, + emailAccountEmail, + ); + draftId = draftResult.draftId; + + // Message should still be unread after draft creation + const afterDraft = await provider.getMessage(testMessage.messageId); + expect(afterDraft.labelIds).toContain("UNREAD"); + } finally { + // Cleanup: restore original state + if (draftId) { + await provider.deleteDraft(draftId); + } + await provider.markReadThread( + testMessage.threadId, + !wasOriginallyUnread, + ); + } + }, 30_000); + }, +); diff --git a/apps/web/utils/outlook/mail.ts b/apps/web/utils/outlook/mail.ts index 4464cd7a96..f613026b46 100644 --- a/apps/web/utils/outlook/mail.ts +++ b/apps/web/utils/outlook/mail.ts @@ -208,6 +208,17 @@ export async function draftEmail( }, })); + // Get the original message's isRead status before creating the draft + // Microsoft Graph's createReplyAll automatically marks the original as read + const originalMessage: Message = await withOutlookRetry(() => + client + .getClient() + .api(`/me/messages/${originalEmail.id}`) + .select("isRead") + .get(), + ); + const wasUnread = originalMessage.isRead === false; + // Use createReplyAll endpoint to create a proper reply draft // This ensures the draft is linked to the original message as a reply all const replyDraft: Message = await withOutlookRetry(() => @@ -238,6 +249,17 @@ export async function draftEmail( }), ); + // Restore the original message's unread status if it was unread before + // createReplyAll automatically marks the original message as read + if (wasUnread) { + await withOutlookRetry(() => + client + .getClient() + .api(`/me/messages/${originalEmail.id}`) + .patch({ isRead: false }), + ); + } + // Use the original replyDraft.id since that's the stable ID // The PATCH response might not always include the full object? return { ...updatedDraft, id: replyDraft.id }; diff --git a/version.txt b/version.txt index 22b8db2cdf..d89fcbe0af 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.21.62 +v2.21.63