Skip to content

Commit fa85105

Browse files
authored
Merge pull request #836 from elie222/fix/outlook-webhook
2 parents 2162f87 + 45d6a3e commit fa85105

File tree

6 files changed

+225
-43
lines changed

6 files changed

+225
-43
lines changed

apps/web/__tests__/outlook-operations.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
*/
1414

1515
import { describe, test, expect, beforeAll, vi } from "vitest";
16+
import { NextRequest } from "next/server";
1617
import prisma from "@/utils/prisma";
1718
import { createEmailProvider } from "@/utils/email/provider";
1819
import type { OutlookProvider } from "@/utils/email/microsoft";
20+
import { webhookBodySchema } from "@/app/api/outlook/webhook/types";
1921

2022
// ============================================
2123
// TEST DATA - SET VIA ENVIRONMENT VARIABLES
@@ -24,10 +26,17 @@ const TEST_OUTLOOK_EMAIL = process.env.TEST_OUTLOOK_EMAIL;
2426
const TEST_CONVERSATION_ID =
2527
process.env.TEST_CONVERSATION_ID ||
2628
"AQQkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoAEABuo-fmt9KvQ4u55KlWB32H"; // Real conversation ID from demoinboxzero@outlook.com
29+
const TEST_MESSAGE_ID =
30+
process.env.TEST_MESSAGE_ID ||
31+
"AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA"; // Real message ID from demoinboxzero@outlook.com
2732
const TEST_CATEGORY_NAME = process.env.TEST_CATEGORY_NAME || "To Reply";
2833

2934
vi.mock("server-only", () => ({}));
3035

