diff --git a/apps/web/__tests__/outlook-operations.test.ts b/apps/web/__tests__/outlook-operations.test.ts index e05ee38ba5..e34de5df95 100644 --- a/apps/web/__tests__/outlook-operations.test.ts +++ b/apps/web/__tests__/outlook-operations.test.ts @@ -13,9 +13,11 @@ */ import { describe, test, expect, beforeAll, vi } from "vitest"; +import { NextRequest } from "next/server"; import prisma from "@/utils/prisma"; import { createEmailProvider } from "@/utils/email/provider"; import type { OutlookProvider } from "@/utils/email/microsoft"; +import { webhookBodySchema } from "@/app/api/outlook/webhook/types"; // ============================================ // TEST DATA - SET VIA ENVIRONMENT VARIABLES @@ -24,10 +26,17 @@ const TEST_OUTLOOK_EMAIL = process.env.TEST_OUTLOOK_EMAIL; const TEST_CONVERSATION_ID = process.env.TEST_CONVERSATION_ID || "AQQkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoAEABuo-fmt9KvQ4u55KlWB32H"; // Real conversation ID from demoinboxzero@outlook.com +const TEST_MESSAGE_ID = + process.env.TEST_MESSAGE_ID || + "AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA"; // Real message ID from demoinboxzero@outlook.com const TEST_CATEGORY_NAME = process.env.TEST_CATEGORY_NAME || "To Reply"; vi.mock("server-only", () => ({})); +vi.mock("@/utils/redis/message-processing", () => ({ + markMessageAsProcessing: vi.fn().mockResolvedValue(true), +})); + describe.skipIf(!TEST_OUTLOOK_EMAIL)( "Outlook Operations Integration Tests", () => { @@ -275,3 +284,169 @@ describe.skipIf(!TEST_OUTLOOK_EMAIL)( }); }, ); + +// ============================================ +// WEBHOOK PAYLOAD TESTS +// ============================================ +describe.skipIf(!TEST_OUTLOOK_EMAIL)("Outlook Webhook Payload", () => { + test("should validate real webhook payload structure", () => { + const realWebhookPayload = { + value: [ + { + subscriptionId: "d2d593e1-9600-4f72-8cd3-dfa04c707f9e", + subscriptionExpirationDateTime: "2025-10-09T15:32:19.8+00:00", + changeType: "updated", + resource: + "Users/faa95128258c6335/Messages/AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA", + resourceData: { + "@odata.type": "#Microsoft.Graph.Message", + "@odata.id": + "Users/faa95128258c6335/Messages/AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA", + "@odata.etag": 'W/"CQAAABYAAAD9/FqFaD10TIpRhdu6azgfAABF+9hk"', + id: "AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA", + }, + clientState: "05338492cb69f2facfe870450308f802", + tenantId: "", + }, + ], + }; + + // Validate against our schema + const result = webhookBodySchema.safeParse(realWebhookPayload); + + expect(result.success).toBe(true); + }); + + test("should process webhook and fetch conversationId from message", async () => { + // Clean slate: delete any existing executedRules for this message + const emailAccount = await prisma.emailAccount.findUniqueOrThrow({ + where: { email: TEST_OUTLOOK_EMAIL }, + }); + + await prisma.executedRule.deleteMany({ + where: { + emailAccountId: emailAccount.id, + messageId: TEST_MESSAGE_ID, + }, + }); + + // This test requires a real Outlook account + const { POST } = await import("@/app/api/outlook/webhook/route"); + + const realWebhookPayload = { + value: [ + { + subscriptionId: "d2d593e1-9600-4f72-8cd3-dfa04c707f9e", + subscriptionExpirationDateTime: "2025-10-09T15:32:19.8+00:00", + changeType: "updated", + resource: `Users/faa95128258c6335/Messages/${TEST_MESSAGE_ID}`, + resourceData: { + "@odata.type": "#Microsoft.Graph.Message", + "@odata.id": `Users/faa95128258c6335/Messages/${TEST_MESSAGE_ID}`, + "@odata.etag": 'W/"CQAAABYAAAD9/FqFaD10TIpRhdu6azgfAABF+9hk"', + id: TEST_MESSAGE_ID, + }, + clientState: process.env.MICROSOFT_WEBHOOK_CLIENT_STATE, + tenantId: "", + }, + ], + }; + + // Create a mock Request object + const mockRequest = new NextRequest( + "http://localhost:3000/api/outlook/webhook", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(realWebhookPayload), + }, + ); + + // Call the webhook handler + const response = await POST(mockRequest, { + params: new Promise(() => ({})), + }); + + // Verify webhook processed successfully + expect(response.status).toBe(200); + + const responseData = await response.json(); + expect(responseData).toEqual({ ok: true }); + + console.log(" ✅ Webhook processed successfully"); + + // Verify an executedRule was created for this message + const thirtySecondsAgo = new Date(Date.now() - 30_000); + + const executedRule = await prisma.executedRule.findFirst({ + where: { + messageId: TEST_MESSAGE_ID, + createdAt: { + gte: thirtySecondsAgo, + }, + }, + include: { + rule: { + select: { + name: true, + }, + }, + actionItems: { + where: { + draftId: { + not: null, + }, + }, + }, + }, + }); + + expect(executedRule).not.toBeNull(); + expect(executedRule).toBeDefined(); + + if (!executedRule) { + throw new Error("ExecutedRule is null"); + } + + console.log(" ✅ ExecutedRule created successfully"); + console.log(` Rule: ${executedRule.rule?.name || "(no rule)"}`); + console.log(` Rule ID: ${executedRule.ruleId || "(no rule id)"}`); + + // Check if a draft was created + const draftAction = executedRule.actionItems.find((a) => a.draftId); + if (draftAction?.draftId) { + const emailAccount = await prisma.emailAccount.findUniqueOrThrow({ + where: { email: TEST_OUTLOOK_EMAIL }, + }); + + const provider = (await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: "microsoft", + })) as OutlookProvider; + + const draft = await provider.getDraft(draftAction.draftId); + + expect(draft).toBeDefined(); + + // Verify draft is actually a reply, not a fresh draft + expect(draft?.threadId).toBeTruthy(); + expect(draft?.threadId).not.toBe(""); + + console.log(" ✅ Draft created successfully"); + console.log(` Draft ID: ${draftAction.draftId}`); + console.log(` Thread ID: ${draft?.threadId}`); + console.log(` Subject: ${draft?.subject || "(no subject)"}`); + console.log(" Content:"); + console.log( + ` ${draft?.textPlain?.substring(0, 200).replace(/\n/g, "\n ") || "(empty)"}`, + ); + if (draft?.textPlain && draft.textPlain.length > 200) { + console.log(` ... (${draft.textPlain.length} total characters)`); + } + } else { + console.log(" ℹ️ No draft action found"); + } + }, 30_000); +}); diff --git a/apps/web/app/api/outlook/webhook/process-history-item.ts b/apps/web/app/api/outlook/webhook/process-history-item.ts index 616b271ace..aa8afa26f6 100644 --- a/apps/web/app/api/outlook/webhook/process-history-item.ts +++ b/apps/web/app/api/outlook/webhook/process-history-item.ts @@ -53,19 +53,20 @@ export async function processHistoryItem( logger.info("Getting message", loggerOptions); try { - const [parsedMessage, hasExistingRule] = await Promise.all([ - provider.getMessage(messageId), - prisma.executedRule.findUnique({ - where: { - unique_emailAccount_thread_message: { - emailAccountId, - threadId: resourceData.conversationId || messageId, - messageId, - }, + const parsedMessage = await provider.getMessage(messageId); + + const threadId = parsedMessage.threadId; + + const hasExistingRule = await prisma.executedRule.findUnique({ + where: { + unique_emailAccount_thread_message: { + emailAccountId, + threadId, + messageId, }, - select: { id: true }, - }), - ]); + }, + select: { id: true }, + }); // if the rule has already been executed, skip if (hasExistingRule) { @@ -116,7 +117,7 @@ export async function processHistoryItem( return processAssistantEmail({ message: { id: messageId, - threadId: resourceData.conversationId || messageId, + threadId, headers: { from, to: to.join(","), @@ -156,7 +157,7 @@ export async function processHistoryItem( parsedMessage, provider, messageId, - resourceData.conversationId || undefined, + threadId, ); return; } @@ -188,7 +189,7 @@ export async function processHistoryItem( const response = await runColdEmailBlocker({ email: { ...emailForLLM, - threadId: resourceData.conversationId || messageId, + threadId, date: parsedMessage.date ? new Date(parsedMessage.date) : new Date(), }, provider, @@ -224,7 +225,7 @@ export async function processHistoryItem( provider, message: { id: messageId, - threadId: resourceData.conversationId || messageId, + threadId, headers: { from, to: to.join(","), diff --git a/apps/web/app/api/outlook/webhook/types.ts b/apps/web/app/api/outlook/webhook/types.ts index cf7be2253a..25c883b030 100644 --- a/apps/web/app/api/outlook/webhook/types.ts +++ b/apps/web/app/api/outlook/webhook/types.ts @@ -16,13 +16,15 @@ export type ProcessHistoryOptions = { EmailAccountWithAI; }; +// https://learn.microsoft.com/en-us/graph/api/resources/resourcedata?view=graph-rest-1.0 const resourceDataSchema = z .object({ - id: z.string(), - folderId: z.string().nullish(), - conversationId: z.string().nullish(), + "@odata.type": z.string().optional(), + "@odata.id": z.string().optional(), + "@odata.etag": z.string().optional(), + id: z.string(), // The message identifier }) - .passthrough(); // Allow additional properties + .passthrough(); // Allow additional properties from other notification types const notificationSchema = z.object({ subscriptionId: z.string(), diff --git a/apps/web/utils/outlook/mail.ts b/apps/web/utils/outlook/mail.ts index 66c183a1e6..a0c56f5153 100644 --- a/apps/web/utils/outlook/mail.ts +++ b/apps/web/utils/outlook/mail.ts @@ -186,29 +186,34 @@ export async function draftEmail( emailAddress: { address: addr }, })); - const draft = { - subject: args.subject || originalEmail.headers.subject, - body: { - contentType: "html", - content: html, - }, - toRecipients: [ - { - emailAddress: { - address: recipients.to, - }, - }, - ], - ...(ccRecipients.length > 0 ? { ccRecipients } : {}), - conversationId: originalEmail.threadId, - isDraft: true, - }; + // Use createReply endpoint to create a proper reply draft + // This ensures the draft is linked to the original message as a reply + const replyDraft: Message = await client + .getClient() + .api(`/me/messages/${originalEmail.id}/createReply`) + .post({}); - const result: Message = await client + // Update the draft with our content + const updatedDraft: Message = await client .getClient() - .api("/me/messages") - .post(draft); - return result; + .api(`/me/messages/${replyDraft.id}`) + .patch({ + subject: args.subject || originalEmail.headers.subject, + body: { + contentType: "html", + content: html, + }, + toRecipients: [ + { + emailAddress: { + address: recipients.to, + }, + }, + ], + ...(ccRecipients.length > 0 ? { ccRecipients } : {}), + }); + + return updatedDraft; } function convertTextToHtmlParagraphs(text?: string | null): string { diff --git a/apps/web/utils/outlook/message.ts b/apps/web/utils/outlook/message.ts index 93288eaab5..cb0dad7157 100644 --- a/apps/web/utils/outlook/message.ts +++ b/apps/web/utils/outlook/message.ts @@ -108,7 +108,6 @@ export async function getFolderIds(client: OutlookClient) { {} as Record, ); - logger.info("Fetched Outlook folder IDs", { folders: folderIdCache }); return folderIdCache; } diff --git a/version.txt b/version.txt index 0f5b24f1ab..90ba1a238a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.15.1 +v2.15.2