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
3 changes: 2 additions & 1 deletion .cursor/rules/testing.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ describe("example", () => {
- Use descriptive test names
- Mock external dependencies
- Clean up mocks between tests
- Avoid testing implementation details
- Avoid testing implementation details
- Do not mock the Logger
227 changes: 227 additions & 0 deletions apps/web/__tests__/ai/choose-rule/draft-management.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
import { handlePreviousDraftDeletion } from "@/utils/ai/choose-rule/draft-management";
import prisma from "@/utils/prisma";
import { getDraft, deleteDraft } from "@/utils/gmail/draft";
import { ActionType } from "@prisma/client";
import type { gmail_v1 } from "@googleapis/gmail";
import { createScopedLogger } from "@/utils/logger";
import type { ParsedMessage } from "@/utils/types";

vi.mock("@/utils/prisma", () => ({
default: {
executedAction: {
findFirst: vi.fn(),
update: vi.fn(),
},
},
}));

vi.mock("@/utils/gmail/draft", () => ({
getDraft: vi.fn(),
deleteDraft: vi.fn(),
}));

describe("handlePreviousDraftDeletion", () => {
const mockGmail = {} as gmail_v1.Gmail;
const logger = createScopedLogger("test");
const mockExecutedRule = {
id: "rule-123",
threadId: "thread-456",
emailAccountId: "account-789",
};

const mockFindFirst = prisma.executedAction.findFirst as Mock;
const mockUpdate = prisma.executedAction.update as Mock;
const mockGetDraft = getDraft as Mock;
const mockDeleteDraft = deleteDraft as Mock;

beforeEach(() => {
vi.clearAllMocks();
});

it("should delete unmodified draft and update wasDraftSent", async () => {
const mockPreviousDraft = {
id: "action-111",
draftId: "draft-222",
content: "Hello, this is a test draft",
};

const mockCurrentDraft: ParsedMessage = {
id: "msg-123",
threadId: "thread-456",
textPlain:
"Hello, this is a test draft\n\nOn Monday wrote:\n> Previous message",
textHtml: undefined,
snippet: "Hello, this is a test draft",
historyId: "12345",
internalDate: "1234567890",
headers: {
from: "test@example.com",
to: "recipient@example.com",
subject: "Test Subject",
date: "Mon, 1 Jan 2024 12:00:00 +0000",
},
labelIds: [],
inline: [],
};

mockFindFirst.mockResolvedValue(mockPreviousDraft);
mockGetDraft.mockResolvedValue(mockCurrentDraft);

await handlePreviousDraftDeletion({
gmail: mockGmail,
executedRule: mockExecutedRule,
logger,
});

expect(mockFindFirst).toHaveBeenCalledWith({
where: {
executedRule: {
threadId: "thread-456",
emailAccountId: "account-789",
},
type: ActionType.DRAFT_EMAIL,
draftId: { not: null },
executedRuleId: { not: "rule-123" },
draftSendLog: null,
},
orderBy: {
createdAt: "desc",
},
select: {
id: true,
draftId: true,
content: true,
},
});

expect(mockGetDraft).toHaveBeenCalledWith("draft-222", mockGmail);
expect(mockDeleteDraft).toHaveBeenCalledWith(mockGmail, "draft-222");
expect(mockUpdate).toHaveBeenCalledWith({
where: { id: "action-111" },
data: { wasDraftSent: false },
});
});

it("should not delete modified draft", async () => {
const mockPreviousDraft = {
id: "action-111",
draftId: "draft-222",
content: "Hello, this is a test draft",
};

const mockCurrentDraft: ParsedMessage = {
id: "msg-123",
threadId: "thread-456",
textPlain:
"Hello, this is a MODIFIED draft\n\nOn Monday wrote:\n> Previous message",
textHtml: undefined,
snippet: "Hello, this is a MODIFIED draft",
historyId: "12345",
internalDate: "1234567890",
headers: {
from: "test@example.com",
to: "recipient@example.com",
subject: "Test Subject",
date: "Mon, 1 Jan 2024 12:00:00 +0000",
},
labelIds: [],
inline: [],
};

mockFindFirst.mockResolvedValue(mockPreviousDraft);
mockGetDraft.mockResolvedValue(mockCurrentDraft);

await handlePreviousDraftDeletion({
gmail: mockGmail,
executedRule: mockExecutedRule,
logger,
});

expect(mockDeleteDraft).not.toHaveBeenCalled();
expect(mockUpdate).not.toHaveBeenCalled();
});

it("should handle no previous draft found", async () => {
mockFindFirst.mockResolvedValue(null);

await handlePreviousDraftDeletion({
gmail: mockGmail,
executedRule: mockExecutedRule,
logger,
});

expect(mockGetDraft).not.toHaveBeenCalled();
expect(mockDeleteDraft).not.toHaveBeenCalled();
});

it("should handle draft not found in Gmail", async () => {
const mockPreviousDraft = {
id: "action-111",
draftId: "draft-222",
content: "Hello, this is a test draft",
};

mockFindFirst.mockResolvedValue(mockPreviousDraft);
mockGetDraft.mockResolvedValue(null);

await handlePreviousDraftDeletion({
gmail: mockGmail,
executedRule: mockExecutedRule,
logger,
});

expect(mockDeleteDraft).not.toHaveBeenCalled();
});

it("should handle errors gracefully", async () => {
const error = new Error("Database error");
mockFindFirst.mockRejectedValue(error);

// Should not throw - errors are caught and logged
await expect(
handlePreviousDraftDeletion({
gmail: mockGmail,
executedRule: mockExecutedRule,
logger,
}),
).resolves.not.toThrow();
});

it("should handle draft with no textPlain content", async () => {
const mockPreviousDraft = {
id: "action-111",
draftId: "draft-222",
content: "Hello, this is a test draft",
};

const mockCurrentDraft = {
id: "msg-123",
threadId: "thread-456",
// No textPlain property
textHtml: "<p>HTML content</p>",
snippet: "HTML content",
historyId: "12345",
internalDate: "1234567890",
headers: {
from: "test@example.com",
to: "recipient@example.com",
subject: "Test Subject",
date: "Mon, 1 Jan 2024 12:00:00 +0000",
},
labelIds: [],
inline: [],
};

mockFindFirst.mockResolvedValue(mockPreviousDraft);
mockGetDraft.mockResolvedValue(mockCurrentDraft);

await handlePreviousDraftDeletion({
gmail: mockGmail,
executedRule: mockExecutedRule,
logger,
});

expect(mockDeleteDraft).not.toHaveBeenCalled();
});
});
19 changes: 17 additions & 2 deletions apps/web/utils/ai/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { callWebhook } from "@/utils/webhook";
import type { ActionItem, EmailForAction } from "@/utils/ai/types";
import { coordinateReplyProcess } from "@/utils/reply-tracker/inbound";
import { internalDateToDate } from "@/utils/date";
import { handlePreviousDraftDeletion } from "@/utils/ai/choose-rule/draft-management";

const logger = createScopedLogger("ai-actions");

Expand Down Expand Up @@ -120,8 +121,22 @@ const label: ActionFunction<{ label: string } | any> = async ({
// content: string;
// attachments?: Attachment[];
// },
const draft: ActionFunction<any> = async ({ gmail, email, args }) => {
const result = await draftEmail(gmail, email, args);
const draft: ActionFunction<any> = async ({
gmail,
email,
args,
executedRule,
}) => {
// Run draft creation and previous draft deletion in parallel
const [result] = await Promise.all([
draftEmail(gmail, email, args),
handlePreviousDraftDeletion({
gmail,
executedRule,
logger,
}),
]);

return { draftId: result.data.message?.id };
};

Expand Down
44 changes: 34 additions & 10 deletions apps/web/utils/ai/choose-rule/draft-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ export async function handlePreviousDraftDeletion({
const previousDraftAction = await prisma.executedAction.findFirst({
where: {
executedRule: {
threadId: executedRule.threadId, // Match threadId
emailAccountId: executedRule.emailAccountId, // Match emailAccountId for safety
threadId: executedRule.threadId,
emailAccountId: executedRule.emailAccountId,
},
type: ActionType.DRAFT_EMAIL,
draftId: { not: null }, // Ensure it has a draftId
executedRuleId: { not: executedRule.id }, // Explicitly exclude actions from the current rule execution
executedRuleId: { not: executedRule.id }, // Explicitly exclude current executedRule from the current rule execution
draftSendLog: null, // Only consider drafts not logged as sent
},
orderBy: {
createdAt: "desc", // Get the most recent one
Expand All @@ -52,10 +53,24 @@ export async function handlePreviousDraftDeletion({

if (currentDraftDetails?.textPlain) {
// Basic comparison: Compare original content with current plain text
const quoteHeaderRegex = /\n\nOn .* wrote:/;
const currentReplyContent = currentDraftDetails.textPlain
.split(quoteHeaderRegex)[0]
?.trim();
// Try multiple quote header patterns
const quoteHeaderPatterns = [
/\n\nOn .* wrote:/,
/\n\n----+ Original Message ----+/,
/\n\n>+ On .*/,
/\n\nFrom: .*/,
];

let currentReplyContent = currentDraftDetails.textPlain;
for (const pattern of quoteHeaderPatterns) {
const parts = currentReplyContent.split(pattern);
if (parts.length > 1) {
currentReplyContent = parts[0];
break;
}
}
currentReplyContent = currentReplyContent.trim();

const originalContent = previousDraftAction.content?.trim();

logger.info("Comparing draft content", {
Expand All @@ -65,7 +80,17 @@ export async function handlePreviousDraftDeletion({

if (originalContent === currentReplyContent) {
logger.info("Draft content matches, deleting draft.");
await deleteDraft(gmail, previousDraftAction.draftId);

// Delete the draft and mark as not sent
await Promise.all([
deleteDraft(gmail, previousDraftAction.draftId),
prisma.executedAction.update({
where: { id: previousDraftAction.id },
data: { wasDraftSent: false },
}),
]);

logger.info("Deleted draft and updated action status.");
} else {
logger.info("Draft content modified by user, skipping deletion.");
}
Expand Down Expand Up @@ -99,7 +124,7 @@ export async function updateExecutedActionWithDraftId({
try {
await prisma.executedAction.update({
where: { id: actionId },
data: { draftId: draftId },
data: { draftId },
});
logger.info("Updated executed action with draft ID", { actionId, draftId });
} catch (error) {
Expand All @@ -108,6 +133,5 @@ export async function updateExecutedActionWithDraftId({
draftId,
error,
});
// Depending on requirements, you might want to re-throw or handle this error differently.
}
}
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v1.3.20
v1.3.21