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 .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@
"emmet.showExpandedAbbreviation": "never",
"[prisma]": {
"editor.defaultFormatter": "Prisma.prisma"
}
},
"prisma.pinToPrisma6": true
Comment thread
elie222 marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ export function RuleForm({
<LearnedPatternsDialog
ruleId={rule.id}
groupId={rule.groupId || null}
disabled={!allowMultipleConditions(rule.systemType)}
disabled={isConversationStatusType(rule.systemType)}
/>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function LearnedPatternsDialog({
</Button>
</DialogTrigger>

<DialogContent>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Learned Patterns</DialogTitle>
<DialogDescription>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,21 +96,6 @@ function ViewGroupInner({ groupId }: { groupId: string }) {
<PlusIcon className="mr-2 h-4 w-4" />
Add pattern
</Button>

{!!group?.items?.length && (
<Button variant="outline" size="sm" asChild>
<Link
href={prefixPath(
emailAccountId,
`/assistant/group/${groupId}/examples`,
)}
target="_blank"
>
<ExternalLinkIcon className="mr-2 size-4" />
Matches
</Link>
</Button>
)}
</div>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,11 @@ function WritingStyleDialog({
registerProps={register("writingStyle")}
error={errors.writingStyle}
placeholder="Typical Length: 2-3 sentences

Formality: Informal but professional

Common Greeting: Hey,

Notable Traits:
- Uses contractions frequently
- Concise and direct responses
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,17 @@ import { AlertBasic } from "@/components/Alert";
import { Button } from "@/components/ui/button";
import { useSearchParams } from "next/navigation";
import { markNotColdEmailAction } from "@/utils/actions/cold-email";
import { toggleRuleAction } from "@/utils/actions/rule";
import { Checkbox } from "@/components/Checkbox";
import { useToggleSelect } from "@/hooks/useToggleSelect";
import { ViewEmailButton } from "@/components/ViewEmailButton";
import { EmailMessageCellWithData } from "@/components/EmailMessageCell";
import { EnableFeatureCard } from "@/components/EnableFeatureCard";
import { toastError, toastSuccess } from "@/components/Toast";
import { useAccount } from "@/providers/EmailAccountProvider";
import { prefixPath } from "@/utils/path";
import { useRules } from "@/hooks/useRules";
import { isColdEmailBlockerEnabled } from "@/utils/cold-email/cold-email-blocker-enabled";
import { SystemType } from "@/generated/prisma/enums";

export function ColdEmailList() {
const searchParams = useSearchParams();
Expand Down Expand Up @@ -187,18 +188,36 @@ function Row({

function NoColdEmails() {
const { emailAccountId } = useAccount();
const { data: rules } = useRules();
const { data: rules, mutate: mutateRules } = useRules();

const { executeAsync: enableColdEmailBlocker } = useAction(
toggleRuleAction.bind(null, emailAccountId),
{
onSuccess: () => {
toastSuccess({ description: "Cold email blocker enabled!" });
mutateRules();
},
onError: () => {
toastError({ description: "Error enabling cold email blocker" });
},
},
);

if (!isColdEmailBlockerEnabled(rules || [])) {
return (
<div className="mb-10">
<EnableFeatureCard
title="Cold Email Blocker"
description="Block unwanted cold emails automatically. Our AI identifies and filters out unsolicited sales emails before they reach your inbox."
description="Our AI identifies cold outreach from senders you've never communicated with before. You can customize the prompt after enabling."
imageSrc="/images/illustrations/calling-help.svg"
imageAlt="Cold email blocker"
buttonText="Set Up"
href={prefixPath(emailAccountId, "/cold-email-blocker?tab=settings")}
buttonText="Enable"
onEnable={async () => {
await enableColdEmailBlocker({
Comment thread
elie222 marked this conversation as resolved.
systemType: SystemType.COLD_EMAIL,
enabled: true,
});
}}
hideBorder
/>
</div>
Expand Down
25 changes: 19 additions & 6 deletions apps/web/app/api/ai/analyze-sender-pattern/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { isValidInternalApiKey } from "@/utils/internal-api";
import { extractEmailAddress } from "@/utils/email";
import { getEmailForLLM } from "@/utils/get-email-from-message";
import { saveLearnedPattern } from "@/utils/rule/learned-patterns";
import { GroupItemSource } from "@/generated/prisma/enums";
import { checkSenderRuleHistory } from "@/utils/rule/check-sender-rule-history";
import { createEmailProvider } from "@/utils/email/provider";
import type { EmailProvider } from "@/utils/email/types";
Expand Down Expand Up @@ -177,12 +178,24 @@ async function process({
if (patternResult?.matchedRule) {
// Verify the AI matched the same rule as the historical data
if (patternResult.matchedRule === senderHistory.consistentRuleName) {
await saveLearnedPattern({
emailAccountId,
from,
ruleName: patternResult.matchedRule,
logger,
});
const matchedRule = emailAccount.rules.find(
(rule) => rule.name === patternResult.matchedRule,
);

if (matchedRule) {
await saveLearnedPattern({
emailAccountId,
from,
ruleId: matchedRule.id,
logger,
source: GroupItemSource.AI,
});
} else {
logger.error("Matched rule not found in email account rules", {
ruleName: patternResult.matchedRule,
availableRules: emailAccount.rules.map((r) => r.name),
});
}
} else {
logger.warn("AI suggested different rule than historical data", {
aiRule: patternResult.matchedRule,
Expand Down
157 changes: 78 additions & 79 deletions apps/web/app/api/google/webhook/process-label-removed-event.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import { vi, describe, it, expect, beforeEach } from "vitest";
import { ColdEmailStatus } from "@/generated/prisma/enums";
import { HistoryEventType } from "./types";
import { handleLabelRemovedEvent } from "./process-label-removed-event";
import type { gmail_v1 } from "@googleapis/gmail";
import { saveLearnedPatterns } from "@/utils/rule/learned-patterns";
import prisma from "@/utils/__mocks__/prisma";
import { saveLearnedPattern } from "@/utils/rule/learned-patterns";
import { createScopedLogger } from "@/utils/logger";
import { GroupItemSource, SystemType } from "@/generated/prisma/enums";
import prisma from "@/utils/prisma";

const logger = createScopedLogger("test");

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

// Mock dependencies
vi.mock("@/utils/prisma");
vi.mock("@/utils/prisma", () => ({
default: {
rule: {
findFirst: vi.fn(),
},
},
}));
Comment on lines +15 to +21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use the provided Prisma mock from @/utils/__mocks__/prisma.

The coding guidelines specify that Prisma should be mocked using the provided mock from @/utils/__mocks__/prisma instead of creating inline mocks. This ensures consistency across test files and leverages the centralized mock implementation.

As per coding guidelines: "Mock Prisma using vi.mock("@/utils/prisma") and the provided mock from @/utils/__mocks__/prisma"

📋 Recommended refactor to use centralized Prisma mock
-vi.mock("@/utils/prisma", () => ({
-  default: {
-    rule: {
-      findFirst: vi.fn(),
-    },
-  },
-}));
+vi.mock("@/utils/prisma");

Then import the mock at the top of the file:

import prisma from "@/utils/prisma";

And in your tests, continue using vi.mocked(prisma.rule.findFirst) as you currently do. The centralized mock from @/utils/__mocks__/prisma will provide the necessary structure automatically.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/app/api/google/webhook/process-label-removed-event.test.ts around
lines 15 to 21, the test creates an inline mock for Prisma via
vi.mock("@/utils/prisma", () => ({ default: { rule: { findFirst: vi.fn(), }, },
})), but the project requires using the centralized Prisma mock from
@/utils/__mocks__/prisma; replace the inline mock with vi.mock("@/utils/prisma")
(no factory) and import the mocked prisma instance (import prisma from
"@/utils/prisma") at the top of the file, then use
vi.mocked(prisma.rule.findFirst) in tests as currently done so the shared mock
implementation is used consistently.


vi.mock("@/utils/rule/learned-patterns", () => ({
saveLearnedPatterns: vi.fn().mockResolvedValue(undefined),
saveLearnedPattern: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("@/utils/gmail/label", () => ({
Expand Down Expand Up @@ -89,88 +96,42 @@ describe("process-label-removed-event", () => {
};

describe("handleLabelRemovedEvent", () => {
it("should process Cold Email label removal and update ColdEmail status", async () => {
prisma.coldEmail.upsert.mockResolvedValue({} as any);
it("should process Cold Email label removal and call saveLearnedPattern with exclude: true", async () => {
vi.mocked(prisma.rule.findFirst).mockResolvedValue({
id: "rule-123",
systemType: SystemType.COLD_EMAIL,
} as any);

const historyItem = createLabelRemovedHistoryItem();

console.log("Test data:", JSON.stringify(historyItem.item, null, 2));

try {
await handleLabelRemovedEvent(historyItem.item, defaultOptions, logger);
} catch (error) {
console.error("Function error:", error);
throw error;
}

expect(prisma.coldEmail.upsert).toHaveBeenCalledWith({
where: {
emailAccountId_fromEmail: {
emailAccountId: "email-account-id",
fromEmail: "sender@example.com",
},
},
update: {
status: ColdEmailStatus.USER_REJECTED_COLD,
},
create: {
status: ColdEmailStatus.USER_REJECTED_COLD,
fromEmail: "sender@example.com",
emailAccountId: "email-account-id",
messageId: "123",
threadId: "thread-123",
},
});
});

it("should skip learning when Newsletter label is removed (only Cold Email is supported)", async () => {
const historyItem = createLabelRemovedHistoryItem("123", "thread-123", [
"label-2",
]);

await handleLabelRemovedEvent(historyItem.item, defaultOptions, logger);

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

it("should skip learning when To Reply label is removed (only Cold Email is supported)", async () => {
const historyItem = createLabelRemovedHistoryItem("123", "thread-123", [
"label-4",
]);

await handleLabelRemovedEvent(historyItem.item, defaultOptions, logger);

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

it("should skip learning when no executed rule exists (only Cold Email is supported)", async () => {
const historyItem = createLabelRemovedHistoryItem("123", "thread-123", [
"label-2",
]);

await handleLabelRemovedEvent(historyItem.item, defaultOptions, logger);

expect(saveLearnedPatterns).not.toHaveBeenCalled();
expect(saveLearnedPattern).toHaveBeenCalledWith({
emailAccountId: "email-account-id",
from: "sender@example.com",
ruleId: "rule-123",
exclude: true,
logger: expect.anything(),
messageId: "123",
threadId: "thread-123",
reason: "Label removed",
source: GroupItemSource.LABEL_REMOVED,
});
});

it("should skip learning when no matching LABEL action is found (only Cold Email is supported)", async () => {
const historyItem = createLabelRemovedHistoryItem("123", "thread-123", [
"label-2",
]);
it("should skip learning when To Reply label is removed (not a learnable rule)", async () => {
vi.mocked(prisma.rule.findFirst).mockResolvedValue({
id: "rule-456",
systemType: SystemType.TO_REPLY,
} as any);

await handleLabelRemovedEvent(historyItem.item, defaultOptions, logger);

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

it("should handle multiple label removals in a single event (only Cold Email is supported)", async () => {
const historyItem = createLabelRemovedHistoryItem("123", "thread-123", [
"label-3",
"label-4",
]);

await handleLabelRemovedEvent(historyItem.item, defaultOptions, logger);

expect(saveLearnedPatterns).not.toHaveBeenCalled();
expect(saveLearnedPattern).not.toHaveBeenCalled();
});

it("should skip processing when only system labels are removed", async () => {
Expand All @@ -183,7 +144,7 @@ describe("process-label-removed-event", () => {

// Should not try to fetch the message when only system labels removed
expect(mockProvider.getMessage).not.toHaveBeenCalled();
expect(prisma.coldEmail.upsert).not.toHaveBeenCalled();
expect(saveLearnedPattern).not.toHaveBeenCalled();
});

it("should skip processing when DRAFT label is removed (prevents 404 errors)", async () => {
Expand All @@ -194,9 +155,8 @@ describe("process-label-removed-event", () => {

await handleLabelRemovedEvent(historyItem, defaultOptions, logger);

// Should not try to fetch the message (which would fail with 404)
expect(mockProvider.getMessage).not.toHaveBeenCalled();
expect(prisma.coldEmail.upsert).not.toHaveBeenCalled();
expect(saveLearnedPattern).not.toHaveBeenCalled();
});

it("should skip processing when messageId is missing", async () => {
Expand All @@ -207,7 +167,7 @@ describe("process-label-removed-event", () => {

await handleLabelRemovedEvent(historyItem, defaultOptions, logger);

expect(prisma.coldEmail.upsert).not.toHaveBeenCalled();
expect(saveLearnedPattern).not.toHaveBeenCalled();
});

it("should skip processing when threadId is missing", async () => {
Expand All @@ -218,7 +178,46 @@ describe("process-label-removed-event", () => {

await handleLabelRemovedEvent(historyItem, defaultOptions, logger);

expect(prisma.coldEmail.upsert).not.toHaveBeenCalled();
expect(saveLearnedPattern).not.toHaveBeenCalled();
});

it("should handle multiple label removals in a single event", async () => {
vi.mocked(prisma.rule.findFirst)
.mockResolvedValueOnce({
id: "rule-1",
systemType: SystemType.COLD_EMAIL,
} as any)
.mockResolvedValueOnce({
id: "rule-2",
systemType: SystemType.NEWSLETTER,
} as any);

const historyItem = createLabelRemovedHistoryItem("123", "thread-123", [
"label-1",
"label-2",
]);

await handleLabelRemovedEvent(historyItem.item, defaultOptions, logger);

expect(saveLearnedPattern).toHaveBeenCalledTimes(2);
expect(saveLearnedPattern).toHaveBeenCalledWith(
expect.objectContaining({ ruleId: "rule-1" }),
);
expect(saveLearnedPattern).toHaveBeenCalledWith(
expect.objectContaining({ ruleId: "rule-2" }),
);
});

it("should skip learning when no rule is found for the removed label", async () => {
vi.mocked(prisma.rule.findFirst).mockResolvedValue(null);

const historyItem = createLabelRemovedHistoryItem("123", "thread-123", [
"unknown-label",
]);

await handleLabelRemovedEvent(historyItem.item, defaultOptions, logger);

expect(saveLearnedPattern).not.toHaveBeenCalled();
});
});
});
Loading
Loading