Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions apps/web/__tests__/outlook-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
() => {
Expand Down Expand Up @@ -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);
});
33 changes: 17 additions & 16 deletions apps/web/app/api/outlook/webhook/process-history-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -116,7 +117,7 @@ export async function processHistoryItem(
return processAssistantEmail({
message: {
id: messageId,
threadId: resourceData.conversationId || messageId,
threadId,
headers: {
from,
to: to.join(","),
Expand Down Expand Up @@ -156,7 +157,7 @@ export async function processHistoryItem(
parsedMessage,
provider,
messageId,
resourceData.conversationId || undefined,
threadId,
);
return;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -224,7 +225,7 @@ export async function processHistoryItem(
provider,
message: {
id: messageId,
threadId: resourceData.conversationId || messageId,
threadId,
headers: {
from,
to: to.join(","),
Expand Down
10 changes: 6 additions & 4 deletions apps/web/app/api/outlook/webhook/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
47 changes: 26 additions & 21 deletions apps/web/utils/outlook/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion apps/web/utils/outlook/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ export async function getFolderIds(client: OutlookClient) {
{} as Record<string, string>,
);

logger.info("Fetched Outlook folder IDs", { folders: folderIdCache });
return folderIdCache;
}

Expand Down
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v2.15.1
v2.15.2
Loading