36+
vi.mock("@/utils/redis/message-processing", () => ({
37+
markMessageAsProcessing: vi.fn().mockResolvedValue(true),
38+
}));
39+
3140
describe.skipIf(!TEST_OUTLOOK_EMAIL)(
3241
"Outlook Operations Integration Tests",
3342
() => {
@@ -275,3 +284,169 @@ describe.skipIf(!TEST_OUTLOOK_EMAIL)(
275284
});
276285
},
277286
);
287+
288+
// ============================================
289+
// WEBHOOK PAYLOAD TESTS
290+
// ============================================
291+
describe.skipIf(!TEST_OUTLOOK_EMAIL)("Outlook Webhook Payload", () => {
292+
test("should validate real webhook payload structure", () => {
293+
const realWebhookPayload = {
294+
value: [
295+
{
296+
subscriptionId: "d2d593e1-9600-4f72-8cd3-dfa04c707f9e",
297+
subscriptionExpirationDateTime: "2025-10-09T15:32:19.8+00:00",
298+
changeType: "updated",
299+
resource:
300+
"Users/faa95128258c6335/Messages/AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA",
301+
resourceData: {
302+
"@odata.type": "#Microsoft.Graph.Message",
303+
"@odata.id":
304+
"Users/faa95128258c6335/Messages/AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA",
305+
"@odata.etag": 'W/"CQAAABYAAAD9/FqFaD10TIpRhdu6azgfAABF+9hk"',
306+
id: "AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA",
307+
},
308+
clientState: "05338492cb69f2facfe870450308f802",
309+
tenantId: "",
310+
},
311+
],
312+
};
313+
314+
// Validate against our schema
315+
const result = webhookBodySchema.safeParse(realWebhookPayload);
316+
317+
expect(result.success).toBe(true);
318+
});
319+
320+
test("should process webhook and fetch conversationId from message", async () => {
321+
// Clean slate: delete any existing executedRules for this message
322+
const emailAccount = await prisma.emailAccount.findUniqueOrThrow({
323+
where: { email: TEST_OUTLOOK_EMAIL },
324+
});
325+
326+
await prisma.executedRule.deleteMany({
327+
where: {
328+
emailAccountId: emailAccount.id,
329+
messageId: TEST_MESSAGE_ID,
330+
},
331+
});
332+
333+
// This test requires a real Outlook account
334+
const { POST } = await import("@/app/api/outlook/webhook/route");
335+
336+
const realWebhookPayload = {
337+
value: [
338+
{
339+
subscriptionId: "d2d593e1-9600-4f72-8cd3-dfa04c707f9e",
340+
subscriptionExpirationDateTime: "2025-10-09T15:32:19.8+00:00",
341+
changeType: "updated",
342+
resource: `Users/faa95128258c6335/Messages/${TEST_MESSAGE_ID}`,
343+
resourceData: {
344+
"@odata.type": "#Microsoft.Graph.Message",
345+
"@odata.id": `Users/faa95128258c6335/Messages/${TEST_MESSAGE_ID}`,
346+
"@odata.etag": 'W/"CQAAABYAAAD9/FqFaD10TIpRhdu6azgfAABF+9hk"',
347+
id: TEST_MESSAGE_ID,
348+
},
349+
clientState: process.env.MICROSOFT_WEBHOOK_CLIENT_STATE,
350+
tenantId: "",
351+
},
352+
],
353+
};
354+
355+
// Create a mock Request object
356+
const mockRequest = new NextRequest(
357+
"http://localhost:3000/api/outlook/webhook",
358+
{
359+
method: "POST",
360+
headers: {
361+
"Content-Type": "application/json",
362+
},
363+
body: JSON.stringify(realWebhookPayload),
364+
},
365+
);
366+
367+
// Call the webhook handler
368+
const response = await POST(mockRequest, {
369+
params: new Promise(() => ({})),
370+
});
371+
372+
// Verify webhook processed successfully
373+
expect(response.status).toBe(200);
374+
375+
const responseData = await response.json();
376+
expect(responseData).toEqual({ ok: true });
377+
378+
console.log(" ✅ Webhook processed successfully");
379+
380+
// Verify an executedRule was created for this message
381+
const thirtySecondsAgo = new Date(Date.now() - 30_000);
382+
383+
const executedRule = await prisma.executedRule.findFirst({
384+
where: {
385+
messageId: TEST_MESSAGE_ID,
386+
createdAt: {
387+
gte: thirtySecondsAgo,
388+
},
389+
},
390+
include: {
391+
rule: {
392+
select: {
393+
name: true,
394+
},
395+
},
396+
actionItems: {
397+
where: {
398+
draftId: {
399+
not: null,
400+
},
401+
},
402+
},
403+
},
404+
});
405+
406+
expect(executedRule).not.toBeNull();
407+
expect(executedRule).toBeDefined();
408+
409+
if (!executedRule) {
410+
throw new Error("ExecutedRule is null");
411+
}
412+
413+
console.log(" ✅ ExecutedRule created successfully");
414+
console.log(` Rule: ${executedRule.rule?.name || "(no rule)"}`);
415+
console.log(` Rule ID: ${executedRule.ruleId || "(no rule id)"}`);
416+
417+
// Check if a draft was created
418+
const draftAction = executedRule.actionItems.find((a) => a.draftId);
419+
if (draftAction?.draftId) {
420+
const emailAccount = await prisma.emailAccount.findUniqueOrThrow({
421+
where: { email: TEST_OUTLOOK_EMAIL },
422+
});
423+
424+
const provider = (await createEmailProvider({
425+
emailAccountId: emailAccount.id,
426+
provider: "microsoft",
427+
})) as OutlookProvider;
428+
429+
const draft = await provider.getDraft(draftAction.draftId);
430+
431+
expect(draft).toBeDefined();
432+
433+
// Verify draft is actually a reply, not a fresh draft
434+
expect(draft?.threadId).toBeTruthy();
435+
expect(draft?.threadId).not.toBe("");
436+
437+
console.log(" ✅ Draft created successfully");
438+
console.log(` Draft ID: ${draftAction.draftId}`);
439+
console.log(` Thread ID: ${draft?.threadId}`);
440+
console.log(` Subject: ${draft?.subject || "(no subject)"}`);
441+
console.log(" Content:");
442+
console.log(
443+
` ${draft?.textPlain?.substring(0, 200).replace(/\n/g, "\n ") || "(empty)"}`,
444+
);
445+
if (draft?.textPlain && draft.textPlain.length > 200) {
446+
console.log(` ... (${draft.textPlain.length} total characters)`);
447+
}
448+
} else {
449+
console.log(" ℹ️ No draft action found");
450+
}
451+
}, 30_000);
452+
});

