From 961c9b192277f8043c59d799a64208328fbcdc42 Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Tue, 29 Jul 2025 09:11:40 -0300 Subject: [PATCH 1/4] Add Outlook Cold Email support --- .../webhook/process-history-item.test.ts | 37 +++--- .../google/webhook/process-history-item.ts | 9 +- apps/web/utils/actions/cold-email.ts | 13 ++- .../utils/cold-email/is-cold-email.test.ts | 108 +++++++++--------- apps/web/utils/cold-email/is-cold-email.ts | 48 ++++---- apps/web/utils/email/provider.ts | 64 ++++++++--- apps/web/utils/gmail/message.ts | 36 +++--- 7 files changed, 183 insertions(+), 132 deletions(-) diff --git a/apps/web/app/api/google/webhook/process-history-item.test.ts b/apps/web/app/api/google/webhook/process-history-item.test.ts index 92cd077972..7f8cf22d21 100644 --- a/apps/web/app/api/google/webhook/process-history-item.test.ts +++ b/apps/web/app/api/google/webhook/process-history-item.test.ts @@ -6,7 +6,10 @@ import { import { ColdEmailSetting } from "@prisma/client"; import type { gmail_v1 } from "@googleapis/gmail"; import { isAssistantEmail } from "@/utils/assistant/is-assistant-email"; -import { runColdEmailBlocker } from "@/utils/cold-email/is-cold-email"; +import { + runColdEmailBlocker, + runColdEmailBlockerWithProvider, +} from "@/utils/cold-email/is-cold-email"; import { blockUnsubscribedEmails } from "@/app/api/google/webhook/block-unsubscribed-emails"; import { markMessageAsProcessing } from "@/utils/redis/message-processing"; @@ -50,6 +53,9 @@ vi.mock("@/utils/cold-email/is-cold-email", () => ({ runColdEmailBlocker: vi .fn() .mockResolvedValue({ isColdEmail: false, reason: "hasPreviousEmail" }), + runColdEmailBlockerWithProvider: vi + .fn() + .mockResolvedValue({ isColdEmail: false, reason: "hasPreviousEmail" }), })); vi.mock("@/app/api/google/webhook/block-unsubscribed-emails", () => ({ blockUnsubscribedEmails: vi.fn().mockResolvedValue(false), @@ -102,6 +108,7 @@ describe("processHistoryItem", () => { const defaultOptions = { gmail: {} as any, + provider: {} as any, email: "user@test.com", accessToken: "fake-token", hasAutomationRules: false, @@ -141,7 +148,7 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem(), options); expect(blockUnsubscribedEmails).not.toHaveBeenCalled(); - expect(runColdEmailBlocker).not.toHaveBeenCalled(); + expect(runColdEmailBlockerWithProvider).not.toHaveBeenCalled(); expect(processAssistantEmail).toHaveBeenCalledWith({ message: expect.objectContaining({ headers: expect.objectContaining({ @@ -184,7 +191,7 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem(), options); expect(blockUnsubscribedEmails).not.toHaveBeenCalled(); - expect(runColdEmailBlocker).not.toHaveBeenCalled(); + expect(runColdEmailBlockerWithProvider).not.toHaveBeenCalled(); }); it("should skip if email is unsubscribed", async () => { @@ -196,7 +203,7 @@ describe("processHistoryItem", () => { }; await processHistoryItem(createHistoryItem(), options); - expect(runColdEmailBlocker).not.toHaveBeenCalled(); + expect(runColdEmailBlockerWithProvider).not.toHaveBeenCalled(); }); it("should run cold email blocker when enabled", async () => { @@ -211,7 +218,7 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem(), options); - expect(runColdEmailBlocker).toHaveBeenCalledWith({ + expect(runColdEmailBlockerWithProvider).toHaveBeenCalledWith({ email: expect.objectContaining({ from: "sender@example.com", subject: "Test Email", @@ -220,13 +227,13 @@ describe("processHistoryItem", () => { threadId: "thread-123", date: expect.any(Date), }), - gmail: options.gmail, + provider: expect.any(Object), emailAccount: options.emailAccount, }); }); it("should skip further processing if cold email is detected", async () => { - vi.mocked(runColdEmailBlocker).mockResolvedValueOnce({ + vi.mocked(runColdEmailBlockerWithProvider).mockResolvedValueOnce({ isColdEmail: true, reason: "ai", aiReason: "This appears to be a cold email", @@ -254,7 +261,7 @@ describe("processHistoryItem", () => { }); it("should add cold email to digest when coldEmailDigest is true and cold email is detected", async () => { - vi.mocked(runColdEmailBlocker).mockResolvedValueOnce({ + vi.mocked(runColdEmailBlockerWithProvider).mockResolvedValueOnce({ isColdEmail: true, reason: "ai", aiReason: "This appears to be a cold email", @@ -275,7 +282,7 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem(), options); - expect(runColdEmailBlocker).toHaveBeenCalledWith({ + expect(runColdEmailBlockerWithProvider).toHaveBeenCalledWith({ email: expect.objectContaining({ from: "sender@example.com", subject: "Test Email", @@ -284,7 +291,7 @@ describe("processHistoryItem", () => { threadId: "thread-123", date: expect.any(Date), }), - gmail: options.gmail, + provider: expect.any(Object), emailAccount: options.emailAccount, }); @@ -322,7 +329,7 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem(), options); - expect(runColdEmailBlocker).not.toHaveBeenCalled(); + expect(runColdEmailBlockerWithProvider).not.toHaveBeenCalled(); expect(categorizeSender).toHaveBeenCalled(); expect(runRules).toHaveBeenCalled(); }); @@ -347,14 +354,14 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem(), options); - expect(runColdEmailBlocker).toHaveBeenCalled(); + expect(runColdEmailBlockerWithProvider).toHaveBeenCalled(); expect(categorizeSender).toHaveBeenCalled(); expect(runRules).toHaveBeenCalled(); }); it("should add second email from known cold emailer to digest when coldEmailDigest is enabled", async () => { // Mock the response for a known cold emailer (already in database) - vi.mocked(runColdEmailBlocker).mockResolvedValueOnce({ + vi.mocked(runColdEmailBlockerWithProvider).mockResolvedValueOnce({ isColdEmail: true, reason: "ai-already-labeled", coldEmailId: "existing-cold-email-456", // Existing cold email entry ID @@ -374,7 +381,7 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem("456", "thread-456"), options); - expect(runColdEmailBlocker).toHaveBeenCalledWith({ + expect(runColdEmailBlockerWithProvider).toHaveBeenCalledWith({ email: expect.objectContaining({ from: "sender@example.com", subject: "Test Email", @@ -383,7 +390,7 @@ describe("processHistoryItem", () => { threadId: "thread-456", date: expect.any(Date), }), - gmail: options.gmail, + provider: expect.any(Object), emailAccount: options.emailAccount, }); diff --git a/apps/web/app/api/google/webhook/process-history-item.ts b/apps/web/app/api/google/webhook/process-history-item.ts index 94647d9983..4694b6a6c8 100644 --- a/apps/web/app/api/google/webhook/process-history-item.ts +++ b/apps/web/app/api/google/webhook/process-history-item.ts @@ -2,7 +2,10 @@ import type { gmail_v1 } from "@googleapis/gmail"; import prisma from "@/utils/prisma"; import { emailToContent } from "@/utils/mail"; import { GmailLabel } from "@/utils/gmail/label"; -import { runColdEmailBlocker } from "@/utils/cold-email/is-cold-email"; +import { + runColdEmailBlocker, + runColdEmailBlockerWithProvider, +} from "@/utils/cold-email/is-cold-email"; import { runRules } from "@/utils/ai/choose-rule/run-rules"; import { blockUnsubscribedEmails } from "@/app/api/google/webhook/block-unsubscribed-emails"; import { categorizeSender } from "@/utils/categorize/senders/categorize"; @@ -152,7 +155,7 @@ export async function processHistoryItem( const content = emailToContent(parsedMessage); - const response = await runColdEmailBlocker({ + const response = await runColdEmailBlockerWithProvider({ email: { from: parsedMessage.headers.from, to: "", @@ -162,7 +165,7 @@ export async function processHistoryItem( threadId, date: internalDateToDate(parsedMessage.internalDate), }, - gmail, + provider, emailAccount, }); diff --git a/apps/web/utils/actions/cold-email.ts b/apps/web/utils/actions/cold-email.ts index dde2c72cbc..1c0174271d 100644 --- a/apps/web/utils/actions/cold-email.ts +++ b/apps/web/utils/actions/cold-email.ts @@ -18,6 +18,7 @@ import { import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; import { SafeError } from "@/utils/error"; +import { createEmailProvider } from "@/utils/email/provider"; export const updateColdEmailSettingsAction = actionClient .metadata({ name: "updateColdEmailSettings" }) @@ -119,12 +120,20 @@ export const testColdEmailAction = actionClient where: { id: emailAccountId }, include: { user: { select: { aiProvider: true, aiModel: true, aiApiKey: true } }, + account: { + select: { + provider: true, + }, + }, }, }); if (!emailAccount) throw new SafeError("Email account not found"); - const gmail = await getGmailClientForEmail({ emailAccountId }); + const emailProvider = await createEmailProvider({ + emailAccountId, + provider: emailAccount.account?.provider, + }); const content = emailToContent({ textHtml: textHtml || undefined, @@ -143,7 +152,7 @@ export const testColdEmailAction = actionClient id: messageId || "", }, emailAccount, - gmail, + provider: emailProvider, }); return response; diff --git a/apps/web/utils/cold-email/is-cold-email.test.ts b/apps/web/utils/cold-email/is-cold-email.test.ts index 773b720396..442ba45adb 100644 --- a/apps/web/utils/cold-email/is-cold-email.test.ts +++ b/apps/web/utils/cold-email/is-cold-email.test.ts @@ -1,11 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { gmail_v1 } from "@googleapis/gmail"; import prisma from "@/utils/prisma"; import { ColdEmailSetting, ColdEmailStatus } from "@prisma/client"; -import { GmailLabel } from "@/utils/gmail/label"; -import * as labelUtils from "@/utils/gmail/label"; import { blockColdEmail } from "./is-cold-email"; import { getEmailAccount } from "@/__tests__/helpers"; +import type { EmailProvider } from "@/utils/email/provider"; // Mock dependencies vi.mock("server-only", () => ({})); @@ -18,17 +16,14 @@ vi.mock("@/utils/prisma", () => ({ }, })); -vi.mock("@/utils/gmail/label", async () => { - const actual = await vi.importActual("@/utils/gmail/label"); - return { - ...actual, +describe("blockColdEmail", () => { + const mockProvider = { getOrCreateInboxZeroLabel: vi.fn(), labelMessage: vi.fn(), - }; -}); + archiveThread: vi.fn(), + markReadThread: vi.fn(), + } as unknown as EmailProvider; -describe("blockColdEmail", () => { - const mockGmail = {} as gmail_v1.Gmail; const mockEmail = { from: "sender@example.com", id: "123", @@ -46,7 +41,7 @@ describe("blockColdEmail", () => { it("should upsert cold email record in database", async () => { await blockColdEmail({ - gmail: mockGmail, + provider: mockProvider, email: mockEmail, emailAccount: mockEmailAccount, aiReason: mockAiReason, @@ -72,27 +67,25 @@ describe("blockColdEmail", () => { }); it("should add cold email label when coldEmailBlocker is LABEL", async () => { - vi.mocked(labelUtils.getOrCreateInboxZeroLabel).mockResolvedValue({ + vi.mocked(mockProvider.getOrCreateInboxZeroLabel).mockResolvedValue({ id: "label123", + name: "Cold Email", + type: "user", }); await blockColdEmail({ - gmail: mockGmail, + provider: mockProvider, email: mockEmail, emailAccount: mockEmailAccount, aiReason: mockAiReason, }); - expect(labelUtils.getOrCreateInboxZeroLabel).toHaveBeenCalledWith({ - gmail: mockGmail, - key: "cold_email", - }); - expect(labelUtils.labelMessage).toHaveBeenCalledWith({ - gmail: mockGmail, - messageId: mockEmail.id, - addLabelIds: ["label123"], - removeLabelIds: undefined, - }); + expect(mockProvider.getOrCreateInboxZeroLabel).toHaveBeenCalledWith( + "cold_email", + ); + expect(mockProvider.labelMessage).toHaveBeenCalledWith(mockEmail.id, [ + "Cold Email", + ]); }); it("should archive email when coldEmailBlocker is ARCHIVE_AND_LABEL", async () => { @@ -100,23 +93,26 @@ describe("blockColdEmail", () => { ...mockEmailAccount, coldEmailBlocker: ColdEmailSetting.ARCHIVE_AND_LABEL, }; - vi.mocked(labelUtils.getOrCreateInboxZeroLabel).mockResolvedValue({ + vi.mocked(mockProvider.getOrCreateInboxZeroLabel).mockResolvedValue({ id: "label123", + name: "Cold Email", + type: "user", }); await blockColdEmail({ - gmail: mockGmail, + provider: mockProvider, email: mockEmail, emailAccount: userWithArchive, aiReason: mockAiReason, }); - expect(labelUtils.labelMessage).toHaveBeenCalledWith({ - gmail: mockGmail, - messageId: mockEmail.id, - addLabelIds: ["label123"], - removeLabelIds: [GmailLabel.INBOX], - }); + expect(mockProvider.labelMessage).toHaveBeenCalledWith(mockEmail.id, [ + "Cold Email", + ]); + expect(mockProvider.archiveThread).toHaveBeenCalledWith( + mockEmail.threadId, + userWithArchive.email, + ); }); it("should archive and mark as read when coldEmailBlocker is ARCHIVE_AND_READ_AND_LABEL", async () => { @@ -124,23 +120,30 @@ describe("blockColdEmail", () => { ...mockEmailAccount, coldEmailBlocker: ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL, }; - vi.mocked(labelUtils.getOrCreateInboxZeroLabel).mockResolvedValue({ + vi.mocked(mockProvider.getOrCreateInboxZeroLabel).mockResolvedValue({ id: "label123", + name: "Cold Email", + type: "user", }); await blockColdEmail({ - gmail: mockGmail, + provider: mockProvider, email: mockEmail, emailAccount: userWithArchiveAndRead, aiReason: mockAiReason, }); - expect(labelUtils.labelMessage).toHaveBeenCalledWith({ - gmail: mockGmail, - messageId: mockEmail.id, - addLabelIds: ["label123"], - removeLabelIds: [GmailLabel.INBOX, GmailLabel.UNREAD], - }); + expect(mockProvider.labelMessage).toHaveBeenCalledWith(mockEmail.id, [ + "Cold Email", + ]); + expect(mockProvider.archiveThread).toHaveBeenCalledWith( + mockEmail.threadId, + userWithArchiveAndRead.email, + ); + expect(mockProvider.markReadThread).toHaveBeenCalledWith( + mockEmail.threadId, + true, + ); }); it("should throw error when user email is missing", async () => { @@ -148,7 +151,7 @@ describe("blockColdEmail", () => { await expect( blockColdEmail({ - gmail: mockGmail, + provider: mockProvider, email: mockEmail, emailAccount: userWithoutEmail, aiReason: mockAiReason, @@ -157,23 +160,22 @@ describe("blockColdEmail", () => { }); it("should handle missing label id", async () => { - vi.mocked(labelUtils.getOrCreateInboxZeroLabel).mockResolvedValue({ - id: null, + vi.mocked(mockProvider.getOrCreateInboxZeroLabel).mockResolvedValue({ + id: "", + name: "Cold Email", + type: "user", }); await blockColdEmail({ - gmail: mockGmail, + provider: mockProvider, email: mockEmail, emailAccount: mockEmailAccount, aiReason: mockAiReason, }); - expect(labelUtils.labelMessage).toHaveBeenCalledWith({ - gmail: mockGmail, - messageId: mockEmail.id, - addLabelIds: undefined, - removeLabelIds: undefined, - }); + expect(mockProvider.labelMessage).toHaveBeenCalledWith(mockEmail.id, [ + "Cold Email", + ]); }); it("should not modify labels when coldEmailBlocker is DISABLED", async () => { @@ -183,13 +185,13 @@ describe("blockColdEmail", () => { }; await blockColdEmail({ - gmail: mockGmail, + provider: mockProvider, email: mockEmail, emailAccount: userWithBlockerOff, aiReason: mockAiReason, }); - expect(labelUtils.getOrCreateInboxZeroLabel).not.toHaveBeenCalled(); - expect(labelUtils.labelMessage).not.toHaveBeenCalled(); + expect(mockProvider.getOrCreateInboxZeroLabel).not.toHaveBeenCalled(); + expect(mockProvider.labelMessage).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/utils/cold-email/is-cold-email.ts b/apps/web/utils/cold-email/is-cold-email.ts index 927290b48d..2c83a77f21 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -25,11 +25,11 @@ type ColdEmailBlockerReason = "hasPreviousEmail" | "ai" | "ai-already-labeled"; export async function isColdEmail({ email, emailAccount, - gmail, + provider, }: { email: EmailForLLM & { threadId?: string }; emailAccount: Pick & EmailAccountWithAI; - gmail: gmail_v1.Gmail; + provider: EmailProvider; }): Promise<{ isColdEmail: boolean; reason: ColdEmailBlockerReason; @@ -59,7 +59,7 @@ export async function isColdEmail({ const hasPreviousEmail = email.date && email.id - ? await hasPreviousCommunicationsWithSenderOrDomain(gmail, { + ? await provider.hasPreviousCommunicationsWithSenderOrDomain({ from: email.from, date: email.date, messageId: email.id, @@ -221,7 +221,7 @@ ${stringifyEmail(email, 500)} export async function runColdEmailBlocker(options: { email: EmailForLLM & { threadId: string }; - gmail: gmail_v1.Gmail; + provider: EmailProvider; emailAccount: Pick & EmailAccountWithAI; }): Promise<{ @@ -230,7 +230,11 @@ export async function runColdEmailBlocker(options: { aiReason?: string | null; coldEmailId?: string | null; }> { - const response = await isColdEmail(options); + const response = await isColdEmailWithProvider({ + email: options.email, + emailAccount: options.emailAccount, + provider: options.provider, + }); if (!response.isColdEmail) return { ...response, coldEmailId: null }; @@ -241,7 +245,6 @@ export async function runColdEmailBlocker(options: { return { ...response, coldEmailId: coldEmail.id }; } -// New function that works with EmailProvider export async function runColdEmailBlockerWithProvider(options: { email: EmailForLLM & { threadId: string }; provider: EmailProvider; @@ -269,12 +272,12 @@ export async function runColdEmailBlockerWithProvider(options: { } export async function blockColdEmail(options: { - gmail: gmail_v1.Gmail; + provider: EmailProvider; email: { from: string; id: string; threadId: string }; emailAccount: Pick & EmailAccountWithAI; aiReason: string | null; }): Promise { - const { gmail, email, emailAccount, aiReason } = options; + const { provider, email, emailAccount, aiReason } = options; const coldEmail = await prisma.coldEmail.upsert({ where: { @@ -301,12 +304,10 @@ export async function blockColdEmail(options: { ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL ) { if (!emailAccount.email) throw new Error("User email is required"); - const coldEmailLabel = await getOrCreateInboxZeroLabel({ - gmail, - key: "cold_email", - }); + const coldEmailLabel = + await provider.getOrCreateInboxZeroLabel("cold_email"); if (!coldEmailLabel?.id) - logger.error("No gmail label id", { emailAccountId: emailAccount.id }); + logger.error("No label id", { emailAccountId: emailAccount.id }); const shouldArchive = emailAccount.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_LABEL || @@ -317,19 +318,18 @@ export async function blockColdEmail(options: { emailAccount.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL; - const addLabelIds: string[] = []; - if (coldEmailLabel?.id) addLabelIds.push(coldEmailLabel.id); + const labels: string[] = []; + if (coldEmailLabel?.name) labels.push(coldEmailLabel.name); + + if (shouldArchive) { + await provider.archiveThread(email.threadId, emailAccount.email); + } - const removeLabelIds: string[] = []; - if (shouldArchive) removeLabelIds.push(GmailLabel.INBOX); - if (shouldMarkRead) removeLabelIds.push(GmailLabel.UNREAD); + if (shouldMarkRead) { + await provider.markReadThread(email.threadId, true); + } - await labelMessage({ - gmail, - messageId: email.id, - addLabelIds: addLabelIds.length ? addLabelIds : undefined, - removeLabelIds: removeLabelIds.length ? removeLabelIds : undefined, - }); + await provider.labelMessage(email.id, labels); } return coldEmail; diff --git a/apps/web/utils/email/provider.ts b/apps/web/utils/email/provider.ts index 5c0a7ab680..4a138f6ab9 100644 --- a/apps/web/utils/email/provider.ts +++ b/apps/web/utils/email/provider.ts @@ -173,7 +173,10 @@ export interface EmailProvider { ownerEmail: string, actionSource: "user" | "automation", ): Promise; - labelMessage(messageId: string, labelName: string): Promise; + labelMessage( + messageId: string, + labelNames: string | string[] | undefined, + ): Promise; removeThreadLabel(threadId: string, labelId: string): Promise; getAwaitingReplyLabel(): Promise; draftEmail( @@ -403,17 +406,31 @@ export class GmailProvider implements EmailProvider { }); } - async labelMessage(messageId: string, labelName: string): Promise { - const label = await gmailGetOrCreateLabel({ - gmail: this.client, - name: labelName, - }); - if (!label.id) - throw new Error("Label not found and unable to create label"); + async labelMessage( + messageId: string, + labelNames: string | string[] | undefined, + ): Promise { + if (!labelNames) return; + + const normalizeLabels = (labels: string | string[] | undefined) => + Array.isArray(labels) ? labels : labels ? [labels] : []; + + const createLabel = async (name: string) => { + const label = await gmailGetOrCreateLabel({ gmail: this.client, name }); + if (!label.id) + throw new Error(`Label not found and unable to create label: ${name}`); + return label.id; + }; + + const addLabels = await Promise.all( + normalizeLabels(labelNames).map(createLabel), + ); + await gmailLabelMessage({ gmail: this.client, messageId, - addLabelIds: [label.id], + addLabelIds: addLabels, + removeLabelIds: [], }); } @@ -799,7 +816,7 @@ export class GmailProvider implements EmailProvider { date: Date; messageId: string; }): Promise { - return hasPreviousCommunicationsWithSenderOrDomain(this.client, options); + return hasPreviousCommunicationsWithSenderOrDomain(this, options); } async getThreadsFromSenderWithSubject( @@ -998,15 +1015,30 @@ export class OutlookProvider implements EmailProvider { }); } - async labelMessage(messageId: string, labelName: string): Promise { - const label = await outlookGetOrCreateLabel({ - client: this.client, - name: labelName, - }); + async labelMessage( + messageId: string, + labelNames: string | string[] | undefined, + ): Promise { + if (!labelNames) return; + + const labelNamesArray = Array.isArray(labelNames) + ? labelNames + : [labelNames].filter((name) => name); + + const labels = await Promise.all( + labelNamesArray.map(async (labelName) => { + const label = await outlookGetOrCreateLabel({ + client: this.client, + name: labelName, + }); + return label.displayName || ""; + }), + ); + await outlookLabelMessage({ client: this.client, messageId, - categories: [label.displayName || ""], + categories: labels, }); } diff --git a/apps/web/utils/gmail/message.ts b/apps/web/utils/gmail/message.ts index 8bc1e30d28..13515d6fdf 100644 --- a/apps/web/utils/gmail/message.ts +++ b/apps/web/utils/gmail/message.ts @@ -15,6 +15,7 @@ import { getAccessTokenFromClient } from "@/utils/gmail/client"; import { GmailLabel } from "@/utils/gmail/label"; import { isIgnoredSender } from "@/utils/filter-ignored-senders"; import parse from "gmail-api-parse-message"; +import type { EmailProvider } from "@/utils/email/provider"; const logger = createScopedLogger("gmail/message"); @@ -165,42 +166,39 @@ export async function getMessagesBatch({ } async function findPreviousEmailsWithSender( - gmail: gmail_v1.Gmail, + client: EmailProvider, options: { sender: string; dateInSeconds: number; }, ) { - // Check for both incoming emails from sender and outgoing emails to sender + const beforeDate = new Date(options.dateInSeconds * 1000); const [incomingEmails, outgoingEmails] = await Promise.all([ - // Incoming - gmail.users.messages.list({ - userId: "me", - q: `from:${options.sender} before:${options.dateInSeconds}`, + client.getMessagesWithPagination({ + query: `from:${options.sender}`, maxResults: 2, + before: beforeDate, }), - // Outgoing - gmail.users.messages.list({ - userId: "me", - q: `to:${options.sender} before:${options.dateInSeconds}`, - maxResults: 1, + client.getMessagesWithPagination({ + query: `to:${options.sender}`, + maxResults: 2, + before: beforeDate, }), ]); - // Combine both incoming and outgoing messages const allMessages = [ - ...(incomingEmails.data.messages || []), - ...(outgoingEmails.data.messages || []), + ...(incomingEmails.messages || []), + ...(outgoingEmails.messages || []), ]; return allMessages; } export async function hasPreviousCommunicationWithSender( - gmail: gmail_v1.Gmail, + client: EmailProvider, options: { from: string; date: Date; messageId: string }, ) { - const previousEmails = await findPreviousEmailsWithSender(gmail, { + const previousEmails = await findPreviousEmailsWithSender(client, { sender: options.from, dateInSeconds: +new Date(options.date) / 1000, }); @@ -229,11 +227,11 @@ const PUBLIC_DOMAINS = new Set([ ]); export async function hasPreviousCommunicationsWithSenderOrDomain( - gmail: gmail_v1.Gmail, + client: EmailProvider, options: { from: string; date: Date; messageId: string }, ) { const domain = extractDomainFromEmail(options.from); - if (!domain) return hasPreviousCommunicationWithSender(gmail, options); + if (!domain) return hasPreviousCommunicationWithSender(client, options); // For public email providers (gmail, yahoo, etc), search by full email address // For company domains, search by domain to catch emails from different people at same company @@ -241,7 +239,7 @@ export async function hasPreviousCommunicationsWithSenderOrDomain( ? options.from : domain; - return hasPreviousCommunicationWithSender(gmail, { + return hasPreviousCommunicationWithSender(client, { ...options, from: searchTerm, }); From 56c880f80cbccb9e404fd469a2d049073fc6f232 Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Tue, 29 Jul 2025 09:16:07 -0300 Subject: [PATCH 2/4] Remove cold blocker without provider --- .../webhook/process-history-item.test.ts | 7 ++--- .../google/webhook/process-history-item.ts | 5 +--- apps/web/utils/cold-email/is-cold-email.ts | 26 ------------------- 3 files changed, 3 insertions(+), 35 deletions(-) diff --git a/apps/web/app/api/google/webhook/process-history-item.test.ts b/apps/web/app/api/google/webhook/process-history-item.test.ts index 7f8cf22d21..5bb9753f2f 100644 --- a/apps/web/app/api/google/webhook/process-history-item.test.ts +++ b/apps/web/app/api/google/webhook/process-history-item.test.ts @@ -6,10 +6,7 @@ import { import { ColdEmailSetting } from "@prisma/client"; import type { gmail_v1 } from "@googleapis/gmail"; import { isAssistantEmail } from "@/utils/assistant/is-assistant-email"; -import { - runColdEmailBlocker, - runColdEmailBlockerWithProvider, -} from "@/utils/cold-email/is-cold-email"; +import { runColdEmailBlockerWithProvider } from "@/utils/cold-email/is-cold-email"; import { blockUnsubscribedEmails } from "@/app/api/google/webhook/block-unsubscribed-emails"; import { markMessageAsProcessing } from "@/utils/redis/message-processing"; @@ -335,7 +332,7 @@ describe("processHistoryItem", () => { }); it("should process normally when cold email is not detected with coldEmailDigest enabled", async () => { - vi.mocked(runColdEmailBlocker).mockResolvedValueOnce({ + vi.mocked(runColdEmailBlockerWithProvider).mockResolvedValueOnce({ isColdEmail: false, reason: "hasPreviousEmail", }); diff --git a/apps/web/app/api/google/webhook/process-history-item.ts b/apps/web/app/api/google/webhook/process-history-item.ts index 4694b6a6c8..a9597a06f8 100644 --- a/apps/web/app/api/google/webhook/process-history-item.ts +++ b/apps/web/app/api/google/webhook/process-history-item.ts @@ -2,10 +2,7 @@ import type { gmail_v1 } from "@googleapis/gmail"; import prisma from "@/utils/prisma"; import { emailToContent } from "@/utils/mail"; import { GmailLabel } from "@/utils/gmail/label"; -import { - runColdEmailBlocker, - runColdEmailBlockerWithProvider, -} from "@/utils/cold-email/is-cold-email"; +import { runColdEmailBlockerWithProvider } from "@/utils/cold-email/is-cold-email"; import { runRules } from "@/utils/ai/choose-rule/run-rules"; import { blockUnsubscribedEmails } from "@/app/api/google/webhook/block-unsubscribed-emails"; import { categorizeSender } from "@/utils/categorize/senders/categorize"; diff --git a/apps/web/utils/cold-email/is-cold-email.ts b/apps/web/utils/cold-email/is-cold-email.ts index 2c83a77f21..de23d5768d 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -219,32 +219,6 @@ ${stringifyEmail(email, 500)} return response.object; } -export async function runColdEmailBlocker(options: { - email: EmailForLLM & { threadId: string }; - provider: EmailProvider; - emailAccount: Pick & - EmailAccountWithAI; -}): Promise<{ - isColdEmail: boolean; - reason: ColdEmailBlockerReason; - aiReason?: string | null; - coldEmailId?: string | null; -}> { - const response = await isColdEmailWithProvider({ - email: options.email, - emailAccount: options.emailAccount, - provider: options.provider, - }); - - if (!response.isColdEmail) return { ...response, coldEmailId: null }; - - const coldEmail = await blockColdEmail({ - ...options, - aiReason: response.aiReason ?? null, - }); - return { ...response, coldEmailId: coldEmail.id }; -} - export async function runColdEmailBlockerWithProvider(options: { email: EmailForLLM & { threadId: string }; provider: EmailProvider; From 8fcb32a110bd153df523319980684e5cf9c4641a Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Tue, 29 Jul 2025 09:32:08 -0300 Subject: [PATCH 3/4] Restore label implementation. Update tests --- .../utils/cold-email/is-cold-email.test.ts | 42 +++++++----- apps/web/utils/cold-email/is-cold-email.ts | 68 +------------------ apps/web/utils/email/provider.ts | 62 ++++------------- 3 files changed, 44 insertions(+), 128 deletions(-) diff --git a/apps/web/utils/cold-email/is-cold-email.test.ts b/apps/web/utils/cold-email/is-cold-email.test.ts index 442ba45adb..b7b3dc67f4 100644 --- a/apps/web/utils/cold-email/is-cold-email.test.ts +++ b/apps/web/utils/cold-email/is-cold-email.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import prisma from "@/utils/prisma"; import { ColdEmailSetting, ColdEmailStatus } from "@prisma/client"; -import { blockColdEmail } from "./is-cold-email"; +import { blockColdEmailWithProvider } from "./is-cold-email"; import { getEmailAccount } from "@/__tests__/helpers"; import type { EmailProvider } from "@/utils/email/provider"; @@ -40,7 +40,13 @@ describe("blockColdEmail", () => { }); it("should upsert cold email record in database", async () => { - await blockColdEmail({ + vi.mocked(mockProvider.getOrCreateInboxZeroLabel).mockResolvedValue({ + id: "label123", + name: "Cold Email", + type: "user", + }); + + await blockColdEmailWithProvider({ provider: mockProvider, email: mockEmail, emailAccount: mockEmailAccount, @@ -73,7 +79,7 @@ describe("blockColdEmail", () => { type: "user", }); - await blockColdEmail({ + await blockColdEmailWithProvider({ provider: mockProvider, email: mockEmail, emailAccount: mockEmailAccount, @@ -83,9 +89,10 @@ describe("blockColdEmail", () => { expect(mockProvider.getOrCreateInboxZeroLabel).toHaveBeenCalledWith( "cold_email", ); - expect(mockProvider.labelMessage).toHaveBeenCalledWith(mockEmail.id, [ + expect(mockProvider.labelMessage).toHaveBeenCalledWith( + mockEmail.id, "Cold Email", - ]); + ); }); it("should archive email when coldEmailBlocker is ARCHIVE_AND_LABEL", async () => { @@ -99,16 +106,17 @@ describe("blockColdEmail", () => { type: "user", }); - await blockColdEmail({ + await blockColdEmailWithProvider({ provider: mockProvider, email: mockEmail, emailAccount: userWithArchive, aiReason: mockAiReason, }); - expect(mockProvider.labelMessage).toHaveBeenCalledWith(mockEmail.id, [ + expect(mockProvider.labelMessage).toHaveBeenCalledWith( + mockEmail.id, "Cold Email", - ]); + ); expect(mockProvider.archiveThread).toHaveBeenCalledWith( mockEmail.threadId, userWithArchive.email, @@ -126,16 +134,17 @@ describe("blockColdEmail", () => { type: "user", }); - await blockColdEmail({ + await blockColdEmailWithProvider({ provider: mockProvider, email: mockEmail, emailAccount: userWithArchiveAndRead, aiReason: mockAiReason, }); - expect(mockProvider.labelMessage).toHaveBeenCalledWith(mockEmail.id, [ + expect(mockProvider.labelMessage).toHaveBeenCalledWith( + mockEmail.id, "Cold Email", - ]); + ); expect(mockProvider.archiveThread).toHaveBeenCalledWith( mockEmail.threadId, userWithArchiveAndRead.email, @@ -150,7 +159,7 @@ describe("blockColdEmail", () => { const userWithoutEmail = { ...mockEmailAccount, email: null as any }; await expect( - blockColdEmail({ + blockColdEmailWithProvider({ provider: mockProvider, email: mockEmail, emailAccount: userWithoutEmail, @@ -166,16 +175,17 @@ describe("blockColdEmail", () => { type: "user", }); - await blockColdEmail({ + await blockColdEmailWithProvider({ provider: mockProvider, email: mockEmail, emailAccount: mockEmailAccount, aiReason: mockAiReason, }); - expect(mockProvider.labelMessage).toHaveBeenCalledWith(mockEmail.id, [ + expect(mockProvider.labelMessage).toHaveBeenCalledWith( + mockEmail.id, "Cold Email", - ]); + ); }); it("should not modify labels when coldEmailBlocker is DISABLED", async () => { @@ -184,7 +194,7 @@ describe("blockColdEmail", () => { coldEmailBlocker: ColdEmailSetting.DISABLED, }; - await blockColdEmail({ + await blockColdEmailWithProvider({ provider: mockProvider, email: mockEmail, emailAccount: userWithBlockerOff, diff --git a/apps/web/utils/cold-email/is-cold-email.ts b/apps/web/utils/cold-email/is-cold-email.ts index de23d5768d..c3a0dfc2d7 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -245,70 +245,6 @@ export async function runColdEmailBlockerWithProvider(options: { return { ...response, coldEmailId: coldEmail.id }; } -export async function blockColdEmail(options: { - provider: EmailProvider; - email: { from: string; id: string; threadId: string }; - emailAccount: Pick & EmailAccountWithAI; - aiReason: string | null; -}): Promise { - const { provider, email, emailAccount, aiReason } = options; - - const coldEmail = await prisma.coldEmail.upsert({ - where: { - emailAccountId_fromEmail: { - emailAccountId: emailAccount.id, - fromEmail: email.from, - }, - }, - update: { status: ColdEmailStatus.AI_LABELED_COLD }, - create: { - status: ColdEmailStatus.AI_LABELED_COLD, - fromEmail: email.from, - emailAccountId: emailAccount.id, - reason: aiReason, - messageId: email.id, - threadId: email.threadId, - }, - }); - - if ( - emailAccount.coldEmailBlocker === ColdEmailSetting.LABEL || - emailAccount.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_LABEL || - emailAccount.coldEmailBlocker === - ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL - ) { - if (!emailAccount.email) throw new Error("User email is required"); - const coldEmailLabel = - await provider.getOrCreateInboxZeroLabel("cold_email"); - if (!coldEmailLabel?.id) - logger.error("No label id", { emailAccountId: emailAccount.id }); - - const shouldArchive = - emailAccount.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_LABEL || - emailAccount.coldEmailBlocker === - ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL; - - const shouldMarkRead = - emailAccount.coldEmailBlocker === - ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL; - - const labels: string[] = []; - if (coldEmailLabel?.name) labels.push(coldEmailLabel.name); - - if (shouldArchive) { - await provider.archiveThread(email.threadId, emailAccount.email); - } - - if (shouldMarkRead) { - await provider.markReadThread(email.threadId, true); - } - - await provider.labelMessage(email.id, labels); - } - - return coldEmail; -} - // New function that works with EmailProvider export async function blockColdEmailWithProvider(options: { provider: EmailProvider; @@ -359,7 +295,9 @@ export async function blockColdEmailWithProvider(options: { // For Outlook, we'll use the provider's labelMessage method // The provider will handle the differences between Gmail labels and Outlook categories - await provider.labelMessage(email.id, coldEmailLabel.name); + if (coldEmailLabel?.name) { + await provider.labelMessage(email.id, coldEmailLabel.name); + } // For archiving and marking as read, we'll need to implement these in the provider if (shouldArchive) { diff --git a/apps/web/utils/email/provider.ts b/apps/web/utils/email/provider.ts index 4a138f6ab9..74765c3f99 100644 --- a/apps/web/utils/email/provider.ts +++ b/apps/web/utils/email/provider.ts @@ -173,10 +173,7 @@ export interface EmailProvider { ownerEmail: string, actionSource: "user" | "automation", ): Promise; - labelMessage( - messageId: string, - labelNames: string | string[] | undefined, - ): Promise; + labelMessage(messageId: string, labelNames: string): Promise; removeThreadLabel(threadId: string, labelId: string): Promise; getAwaitingReplyLabel(): Promise; draftEmail( @@ -406,31 +403,17 @@ export class GmailProvider implements EmailProvider { }); } - async labelMessage( - messageId: string, - labelNames: string | string[] | undefined, - ): Promise { - if (!labelNames) return; - - const normalizeLabels = (labels: string | string[] | undefined) => - Array.isArray(labels) ? labels : labels ? [labels] : []; - - const createLabel = async (name: string) => { - const label = await gmailGetOrCreateLabel({ gmail: this.client, name }); - if (!label.id) - throw new Error(`Label not found and unable to create label: ${name}`); - return label.id; - }; - - const addLabels = await Promise.all( - normalizeLabels(labelNames).map(createLabel), - ); - + async labelMessage(messageId: string, labelName: string): Promise { + const label = await gmailGetOrCreateLabel({ + gmail: this.client, + name: labelName, + }); + if (!label.id) + throw new Error("Label not found and unable to create label"); await gmailLabelMessage({ gmail: this.client, messageId, - addLabelIds: addLabels, - removeLabelIds: [], + addLabelIds: [label.id], }); } @@ -1015,30 +998,15 @@ export class OutlookProvider implements EmailProvider { }); } - async labelMessage( - messageId: string, - labelNames: string | string[] | undefined, - ): Promise { - if (!labelNames) return; - - const labelNamesArray = Array.isArray(labelNames) - ? labelNames - : [labelNames].filter((name) => name); - - const labels = await Promise.all( - labelNamesArray.map(async (labelName) => { - const label = await outlookGetOrCreateLabel({ - client: this.client, - name: labelName, - }); - return label.displayName || ""; - }), - ); - + async labelMessage(messageId: string, labelName: string): Promise { + const label = await outlookGetOrCreateLabel({ + client: this.client, + name: labelName, + }); await outlookLabelMessage({ client: this.client, messageId, - categories: labels, + categories: [label.displayName || ""], }); } From 60bc460acf7340e84f90d33615fc0a75ada953bf Mon Sep 17 00:00:00 2001 From: Eduardo Lelis Date: Tue, 29 Jul 2025 09:40:28 -0300 Subject: [PATCH 4/4] Cleanup unused imports --- apps/web/utils/cold-email/is-cold-email.ts | 4 ---- apps/web/utils/email/provider.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/web/utils/cold-email/is-cold-email.ts b/apps/web/utils/cold-email/is-cold-email.ts index c3a0dfc2d7..407f9ead7b 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -1,9 +1,6 @@ import { z } from "zod"; -import type { gmail_v1 } from "@googleapis/gmail"; import { chatCompletionObject } from "@/utils/llms"; import type { EmailAccountWithAI } from "@/utils/llms/types"; -import { getOrCreateInboxZeroLabel, GmailLabel } from "@/utils/gmail/label"; -import { labelMessage } from "@/utils/gmail/label"; import type { ColdEmail } from "@prisma/client"; import { ColdEmailSetting, @@ -14,7 +11,6 @@ import prisma from "@/utils/prisma"; import { DEFAULT_COLD_EMAIL_PROMPT } from "@/utils/cold-email/prompt"; import { stringifyEmail } from "@/utils/stringify-email"; import { createScopedLogger } from "@/utils/logger"; -import { hasPreviousCommunicationsWithSenderOrDomain } from "@/utils/gmail/message"; import type { EmailForLLM } from "@/utils/types"; import type { EmailProvider } from "@/utils/email/provider"; diff --git a/apps/web/utils/email/provider.ts b/apps/web/utils/email/provider.ts index 74765c3f99..d5a2295148 100644 --- a/apps/web/utils/email/provider.ts +++ b/apps/web/utils/email/provider.ts @@ -173,7 +173,7 @@ export interface EmailProvider { ownerEmail: string, actionSource: "user" | "automation", ): Promise; - labelMessage(messageId: string, labelNames: string): Promise; + labelMessage(messageId: string, labelName: string): Promise; removeThreadLabel(threadId: string, labelId: string): Promise; getAwaitingReplyLabel(): Promise; draftEmail(