apps/web/app/api/outlook/webhook/process-history-item.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,20 @@ export async function processHistoryItem(
5353
logger.info("Getting message", loggerOptions);
5454

5555
try {
56-
const [parsedMessage, hasExistingRule] = await Promise.all([
57-
provider.getMessage(messageId),
58-
prisma.executedRule.findUnique({
59-
where: {
60-
unique_emailAccount_thread_message: {
61-
emailAccountId,
62-
threadId: resourceData.conversationId || messageId,
63-
messageId,
64-
},
56+
const parsedMessage = await provider.getMessage(messageId);
57+
58+
const threadId = parsedMessage.threadId;
59+
60+
const hasExistingRule = await prisma.executedRule.findUnique({
61+
where: {
62+
unique_emailAccount_thread_message: {
63+
emailAccountId,
64+
threadId,
65+
messageId,
6566
},
66-
select: { id: true },
67-
}),
68-
]);
67+
},
68+
select: { id: true },
69+
});
6970

7071
// if the rule has already been executed, skip
7172
if (hasExistingRule) {
@@ -116,7 +117,7 @@ export async function processHistoryItem(
116117
return processAssistantEmail({
117118
message: {
118119
id: messageId,
119-
threadId: resourceData.conversationId || messageId,
120+
threadId,
120121
headers: {
121122
from,
122123
to: to.join(","),
@@ -156,7 +157,7 @@ export async function processHistoryItem(
156157
parsedMessage,
157158
provider,
158159
messageId,
159-
resourceData.conversationId || undefined,
160+
threadId,
160161
);
161162
return;
162163
}
@@ -188,7 +189,7 @@ export async function processHistoryItem(
188189
const response = await runColdEmailBlocker({
189190
email: {
190191
...emailForLLM,
191-
threadId: resourceData.conversationId || messageId,
192+
threadId,
192193
date: parsedMessage.date ? new Date(parsedMessage.date) : new Date(),
193194
},
194195
provider,
@@ -224,7 +225,7 @@ export async function processHistoryItem(
224225
provider,
225226
message: {
226227
id: messageId,
227-
threadId: resourceData.conversationId || messageId,
228+
threadId,
228229
headers: {
229230
from,
230231
to: to.join(","),

apps/web/app/api/outlook/webhook/types.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ export type ProcessHistoryOptions = {
1616
EmailAccountWithAI;
1717
};
1818

19+
// https://learn.microsoft.com/en-us/graph/api/resources/resourcedata?view=graph-rest-1.0
1920
const resourceDataSchema = z
2021
.object({
21-
id: z.string(),
22-
folderId: z.string().nullish(),
23-
conversationId: z.string().nullish(),
22+
"@odata.type": z.string().optional(),
23+
"@odata.id": z.string().optional(),
24+
"@odata.etag": z.string().optional(),
25+
id: z.string(), // The message identifier
2426
})
25-
.passthrough(); // Allow additional properties
27+
.passthrough(); // Allow additional properties from other notification types
2628

2729
const notificationSchema = z.object({
2830
subscriptionId: z.string(),

apps/web/utils/outlook/mail.ts

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -186,29 +186,34 @@ export async function draftEmail(
186186
emailAddress: { address: addr },
187187
}));
188188

189-
const draft = {
190-
subject: args.subject || originalEmail.headers.subject,
191-
body: {
192-
contentType: "html",
193-
content: html,
194-
},
195-
toRecipients: [
196-
{
197-
emailAddress: {
198-
address: recipients.to,
199-
},
200-
},
201-
],
202-
...(ccRecipients.length > 0 ? { ccRecipients } : {}),
203-
conversationId: originalEmail.threadId,
204-
isDraft: true,
205-
};
189+
// Use createReply endpoint to create a proper reply draft
190+
// This ensures the draft is linked to the original message as a reply
191+
const replyDraft: Message = await client
192+
.getClient()
193+
.api(`/me/messages/${originalEmail.id}/createReply`)
194+
.post({});
206195

207-
const result: Message = await client
196+
// Update the draft with our content
197+
const updatedDraft: Message = await client
208198
.getClient()
209-
.api("/me/messages")
210-
.post(draft);
211-
return result;
199+
.api(`/me/messages/${replyDraft.id}`)
200+
.patch({
201+
subject: args.subject || originalEmail.headers.subject,
202+
body: {
203+
contentType: "html",
204+
content: html,
205+
},
206+
toRecipients: [
207+
{
208+
emailAddress: {
209+
address: recipients.to,
210+
},
211+
},
212+
],
213+
...(ccRecipients.length > 0 ? { ccRecipients } : {}),
214+
});
215+
216+
return updatedDraft;
212217
}
213218

214219
function convertTextToHtmlParagraphs(text?: string | null): string {

apps/web/utils/outlook/message.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@ export async function getFolderIds(client: OutlookClient) {
108108
{} as Record<string, string>,
109109
);
110110

111-
logger.info("Fetched Outlook folder IDs", { folders: folderIdCache });
112111
return folderIdCache;
113112
}
114113

version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v2.15.1
1+
v2.15.2

0 commit comments

Comments
 (0)