From ba3389c93ee69b171266636cde404092aa9884fc Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 26 Aug 2025 17:49:29 +0300 Subject: [PATCH 01/16] Reply tracker for Outlook --- .cursor/rules/features/reply-tracker.mdc | 8 ++- .../google/webhook/process-history-item.ts | 15 ++++-- apps/web/utils/email/google.ts | 41 ++++++++++++-- apps/web/utils/email/microsoft.ts | 50 +++++++++++++++-- apps/web/utils/email/types.ts | 6 +++ apps/web/utils/gmail/label.ts | 39 ++++++++++++++ apps/web/utils/outlook/label.ts | 53 +++++++++++++++++++ .../reply-tracker/check-previous-emails.ts | 13 +++-- .../web/utils/reply-tracker/draft-tracking.ts | 16 +++--- apps/web/utils/reply-tracker/inbound.ts | 5 +- apps/web/utils/reply-tracker/label.ts | 31 ----------- apps/web/utils/reply-tracker/outbound.ts | 50 ++++++----------- 12 files changed, 228 insertions(+), 99 deletions(-) delete mode 100644 apps/web/utils/reply-tracker/label.ts diff --git a/.cursor/rules/features/reply-tracker.mdc b/.cursor/rules/features/reply-tracker.mdc index e0a187e845..5aaf78fedf 100644 --- a/.cursor/rules/features/reply-tracker.mdc +++ b/.cursor/rules/features/reply-tracker.mdc @@ -22,4 +22,10 @@ When enabling the reply tracker, we create a rule for the user that has the foll We'll draft a reply for the user automatically if DRAFT_EMAIL is set. The draft will be generated using the email history for this sender as well as the Knowledge base. See `.cursor/rules/features/knowledge.mdc` for more on the Knowledge Base feature. -Enabling `User.outboundReplyTracking` means that when a user sends an email, we'll run an LLM over the email and check if it needs a reply. If it does we'll mark it as Awaiting Reply. \ No newline at end of file +Typically rules apply to emails as they come in. Rules have no impact afterwards. So to remove a label from a thread later we use the `TRACK_THREAD` action. This action is in charge of removing the Awaiting Reply or To Reply label. Note, adding labels is handled in the usual manner or with `outboundReplyTracking`. + +Enabling `EmailAccount.outboundReplyTracking` means that when a user sends an email, we'll run an LLM over the email and check if it needs a reply. If it does we'll mark it as "Awaiting Reply". + +Some relevant files: +- apps/web/utils/reply-tracker/outbound.ts +- apps/web/utils/reply-tracker/inbound.ts \ No newline at end of file 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 200da3f0fc..aa883b92d4 100644 --- a/apps/web/app/api/google/webhook/process-history-item.ts +++ b/apps/web/app/api/google/webhook/process-history-item.ts @@ -24,6 +24,7 @@ import type { ParsedMessage } from "@/utils/types"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import { formatError } from "@/utils/error"; import { createEmailProvider } from "@/utils/email/provider"; +import type { EmailProvider } from "@/utils/email/types"; import { enqueueDigestItem } from "@/utils/digest/index"; import { HistoryEventType } from "@/app/api/google/webhook/types"; import { handleLabelRemovedEvent } from "@/app/api/google/webhook/process-label-removed-event"; @@ -139,7 +140,7 @@ export async function processHistoryItem( const isOutbound = parsedMessage.labelIds?.includes(GmailLabel.SENT); if (isOutbound) { - await handleOutbound(emailAccount, parsedMessage, gmail); + await handleOutbound(emailAccount, parsedMessage, provider); return; } @@ -241,7 +242,7 @@ export async function processHistoryItem( async function handleOutbound( emailAccount: EmailAccountWithAI, message: ParsedMessage, - gmail: gmail_v1.Gmail, + provider: EmailProvider, ) { const loggerOptions = { email: emailAccount.email, @@ -257,9 +258,13 @@ async function handleOutbound( trackSentDraftStatus({ emailAccountId: emailAccount.id, message, - gmail, + provider, + }), + handleOutboundReply({ + emailAccount, + message, + provider, }), - handleOutboundReply({ emailAccount, message, gmail }), ]); if (trackingResult.status === "rejected") { @@ -282,7 +287,7 @@ async function handleOutbound( await cleanupThreadAIDrafts({ threadId: message.threadId, emailAccountId: emailAccount.id, - gmail, + provider, }); } catch (cleanupError) { logger.error("Error during thread draft cleanup", { diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 45e76e0aa8..5db01b1bdd 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -13,6 +13,8 @@ import { createLabel, getOrCreateInboxZeroLabel, GmailLabel, + getNeedsReplyLabel, + getAwaitingReplyLabel, } from "@/utils/gmail/label"; import { labelVisibility, messageVisibility } from "@/utils/gmail/constants"; import type { InboxZeroLabel } from "@/utils/label"; @@ -46,10 +48,7 @@ import { getThreadsWithNextPageToken, } from "@/utils/gmail/thread"; import { decodeSnippet } from "@/utils/gmail/decode"; -import { - getAwaitingReplyLabel as getGmailAwaitingReplyLabel, - getReplyTrackingLabels, -} from "@/utils/reply-tracker/label"; +import { getReplyTrackingLabels } from "@/utils/gmail/label"; import { getDraft, deleteDraft } from "@/utils/gmail/draft"; import { getFiltersList, @@ -211,6 +210,24 @@ export class GmailProvider implements EmailProvider { }); } + async labelMessageById( + messageId: string, + label: { id?: string; name: string } | { id: string; name?: string }, + ): Promise { + const labelId = label.id; + + if (!labelId) { + logger.warn("Label ID is required", { label }); + return; + } + + await labelMessage({ + gmail: this.client, + messageId, + addLabelIds: [labelId], + }); + } + async getDraft(draftId: string): Promise { return getDraft(draftId, this.client); } @@ -292,7 +309,21 @@ export class GmailProvider implements EmailProvider { } async getAwaitingReplyLabel(): Promise { - return getGmailAwaitingReplyLabel(this.client); + return getAwaitingReplyLabel(this.client); + } + + async getNeedsReplyLabel(): Promise { + return getNeedsReplyLabel(this.client); + } + + async removeAwaitingReplyLabel(threadId: string): Promise { + const awaitingReplyLabelId = await this.getAwaitingReplyLabel(); + await removeThreadLabel(this.client, threadId, awaitingReplyLabelId); + } + + async removeNeedsReplyLabel(threadId: string): Promise { + const needsReplyLabelId = await this.getNeedsReplyLabel(); + await removeThreadLabel(this.client, threadId, needsReplyLabelId); } async createLabel(name: string): Promise { diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 16fc6b822f..e60f92e78d 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -10,6 +10,7 @@ import { getLabels, createLabel, getOrCreateInboxZeroLabel, + getLabelById, } from "@/utils/outlook/label"; import type { InboxZeroLabel } from "@/utils/label"; import type { ThreadsQuery } from "@/app/api/threads/validation"; @@ -24,6 +25,7 @@ import { getOrCreateLabel, labelMessage, markReadThread, + removeThreadLabel, } from "@/utils/outlook/label"; import { trashThread } from "@/utils/outlook/trash"; import { markSpam } from "@/utils/outlook/spam"; @@ -202,6 +204,24 @@ export class OutlookProvider implements EmailProvider { }); } + async labelMessageById( + messageId: string, + label: { id?: string; name: string } | { id: string; name?: string }, + ): Promise { + const name = label.name; + + if (!name) { + logger.warn("Label name is required", { label }); + return; + } + + await labelMessage({ + client: this.client, + messageId, + categories: [name], + }); + } + async getDraft(draftId: string): Promise { return getDraft(draftId, this.client); } @@ -275,9 +295,33 @@ export class OutlookProvider implements EmailProvider { return this.getThreadMessages(messageIds[0]); } - async removeThreadLabel(_threadId: string, _labelId: string): Promise { - // For Outlook, we don't need to do anything with labels at this point - return Promise.resolve(); + async removeThreadLabel(threadId: string, labelId: string): Promise { + // TODO: this can be more efficient by using the label name directly + // Get the label to convert ID to name (Outlook uses names) + const label = await getLabelById({ client: this.client, id: labelId }); + const categoryName = label.displayName || ""; + + await removeThreadLabel({ + client: this.client, + threadId, + categoryName, + }); + } + + async removeAwaitingReplyLabel(threadId: string): Promise { + await removeThreadLabel({ + client: this.client, + threadId, + categoryName: AWAITING_REPLY_LABEL_NAME, + }); + } + + async removeNeedsReplyLabel(threadId: string): Promise { + await removeThreadLabel({ + client: this.client, + threadId, + categoryName: NEEDS_REPLY_LABEL_NAME, + }); } async createLabel(name: string): Promise { diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 33674f03e4..0bb8084211 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -58,8 +58,14 @@ export interface EmailProvider { actionSource: "user" | "automation", ): Promise; labelMessage(messageId: string, labelName: string): Promise; + labelMessageById( + messageId: string, + label: { id?: string; name: string } | { id: string; name?: string }, + ): Promise; removeThreadLabel(threadId: string, labelId: string): Promise; getAwaitingReplyLabel(): Promise; + removeAwaitingReplyLabel(threadId: string): Promise; + removeNeedsReplyLabel(threadId: string): Promise; draftEmail( email: ParsedMessage, args: { to?: string; subject?: string; content: string }, diff --git a/apps/web/utils/gmail/label.ts b/apps/web/utils/gmail/label.ts index 5023cf8196..11e2045305 100644 --- a/apps/web/utils/gmail/label.ts +++ b/apps/web/utils/gmail/label.ts @@ -14,6 +14,10 @@ import { } from "@/utils/gmail/constants"; import { createScopedLogger } from "@/utils/logger"; import { withGmailRetry } from "@/utils/gmail/retry"; +import { + AWAITING_REPLY_LABEL_NAME, + NEEDS_REPLY_LABEL_NAME, +} from "@/utils/reply-tracker/consts"; const logger = createScopedLogger("gmail/label"); @@ -355,3 +359,38 @@ export async function getOrCreateInboxZeroLabel({ }); return createdLabel; } + +export async function getAwaitingReplyLabel( + gmail: gmail_v1.Gmail, +): Promise { + const [awaitingReplyLabel] = await getOrCreateLabels({ + gmail, + names: [AWAITING_REPLY_LABEL_NAME], + }); + return awaitingReplyLabel.id || ""; +} + +export async function getNeedsReplyLabel( + gmail: gmail_v1.Gmail, +): Promise { + const [toReplyLabel] = await getOrCreateLabels({ + gmail, + names: [NEEDS_REPLY_LABEL_NAME], + }); + return toReplyLabel.id || ""; +} + +export async function getReplyTrackingLabels(gmail: gmail_v1.Gmail): Promise<{ + awaitingReplyLabelId: string; + needsReplyLabelId: string; +}> { + const [awaitingReplyLabel, needsReplyLabel] = await getOrCreateLabels({ + gmail, + names: [AWAITING_REPLY_LABEL_NAME, NEEDS_REPLY_LABEL_NAME], + }); + + return { + awaitingReplyLabelId: awaitingReplyLabel.id || "", + needsReplyLabelId: needsReplyLabel.id || "", + }; +} diff --git a/apps/web/utils/outlook/label.ts b/apps/web/utils/outlook/label.ts index cf434d1241..7351bb0ce0 100644 --- a/apps/web/utils/outlook/label.ts +++ b/apps/web/utils/outlook/label.ts @@ -230,6 +230,59 @@ export async function labelThread({ ); } +export async function removeThreadLabel({ + client, + threadId, + categoryName, +}: { + client: OutlookClient; + threadId: string; + categoryName: string; +}) { + if (!categoryName) { + logger.warn("Category name is empty, skipping removal", { threadId }); + return; + } + + // Get all messages in the thread + const escapedThreadId = threadId.replace(/'/g, "''"); + const messages = await client + .getClient() + .api("/me/messages") + .filter(`conversationId eq '${escapedThreadId}'`) + .select("id,categories") + .get(); + + // Remove the category from each message + await Promise.all( + messages.value.map( + async (message: { id: string; categories?: string[] }) => { + if (!message.categories || !message.categories.includes(categoryName)) { + return; // Category not present, nothing to remove + } + + const updatedCategories = message.categories.filter( + (cat) => cat !== categoryName, + ); + + try { + await client + .getClient() + .api(`/me/messages/${message.id}`) + .patch({ categories: updatedCategories }); + } catch (error) { + logger.warn("Failed to remove category from message", { + messageId: message.id, + threadId, + categoryName, + error: error instanceof Error ? error.message : error, + }); + } + }, + ), + ); +} + export async function archiveThread({ client, threadId, diff --git a/apps/web/utils/reply-tracker/check-previous-emails.ts b/apps/web/utils/reply-tracker/check-previous-emails.ts index 9d0fb35043..e4e6c323b4 100644 --- a/apps/web/utils/reply-tracker/check-previous-emails.ts +++ b/apps/web/utils/reply-tracker/check-previous-emails.ts @@ -84,23 +84,22 @@ export async function processPreviousSentEmails({ } try { + const provider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: "google", + }); + if (latestMessage.labelIds?.includes(GmailLabel.SENT)) { // outbound logger.info("Processing outbound reply", loggerOptions); await handleOutboundReply({ emailAccount, message: latestMessage, - gmail, + provider, }); } else { // inbound logger.info("Processing inbound reply", loggerOptions); - - const provider = await createEmailProvider({ - emailAccountId: emailAccount.id, - provider: "google", - }); - await handleInboundReply({ emailAccount, message: latestMessage, diff --git a/apps/web/utils/reply-tracker/draft-tracking.ts b/apps/web/utils/reply-tracker/draft-tracking.ts index d189755fda..c7623db65e 100644 --- a/apps/web/utils/reply-tracker/draft-tracking.ts +++ b/apps/web/utils/reply-tracker/draft-tracking.ts @@ -1,10 +1,8 @@ -import type { gmail_v1 } from "@googleapis/gmail"; import { ActionType } from "@prisma/client"; import type { ParsedMessage } from "@/utils/types"; import prisma from "@/utils/prisma"; import { createScopedLogger } from "@/utils/logger"; import { calculateSimilarity } from "@/utils/similarity-score"; -import { getDraft, deleteDraft } from "@/utils/gmail/draft"; import { formatError } from "@/utils/error"; import type { EmailProvider } from "@/utils/email/types"; @@ -16,11 +14,11 @@ const logger = createScopedLogger("draft-tracking"); export async function trackSentDraftStatus({ emailAccountId, message, - gmail, + provider, }: { emailAccountId: string; message: ParsedMessage; - gmail: gmail_v1.Gmail; + provider: EmailProvider; }) { const { threadId, id: sentMessageId, textPlain: sentTextPlain } = message; @@ -67,7 +65,7 @@ export async function trackSentDraftStatus({ return; } - const draftExists = await getDraft(executedAction.draftId, gmail); + const draftExists = await provider.getDraft(executedAction.draftId); if (draftExists) { logger.info("Original AI draft still exists, sent message was different.", { @@ -248,11 +246,11 @@ export async function trackSentDraftStatusWithProvider({ export async function cleanupThreadAIDrafts({ threadId, emailAccountId, - gmail, + provider, }: { threadId: string; emailAccountId: string; - gmail: gmail_v1.Gmail; + provider: EmailProvider; }) { const loggerOptions = { emailAccountId, threadId }; logger.info("Starting cleanup of old AI drafts for thread", loggerOptions); @@ -295,7 +293,7 @@ export async function cleanupThreadAIDrafts({ draftId: action.draftId, }; try { - const draftDetails = await getDraft(action.draftId, gmail); + const draftDetails = await provider.getDraft(action.draftId); if (draftDetails?.textPlain) { // Draft exists, check if modified @@ -318,7 +316,7 @@ export async function cleanupThreadAIDrafts({ actionLoggerOptions, ); await Promise.all([ - deleteDraft(gmail, action.draftId), + provider.deleteDraft(action.draftId), // Mark as not sent (cleaned up because ignored/superseded) prisma.executedAction.update({ where: { id: action.id }, diff --git a/apps/web/utils/reply-tracker/inbound.ts b/apps/web/utils/reply-tracker/inbound.ts index 9ae26c3221..3852653edd 100644 --- a/apps/web/utils/reply-tracker/inbound.ts +++ b/apps/web/utils/reply-tracker/inbound.ts @@ -43,10 +43,7 @@ export async function coordinateReplyProcess({ sentAt, }); - const labelsPromise = client.removeThreadLabel( - threadId, - await client.getAwaitingReplyLabel(), - ); + const labelsPromise = client.removeAwaitingReplyLabel(threadId); const [dbResult, labelsResult] = await Promise.allSettled([ dbPromise, diff --git a/apps/web/utils/reply-tracker/label.ts b/apps/web/utils/reply-tracker/label.ts deleted file mode 100644 index ee09ed68c3..0000000000 --- a/apps/web/utils/reply-tracker/label.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { gmail_v1 } from "@googleapis/gmail"; -import { getOrCreateLabels } from "@/utils/gmail/label"; -import { - NEEDS_REPLY_LABEL_NAME, - AWAITING_REPLY_LABEL_NAME, -} from "@/utils/reply-tracker/consts"; - -export async function getAwaitingReplyLabel( - gmail: gmail_v1.Gmail, -): Promise { - const [awaitingReplyLabel] = await getOrCreateLabels({ - gmail, - names: [AWAITING_REPLY_LABEL_NAME], - }); - return awaitingReplyLabel.id || ""; -} - -export async function getReplyTrackingLabels(gmail: gmail_v1.Gmail): Promise<{ - awaitingReplyLabelId: string; - needsReplyLabelId: string; -}> { - const [awaitingReplyLabel, needsReplyLabel] = await getOrCreateLabels({ - gmail, - names: [AWAITING_REPLY_LABEL_NAME, NEEDS_REPLY_LABEL_NAME], - }); - - return { - awaitingReplyLabelId: awaitingReplyLabel.id || "", - needsReplyLabelId: needsReplyLabel.id || "", - }; -} diff --git a/apps/web/utils/reply-tracker/outbound.ts b/apps/web/utils/reply-tracker/outbound.ts index f6cb1559b4..0756488fb2 100644 --- a/apps/web/utils/reply-tracker/outbound.ts +++ b/apps/web/utils/reply-tracker/outbound.ts @@ -1,25 +1,22 @@ -import type { gmail_v1 } from "@googleapis/gmail"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailForLLM, ParsedMessage } from "@/utils/types"; import { aiCheckIfNeedsReply } from "@/utils/ai/reply/check-if-needs-reply"; import prisma from "@/utils/prisma"; -import { getThreadMessages } from "@/utils/gmail/thread"; import { ThreadTrackerType } from "@prisma/client"; import { createScopedLogger, type Logger } from "@/utils/logger"; import { getEmailForLLM } from "@/utils/get-email-from-message"; -import { getReplyTrackingLabels } from "@/utils/reply-tracker/label"; -import { labelMessage, removeThreadLabel } from "@/utils/gmail/label"; import { internalDateToDate } from "@/utils/date"; import type { EmailProvider } from "@/utils/email/types"; +import { AWAITING_REPLY_LABEL_NAME } from "@/utils/reply-tracker/consts"; export async function handleOutboundReply({ emailAccount, message, - gmail, + provider, }: { emailAccount: EmailAccountWithAI; message: ParsedMessage; - gmail: gmail_v1.Gmail; + provider: EmailProvider; }) { const logger = createScopedLogger("reply-tracker/outbound").with({ email: emailAccount.email, @@ -39,19 +36,13 @@ export async function handleOutboundReply({ logger.info("Checking outbound reply"); // 2. Get necessary labels - const { awaitingReplyLabelId, needsReplyLabelId } = - await getReplyTrackingLabels(gmail); + const { awaitingReplyLabelId } = await provider.getReplyTrackingLabels(); // 3. Resolve existing NEEDS_REPLY trackers for this thread - await resolveReplyTrackers( - gmail, - emailAccount.userId, - message.threadId, - needsReplyLabelId, - ); + await resolveReplyTrackers(provider, emailAccount.userId, message.threadId); // 4. Get thread context - const threadMessages = await getThreadMessages(message.threadId, gmail); + const threadMessages = await provider.getThreadMessages(message.threadId); if (!threadMessages?.length) { logger.error("No thread messages found, cannot proceed."); return; @@ -85,7 +76,7 @@ export async function handleOutboundReply({ if (aiResult.needsReply) { logger.info("Needs reply. Creating reply tracker outbound"); await createReplyTrackerOutbound({ - gmail, + provider, emailAccountId: emailAccount.id, threadId: message.threadId, messageId: message.id, @@ -126,15 +117,13 @@ export async function handleOutboundReplyWithProvider({ logger.info("Checking outbound reply"); // 2. Get necessary labels - const { awaitingReplyLabelId, needsReplyLabelId } = - await provider.getReplyTrackingLabels(); + const { awaitingReplyLabelId } = await provider.getReplyTrackingLabels(); // 3. Resolve existing NEEDS_REPLY trackers for this thread await resolveReplyTrackersWithProvider( provider, emailAccount.userId, message.threadId, - needsReplyLabelId, ); // 4. Get thread context @@ -186,7 +175,7 @@ export async function handleOutboundReplyWithProvider({ } async function createReplyTrackerOutbound({ - gmail, + provider, emailAccountId, threadId, messageId, @@ -194,7 +183,7 @@ async function createReplyTrackerOutbound({ sentAt, logger, }: { - gmail: gmail_v1.Gmail; + provider: EmailProvider; emailAccountId: string; threadId: string; messageId: string; @@ -222,10 +211,9 @@ async function createReplyTrackerOutbound({ }, }); - const labelPromise = labelMessage({ - gmail, - messageId, - addLabelIds: [awaitingReplyLabelId], + const labelPromise = provider.labelMessageById(messageId, { + id: awaitingReplyLabelId, + name: AWAITING_REPLY_LABEL_NAME, }); const [upsertResult, labelResult] = await Promise.allSettled([ @@ -307,10 +295,9 @@ async function createReplyTrackerOutboundWithProvider({ } async function resolveReplyTrackers( - gmail: gmail_v1.Gmail, + provider: EmailProvider, emailAccountId: string, threadId: string, - needsReplyLabelId: string, ) { const updateDbPromise = prisma.threadTracker.updateMany({ where: { @@ -324,17 +311,15 @@ async function resolveReplyTrackers( }, }); - const labelPromise = removeThreadLabel(gmail, threadId, needsReplyLabelId); + const labelPromise = provider.removeNeedsReplyLabel(threadId); await Promise.allSettled([updateDbPromise, labelPromise]); } -// New function that works with EmailProvider async function resolveReplyTrackersWithProvider( provider: EmailProvider, emailAccountId: string, threadId: string, - needsReplyLabelId: string, ) { const updateDbPromise = prisma.threadTracker.updateMany({ where: { @@ -348,10 +333,7 @@ async function resolveReplyTrackersWithProvider( }, }); - // For Outlook, if needsReplyLabelId is empty, we'll skip label removal - const labelPromise = needsReplyLabelId - ? provider.removeThreadLabel(threadId, needsReplyLabelId) - : Promise.resolve(); + const labelPromise = provider.removeNeedsReplyLabel(threadId); await Promise.allSettled([updateDbPromise, labelPromise]); } From 752dade280d7cfe09d82ee35eb1462e50dffb22a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:36:51 +0300 Subject: [PATCH 02/16] fix up processing for microsoft --- apps/web/utils/email/google.ts | 34 ++++----- apps/web/utils/email/microsoft.ts | 93 +++++++++++++----------- apps/web/utils/email/types.ts | 15 ++-- apps/web/utils/reply-tracker/outbound.ts | 11 ++- 4 files changed, 78 insertions(+), 75 deletions(-) diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 5db01b1bdd..3fda90377d 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -210,24 +210,6 @@ export class GmailProvider implements EmailProvider { }); } - async labelMessageById( - messageId: string, - label: { id?: string; name: string } | { id: string; name?: string }, - ): Promise { - const labelId = label.id; - - if (!labelId) { - logger.warn("Label ID is required", { label }); - return; - } - - await labelMessage({ - gmail: this.client, - messageId, - addLabelIds: [labelId], - }); - } - async getDraft(draftId: string): Promise { return getDraft(draftId, this.client); } @@ -648,6 +630,22 @@ export class GmailProvider implements EmailProvider { return getReplyTrackingLabels(this.client); } + async labelAwaitingReply(messageId: string, labelId: string): Promise { + await labelMessage({ + gmail: this.client, + messageId, + addLabelIds: [labelId], + }); + } + + async labelNeedsReply(messageId: string, labelId: string): Promise { + await labelMessage({ + gmail: this.client, + messageId, + addLabelIds: [labelId], + }); + } + async processHistory(options: { emailAddress: string; historyId?: number; diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index e60f92e78d..a64bb85413 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -204,24 +204,6 @@ export class OutlookProvider implements EmailProvider { }); } - async labelMessageById( - messageId: string, - label: { id?: string; name: string } | { id: string; name?: string }, - ): Promise { - const name = label.name; - - if (!name) { - logger.warn("Label name is required", { label }); - return; - } - - await labelMessage({ - client: this.client, - messageId, - categories: [name], - }); - } - async getDraft(draftId: string): Promise { return getDraft(draftId, this.client); } @@ -308,22 +290,6 @@ export class OutlookProvider implements EmailProvider { }); } - async removeAwaitingReplyLabel(threadId: string): Promise { - await removeThreadLabel({ - client: this.client, - threadId, - categoryName: AWAITING_REPLY_LABEL_NAME, - }); - } - - async removeNeedsReplyLabel(threadId: string): Promise { - await removeThreadLabel({ - client: this.client, - threadId, - categoryName: NEEDS_REPLY_LABEL_NAME, - }); - } - async createLabel(name: string): Promise { const label = await createLabel({ client: this.client, @@ -775,15 +741,6 @@ export class OutlookProvider implements EmailProvider { return getThreadsFromSenderWithSubject(this.client, sender, limit); } - async getAwaitingReplyLabel(): Promise { - const [awaitingReplyLabel] = await getOutlookOrCreateLabels({ - client: this.client, - names: [AWAITING_REPLY_LABEL_NAME], - }); - - return awaitingReplyLabel.id || ""; - } - async getReplyTrackingLabels(): Promise<{ awaitingReplyLabelId: string; needsReplyLabelId: string; @@ -800,6 +757,56 @@ export class OutlookProvider implements EmailProvider { }; } + async getNeedsReplyLabel(): Promise { + const [needsReplyLabel] = await getOutlookOrCreateLabels({ + client: this.client, + names: [NEEDS_REPLY_LABEL_NAME], + }); + + return needsReplyLabel.id || ""; + } + + async getAwaitingReplyLabel(): Promise { + const [awaitingReplyLabel] = await getOutlookOrCreateLabels({ + client: this.client, + names: [AWAITING_REPLY_LABEL_NAME], + }); + + return awaitingReplyLabel.id || ""; + } + + async labelNeedsReply(messageId: string, _labelId: string): Promise { + await labelMessage({ + client: this.client, + messageId, + categories: [NEEDS_REPLY_LABEL_NAME], + }); + } + + async labelAwaitingReply(messageId: string, _labelId: string): Promise { + await labelMessage({ + client: this.client, + messageId, + categories: [AWAITING_REPLY_LABEL_NAME], + }); + } + + async removeAwaitingReplyLabel(threadId: string): Promise { + await removeThreadLabel({ + client: this.client, + threadId, + categoryName: AWAITING_REPLY_LABEL_NAME, + }); + } + + async removeNeedsReplyLabel(threadId: string): Promise { + await removeThreadLabel({ + client: this.client, + threadId, + categoryName: NEEDS_REPLY_LABEL_NAME, + }); + } + async processHistory(options: { emailAddress: string; historyId?: number; diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 0bb8084211..2d32c3e099 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -58,12 +58,15 @@ export interface EmailProvider { actionSource: "user" | "automation", ): Promise; labelMessage(messageId: string, labelName: string): Promise; - labelMessageById( - messageId: string, - label: { id?: string; name: string } | { id: string; name?: string }, - ): Promise; removeThreadLabel(threadId: string, labelId: string): Promise; + getReplyTrackingLabels(): Promise<{ + awaitingReplyLabelId: string; + needsReplyLabelId: string; + }>; + getNeedsReplyLabel(): Promise; getAwaitingReplyLabel(): Promise; + labelAwaitingReply(messageId: string, labelId: string): Promise; + labelNeedsReply(messageId: string, labelId: string): Promise; removeAwaitingReplyLabel(threadId: string): Promise; removeNeedsReplyLabel(threadId: string): Promise; draftEmail( @@ -143,10 +146,6 @@ export interface EmailProvider { sender: string, limit: number, ): Promise>; - getReplyTrackingLabels(): Promise<{ - awaitingReplyLabelId: string; - needsReplyLabelId: string; - }>; processHistory(options: { emailAddress: string; historyId?: number; diff --git a/apps/web/utils/reply-tracker/outbound.ts b/apps/web/utils/reply-tracker/outbound.ts index 0756488fb2..81f89d4be2 100644 --- a/apps/web/utils/reply-tracker/outbound.ts +++ b/apps/web/utils/reply-tracker/outbound.ts @@ -7,7 +7,6 @@ import { createScopedLogger, type Logger } from "@/utils/logger"; import { getEmailForLLM } from "@/utils/get-email-from-message"; import { internalDateToDate } from "@/utils/date"; import type { EmailProvider } from "@/utils/email/types"; -import { AWAITING_REPLY_LABEL_NAME } from "@/utils/reply-tracker/consts"; export async function handleOutboundReply({ emailAccount, @@ -117,7 +116,7 @@ export async function handleOutboundReplyWithProvider({ logger.info("Checking outbound reply"); // 2. Get necessary labels - const { awaitingReplyLabelId } = await provider.getReplyTrackingLabels(); + const awaitingReplyLabelId = await provider.getAwaitingReplyLabel(); // 3. Resolve existing NEEDS_REPLY trackers for this thread await resolveReplyTrackersWithProvider( @@ -211,10 +210,10 @@ async function createReplyTrackerOutbound({ }, }); - const labelPromise = provider.labelMessageById(messageId, { - id: awaitingReplyLabelId, - name: AWAITING_REPLY_LABEL_NAME, - }); + const labelPromise = provider.labelAwaitingReply( + messageId, + awaitingReplyLabelId, + ); const [upsertResult, labelResult] = await Promise.allSettled([ upsertPromise, From c3cdddb8680505c4454ab1e0635ca96743b9181f Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:48:44 +0300 Subject: [PATCH 03/16] remove duplicated code --- .../outlook/webhook/process-history-item.ts | 16 +- .../web/utils/reply-tracker/draft-tracking.ts | 233 ------------------ apps/web/utils/reply-tracker/outbound.ts | 85 ------- 3 files changed, 8 insertions(+), 326 deletions(-) diff --git a/apps/web/app/api/outlook/webhook/process-history-item.ts b/apps/web/app/api/outlook/webhook/process-history-item.ts index 9b624585c3..8cb07cd1cf 100644 --- a/apps/web/app/api/outlook/webhook/process-history-item.ts +++ b/apps/web/app/api/outlook/webhook/process-history-item.ts @@ -7,7 +7,7 @@ import { categorizeSenderWithProvider } from "@/utils/categorize/senders/categor import { markMessageAsProcessing } from "@/utils/redis/message-processing"; import { isAssistantEmail } from "@/utils/assistant/is-assistant-email"; import { processAssistantEmail } from "@/utils/assistant/process-assistant-email"; -import { handleOutboundReplyWithProvider } from "@/utils/reply-tracker/outbound"; +import { handleOutboundReply } from "@/utils/reply-tracker/outbound"; import type { ProcessHistoryOptions, OutlookResourceData, @@ -16,8 +16,8 @@ import { ColdEmailSetting } from "@prisma/client"; import { logger } from "@/app/api/outlook/webhook/logger"; import { extractEmailAddress } from "@/utils/email"; import { - trackSentDraftStatusWithProvider, - cleanupThreadAIDraftsWithProvider, + trackSentDraftStatus, + cleanupThreadAIDrafts, } from "@/utils/reply-tracker/draft-tracking"; import { formatError } from "@/utils/error"; import { createEmailProvider } from "@/utils/email/provider"; @@ -297,15 +297,15 @@ async function handleOutbound( // Run tracking and outbound reply handling concurrently // The individual functions handle their own operational errors. const [trackingResult, outboundResult] = await Promise.allSettled([ - trackSentDraftStatusWithProvider({ + trackSentDraftStatus({ emailAccountId: emailAccount.id, message: parsedMessage, - provider: provider, + provider, }), - handleOutboundReplyWithProvider({ + handleOutboundReply({ emailAccount, message: parsedMessage, - provider: provider, + provider, }), ]); @@ -326,7 +326,7 @@ async function handleOutbound( // Run cleanup for any other old/unmodified drafts in the thread // Must happen after previous steps try { - await cleanupThreadAIDraftsWithProvider({ + await cleanupThreadAIDrafts({ threadId: conversationId || messageId, emailAccountId: emailAccount.id, provider: provider, diff --git a/apps/web/utils/reply-tracker/draft-tracking.ts b/apps/web/utils/reply-tracker/draft-tracking.ts index c7623db65e..fb61ea1f7d 100644 --- a/apps/web/utils/reply-tracker/draft-tracking.ts +++ b/apps/web/utils/reply-tracker/draft-tracking.ts @@ -124,120 +124,6 @@ export async function trackSentDraftStatus({ ); } -// New function that works with EmailProvider -export async function trackSentDraftStatusWithProvider({ - emailAccountId, - message, - provider, -}: { - emailAccountId: string; - message: ParsedMessage; - provider: EmailProvider; -}) { - const { threadId, id: sentMessageId, textPlain: sentTextPlain } = message; - - const loggerOptions = { threadId, sentMessageId }; - - logger.info( - "Checking if sent message corresponds to an AI draft", - loggerOptions, - ); - - if (!sentMessageId) { - logger.warn("Sent message missing ID, cannot track draft status", { - threadId, - }); - return; - } - - // Find the most recently created draft for this thread - const executedAction = await prisma.executedAction.findFirst({ - where: { - executedRule: { - emailAccountId, - threadId: threadId, - }, - type: ActionType.DRAFT_EMAIL, - draftId: { not: null }, - draftSendLog: null, - }, - orderBy: { - createdAt: "desc", - }, - select: { - id: true, - content: true, - draftId: true, - }, - }); - - if (!executedAction?.draftId) { - logger.info( - "No corresponding AI draft action with draftId found", - loggerOptions, - ); - return; - } - - const draftExists = await provider.getDraft(executedAction.draftId); - - if (draftExists) { - logger.info("Original AI draft still exists, sent message was different.", { - ...loggerOptions, - executedActionId: executedAction.id, - draftId: executedAction.draftId, - }); - // Mark the action to indicate its draft was not sent - await prisma.executedAction.update({ - where: { id: executedAction.id }, - data: { wasDraftSent: false }, - }); - return; - } - - logger.info( - "Original AI draft not found (likely sent or deleted), proceeding to log similarity.", - { - ...loggerOptions, - executedActionId: executedAction.id, - draftId: executedAction.draftId, - }, - ); - - const executedActionId = executedAction.id; - const loggerOptionsWithAction = { ...loggerOptions, executedActionId }; - - const similarityScore = calculateSimilarity( - executedAction.content, - sentTextPlain, - ); - - logger.info("Calculated similarity score", { - ...loggerOptionsWithAction, - similarityScore, - }); - - await prisma.$transaction([ - prisma.draftSendLog.create({ - data: { - executedActionId: executedActionId, - sentMessageId: sentMessageId, - similarityScore: similarityScore, - }, - }), - // Mark that the draft was sent - prisma.executedAction.update({ - where: { id: executedActionId }, - data: { wasDraftSent: true }, - }), - ]); - - logger.info( - "Successfully created draft send log and updated action status via transaction", - loggerOptionsWithAction, - ); -} - /** * Cleans up old, unmodified AI-generated drafts in a thread. * Finds drafts created by executed actions that haven't been logged as sent, @@ -360,122 +246,3 @@ export async function cleanupThreadAIDrafts({ }); } } - -// New function that works with EmailProvider -export async function cleanupThreadAIDraftsWithProvider({ - threadId, - emailAccountId, - provider, -}: { - threadId: string; - emailAccountId: string; - provider: EmailProvider; -}) { - const loggerOptions = { emailAccountId, threadId }; - logger.info("Starting cleanup of old AI drafts for thread", loggerOptions); - - try { - // Find all draft actions for this thread that haven't resulted in a sent log - const potentialDraftsToClean = await prisma.executedAction.findMany({ - where: { - executedRule: { - emailAccountId, - threadId: threadId, - }, - type: ActionType.DRAFT_EMAIL, - draftId: { not: null }, - draftSendLog: null, // Only consider drafts not logged as sent - }, - select: { - id: true, - draftId: true, - content: true, - }, - }); - - if (potentialDraftsToClean.length === 0) { - logger.info("No relevant old AI drafts found to cleanup", loggerOptions); - return; - } - - logger.info( - `Found ${potentialDraftsToClean.length} potential AI drafts to check for cleanup`, - loggerOptions, - ); - - for (const action of potentialDraftsToClean) { - if (!action.draftId) continue; // Not expected to happen, but to fix TS error - - const actionLoggerOptions = { - ...loggerOptions, - executedActionId: action.id, - draftId: action.draftId, - }; - try { - const draftDetails = await provider.getDraft(action.draftId); - - if (draftDetails?.textPlain) { - // Draft exists, check if modified - // Using calculateSimilarity == 1.0 as the check for "unmodified" - const similarityScore = calculateSimilarity( - action.content, - draftDetails.textPlain, - ); - const isUnmodified = similarityScore === 1.0; - - logger.info("Checked existing draft for modification", { - ...actionLoggerOptions, - similarityScore, - isUnmodified, - }); - - if (isUnmodified) { - logger.info( - "Draft is unmodified, deleting...", - actionLoggerOptions, - ); - await Promise.all([ - provider.deleteDraft(action.draftId), - // Mark as not sent (cleaned up because ignored/superseded) - prisma.executedAction.update({ - where: { id: action.id }, - data: { wasDraftSent: false }, - }), - ]); - logger.info( - "Deleted unmodified draft and updated action status.", - actionLoggerOptions, - ); - } else { - logger.info( - "Draft has been modified, skipping deletion.", - actionLoggerOptions, - ); - } - } else { - logger.info( - "Draft no longer exists, marking as not sent.", - actionLoggerOptions, - ); - // Draft doesn't exist anymore, mark as not sent - await prisma.executedAction.update({ - where: { id: action.id }, - data: { wasDraftSent: false }, - }); - } - } catch (error) { - logger.error("Error checking draft for cleanup", { - ...actionLoggerOptions, - error: formatError(error), - }); - } - } - - logger.info("Completed cleanup of old AI drafts for thread", loggerOptions); - } catch (error) { - logger.error("Error during thread draft cleanup", { - ...loggerOptions, - error: formatError(error), - }); - } -} diff --git a/apps/web/utils/reply-tracker/outbound.ts b/apps/web/utils/reply-tracker/outbound.ts index 81f89d4be2..4e1341a3c1 100644 --- a/apps/web/utils/reply-tracker/outbound.ts +++ b/apps/web/utils/reply-tracker/outbound.ts @@ -88,91 +88,6 @@ export async function handleOutboundReply({ } } -// New function that works with EmailProvider -export async function handleOutboundReplyWithProvider({ - emailAccount, - message, - provider, -}: { - emailAccount: EmailAccountWithAI; - message: ParsedMessage; - provider: EmailProvider; -}) { - const logger = createScopedLogger("reply-tracker/outbound").with({ - email: emailAccount.email, - messageId: message.id, - threadId: message.threadId, - }); - - // 1. Check if feature enabled - const isEnabled = await isOutboundTrackingEnabled({ - email: emailAccount.email, - }); - if (!isEnabled) { - logger.info("Outbound reply tracking disabled, skipping."); - return; - } - - logger.info("Checking outbound reply"); - - // 2. Get necessary labels - const awaitingReplyLabelId = await provider.getAwaitingReplyLabel(); - - // 3. Resolve existing NEEDS_REPLY trackers for this thread - await resolveReplyTrackersWithProvider( - provider, - emailAccount.userId, - message.threadId, - ); - - // 4. Get thread context - const threadMessages = await provider.getThreadMessages(message.threadId); - if (!threadMessages?.length) { - logger.error("No thread messages found, cannot proceed."); - return; - } - - // 5. Check if this message is the latest - const { isLatest, sortedMessages } = isMessageLatestInThread( - message, - threadMessages, - logger, - ); - if (!isLatest) { - logger.info( - "Skipping outbound reply check: message is not the latest in the thread", - ); - return; // Stop processing if not the latest - } - - // 6. Prepare data for AI - const { messageToSendForLLM, threadContextMessagesForLLM } = - prepareDataForAICheck(message, sortedMessages); - - // 7. Perform AI check - const aiResult = await aiCheckIfNeedsReply({ - emailAccount, - messageToSend: messageToSendForLLM, - threadContextMessages: threadContextMessagesForLLM, - }); - - // 8. If yes, create a tracker - if (aiResult.needsReply) { - logger.info("Needs reply. Creating reply tracker outbound"); - await createReplyTrackerOutboundWithProvider({ - provider, - emailAccountId: emailAccount.id, - threadId: message.threadId, - messageId: message.id, - awaitingReplyLabelId, - sentAt: internalDateToDate(message.internalDate), - logger, - }); - } else { - logger.trace("No need to reply"); - } -} - async function createReplyTrackerOutbound({ provider, emailAccountId, From 6972181dfe66c9785dc902fca2123d9eef5e3579 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:59:25 +0300 Subject: [PATCH 04/16] remove duplicate functions --- .../webhook/process-history-item.test.ts | 30 +++---- .../google/webhook/process-history-item.ts | 6 +- .../outlook/webhook/process-history-item.ts | 8 +- apps/web/utils/actions/categorize.ts | 14 ++-- .../utils/categorize/senders/categorize.ts | 48 ----------- .../utils/cold-email/is-cold-email.test.ts | 16 ++-- apps/web/utils/cold-email/is-cold-email.ts | 75 +---------------- apps/web/utils/reply-tracker/outbound.ts | 82 ------------------- 8 files changed, 39 insertions(+), 240 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 adf049946a..16412a100b 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 @@ -7,9 +7,8 @@ import { HistoryEventType } from "./types"; import { ColdEmailSetting } from "@prisma/client"; import type { gmail_v1 } from "@googleapis/gmail"; import { isAssistantEmail } from "@/utils/assistant/is-assistant-email"; -import { runColdEmailBlockerWithProvider } from "@/utils/cold-email/is-cold-email"; +import { runColdEmailBlocker } from "@/utils/cold-email/is-cold-email"; import { blockUnsubscribedEmails } from "@/app/api/google/webhook/block-unsubscribed-emails"; - import { markMessageAsProcessing } from "@/utils/redis/message-processing"; import { GmailLabel } from "@/utils/gmail/label"; import { categorizeSender } from "@/utils/categorize/senders/categorize"; @@ -53,9 +52,6 @@ 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), @@ -193,7 +189,7 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem(), options); expect(blockUnsubscribedEmails).not.toHaveBeenCalled(); - expect(runColdEmailBlockerWithProvider).not.toHaveBeenCalled(); + expect(runColdEmailBlocker).not.toHaveBeenCalled(); expect(processAssistantEmail).toHaveBeenCalledWith({ message: expect.objectContaining({ headers: expect.objectContaining({ @@ -235,7 +231,7 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem(), options); expect(blockUnsubscribedEmails).not.toHaveBeenCalled(); - expect(runColdEmailBlockerWithProvider).not.toHaveBeenCalled(); + expect(runColdEmailBlocker).not.toHaveBeenCalled(); }); it("should skip if email is unsubscribed", async () => { @@ -247,7 +243,7 @@ describe("processHistoryItem", () => { }; await processHistoryItem(createHistoryItem(), options); - expect(runColdEmailBlockerWithProvider).not.toHaveBeenCalled(); + expect(runColdEmailBlocker).not.toHaveBeenCalled(); }); it("should run cold email blocker when enabled", async () => { @@ -262,7 +258,7 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem(), options); - expect(runColdEmailBlockerWithProvider).toHaveBeenCalledWith({ + expect(runColdEmailBlocker).toHaveBeenCalledWith({ email: expect.objectContaining({ from: "sender@example.com", to: "", @@ -279,7 +275,7 @@ describe("processHistoryItem", () => { }); it("should skip further processing if cold email is detected", async () => { - vi.mocked(runColdEmailBlockerWithProvider).mockResolvedValueOnce({ + vi.mocked(runColdEmailBlocker).mockResolvedValueOnce({ isColdEmail: true, reason: "ai", aiReason: "This appears to be a cold email", @@ -307,7 +303,7 @@ describe("processHistoryItem", () => { }); it("should add cold email to digest when coldEmailDigest is true and cold email is detected", async () => { - vi.mocked(runColdEmailBlockerWithProvider).mockResolvedValueOnce({ + vi.mocked(runColdEmailBlocker).mockResolvedValueOnce({ isColdEmail: true, reason: "ai", aiReason: "This appears to be a cold email", @@ -328,7 +324,7 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem(), options); - expect(runColdEmailBlockerWithProvider).toHaveBeenCalledWith({ + expect(runColdEmailBlocker).toHaveBeenCalledWith({ email: expect.objectContaining({ from: "sender@example.com", to: "", @@ -377,13 +373,13 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem(), options); - expect(runColdEmailBlockerWithProvider).not.toHaveBeenCalled(); + expect(runColdEmailBlocker).not.toHaveBeenCalled(); expect(categorizeSender).toHaveBeenCalled(); expect(runRules).toHaveBeenCalled(); }); it("should process normally when cold email is not detected with coldEmailDigest enabled", async () => { - vi.mocked(runColdEmailBlockerWithProvider).mockResolvedValueOnce({ + vi.mocked(runColdEmailBlocker).mockResolvedValueOnce({ isColdEmail: false, reason: "hasPreviousEmail", }); @@ -402,14 +398,14 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem(), options); - expect(runColdEmailBlockerWithProvider).toHaveBeenCalled(); + expect(runColdEmailBlocker).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(runColdEmailBlockerWithProvider).mockResolvedValueOnce({ + vi.mocked(runColdEmailBlocker).mockResolvedValueOnce({ isColdEmail: true, reason: "ai-already-labeled", coldEmailId: "existing-cold-email-456", // Existing cold email entry ID @@ -429,7 +425,7 @@ describe("processHistoryItem", () => { await processHistoryItem(createHistoryItem("456", "thread-456"), options); - expect(runColdEmailBlockerWithProvider).toHaveBeenCalledWith({ + expect(runColdEmailBlocker).toHaveBeenCalledWith({ email: expect.objectContaining({ from: "sender@example.com", to: "", 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 aa883b92d4..e1112c4bab 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,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 { runColdEmailBlockerWithProvider } from "@/utils/cold-email/is-cold-email"; +import { runColdEmailBlocker } 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"; @@ -167,7 +167,7 @@ export async function processHistoryItem( const content = emailToContent(parsedMessage); - const response = await runColdEmailBlockerWithProvider({ + const response = await runColdEmailBlocker({ email: { from: parsedMessage.headers.from, to: "", @@ -209,7 +209,7 @@ export async function processHistoryItem( select: { category: true }, }); if (!existingSender?.category) { - await categorizeSender(sender, emailAccount, gmail, accessToken); + await categorizeSender(sender, emailAccount, provider); } } diff --git a/apps/web/app/api/outlook/webhook/process-history-item.ts b/apps/web/app/api/outlook/webhook/process-history-item.ts index 8cb07cd1cf..04f87ba581 100644 --- a/apps/web/app/api/outlook/webhook/process-history-item.ts +++ b/apps/web/app/api/outlook/webhook/process-history-item.ts @@ -1,9 +1,9 @@ import type { Message } from "@microsoft/microsoft-graph-types"; import prisma from "@/utils/prisma"; -import { runColdEmailBlockerWithProvider } from "@/utils/cold-email/is-cold-email"; +import { runColdEmailBlocker } from "@/utils/cold-email/is-cold-email"; import { runRules } from "@/utils/ai/choose-rule/run-rules"; import { blockUnsubscribedEmails } from "@/app/api/outlook/webhook/block-unsubscribed-emails"; -import { categorizeSenderWithProvider } from "@/utils/categorize/senders/categorize"; +import { categorizeSender } from "@/utils/categorize/senders/categorize"; import { markMessageAsProcessing } from "@/utils/redis/message-processing"; import { isAssistantEmail } from "@/utils/assistant/is-assistant-email"; import { processAssistantEmail } from "@/utils/assistant/process-assistant-email"; @@ -171,7 +171,7 @@ export async function processHistoryItem( const content = message.body?.content || ""; - const response = await runColdEmailBlockerWithProvider({ + const response = await runColdEmailBlocker({ email: { from, to: to.join(","), @@ -205,7 +205,7 @@ export async function processHistoryItem( select: { category: true }, }); if (!existingSender?.category) { - await categorizeSenderWithProvider(sender, emailAccount, emailProvider); + await categorizeSender(sender, emailAccount, emailProvider); } } diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index cfc7c8861b..d24209dcbf 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { revalidatePath } from "next/cache"; +import { createEmailProvider } from "@/utils/email/provider"; import { type CreateCategoryBody, createCategoryBody, @@ -24,7 +25,6 @@ import { saveCategorizationTotalItems } from "@/utils/redis/categorization-progr import { getSenders } from "@/app/api/user/categorize/senders/uncategorized/get-senders"; import { extractEmailAddress } from "@/utils/email"; import { actionClient } from "@/utils/actions/safe-action"; -import { getGmailClientForEmail } from "@/utils/account"; import { prefixPath } from "@/utils/path"; const logger = createScopedLogger("actions/categorize"); @@ -110,21 +110,21 @@ export const categorizeSenderAction = actionClient .schema(z.object({ senderAddress: z.string() })) .action( async ({ - ctx: { emailAccountId, session }, + ctx: { emailAccountId, provider }, parsedInput: { senderAddress }, }) => { - const gmail = await getGmailClientForEmail({ emailAccountId }); - const userResult = await validateUserAndAiAccess({ emailAccountId }); const { emailAccount } = userResult; - if (!session.session.token) throw new SafeError("No access token"); + const emailProvider = await createEmailProvider({ + emailAccountId, + provider, + }); const result = await categorizeSender( senderAddress, emailAccount, - gmail, - session.session.token, + emailProvider, ); revalidatePath(prefixPath(emailAccountId, "/smart-categories")); diff --git a/apps/web/utils/categorize/senders/categorize.ts b/apps/web/utils/categorize/senders/categorize.ts index 9b136d9517..e1af709bd3 100644 --- a/apps/web/utils/categorize/senders/categorize.ts +++ b/apps/web/utils/categorize/senders/categorize.ts @@ -1,11 +1,9 @@ -import type { gmail_v1 } from "@googleapis/gmail"; import prisma from "@/utils/prisma"; import { aiCategorizeSenders } from "@/utils/ai/categorize-sender/ai-categorize-senders"; import { defaultCategory, type SenderCategory } from "@/utils/categories"; import { isNewsletterSender } from "@/utils/ai/group/find-newsletters"; import { isReceiptSender } from "@/utils/ai/group/find-receipts"; import { aiCategorizeSender } from "@/utils/ai/categorize-sender/ai-categorize-single-sender"; -import { getThreadsFromSenderWithSubject } from "@/utils/gmail/thread"; import type { Category } from "@prisma/client"; import { getUserCategories } from "@/utils/category.server"; import type { EmailAccountWithAI } from "@/utils/llms/types"; @@ -17,52 +15,6 @@ import type { EmailProvider } from "@/utils/email/types"; const logger = createScopedLogger("categorize/senders"); export async function categorizeSender( - senderAddress: string, - emailAccount: EmailAccountWithAI, - gmail: gmail_v1.Gmail, - accessToken: string, - userCategories?: Pick[], -) { - const categories = - userCategories || - (await getUserCategories({ emailAccountId: emailAccount.id })); - if (categories.length === 0) return { categoryId: undefined }; - - const previousEmails = await getThreadsFromSenderWithSubject( - gmail, - accessToken, - senderAddress, - 3, - ); - - const aiResult = await aiCategorizeSender({ - emailAccount, - sender: senderAddress, - previousEmails, - categories, - }); - - if (aiResult) { - const { newsletter } = await updateSenderCategory({ - sender: senderAddress, - categories, - categoryName: aiResult.category, - emailAccountId: emailAccount.id, - }); - - return { categoryId: newsletter.categoryId }; - } - - logger.error("No AI result for sender", { - userEmail: emailAccount.email, - senderAddress, - }); - - return { categoryId: undefined }; -} - -// New function that works with EmailProvider -export async function categorizeSenderWithProvider( senderAddress: string, emailAccount: EmailAccountWithAI, provider: EmailProvider, 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 6a843917f2..1105095cf0 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 { blockColdEmailWithProvider } from "./is-cold-email"; +import { blockColdEmail } from "./is-cold-email"; import { getEmailAccount } from "@/__tests__/helpers"; import type { EmailProvider } from "@/utils/email/types"; @@ -46,7 +46,7 @@ describe("blockColdEmail", () => { type: "user", }); - await blockColdEmailWithProvider({ + await blockColdEmail({ provider: mockProvider, email: mockEmail, emailAccount: mockEmailAccount, @@ -79,7 +79,7 @@ describe("blockColdEmail", () => { type: "user", }); - await blockColdEmailWithProvider({ + await blockColdEmail({ provider: mockProvider, email: mockEmail, emailAccount: mockEmailAccount, @@ -106,7 +106,7 @@ describe("blockColdEmail", () => { type: "user", }); - await blockColdEmailWithProvider({ + await blockColdEmail({ provider: mockProvider, email: mockEmail, emailAccount: userWithArchive, @@ -134,7 +134,7 @@ describe("blockColdEmail", () => { type: "user", }); - await blockColdEmailWithProvider({ + await blockColdEmail({ provider: mockProvider, email: mockEmail, emailAccount: userWithArchiveAndRead, @@ -159,7 +159,7 @@ describe("blockColdEmail", () => { const userWithoutEmail = { ...mockEmailAccount, email: null as any }; await expect( - blockColdEmailWithProvider({ + blockColdEmail({ provider: mockProvider, email: mockEmail, emailAccount: userWithoutEmail, @@ -175,7 +175,7 @@ describe("blockColdEmail", () => { type: "user", }); - await blockColdEmailWithProvider({ + await blockColdEmail({ provider: mockProvider, email: mockEmail, emailAccount: mockEmailAccount, @@ -194,7 +194,7 @@ describe("blockColdEmail", () => { coldEmailBlocker: ColdEmailSetting.DISABLED, }; - await blockColdEmailWithProvider({ + await blockColdEmail({ 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 a891a1a6d1..1cedcaefbb 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -87,73 +87,6 @@ export async function isColdEmail({ }; } -// New function that works with EmailProvider -export async function isColdEmailWithProvider({ - email, - emailAccount, - provider, - modelType, -}: { - email: EmailForLLM & { threadId?: string }; - emailAccount: Pick & EmailAccountWithAI; - provider: EmailProvider; - modelType: ModelType; -}): Promise<{ - isColdEmail: boolean; - reason: ColdEmailBlockerReason; - aiReason?: string | null; -}> { - const loggerOptions = { - email: emailAccount.email, - threadId: email.threadId, - messageId: email.id, - }; - - logger.info("Checking is cold email", loggerOptions); - - // Check if we marked it as a cold email already - const isColdEmailer = await isKnownColdEmailSender({ - from: email.from, - emailAccountId: emailAccount.id, - }); - - if (isColdEmailer) { - logger.info("Known cold email sender", { - ...loggerOptions, - from: email.from, - }); - return { isColdEmail: true, reason: "ai-already-labeled" }; - } - - const hasPreviousEmail = - email.date && email.id - ? await provider.hasPreviousCommunicationsWithSenderOrDomain({ - from: email.from, - date: email.date, - messageId: email.id, - }) - : false; - - if (hasPreviousEmail) { - logger.info("Has previous email", loggerOptions); - return { isColdEmail: false, reason: "hasPreviousEmail" }; - } - - // otherwise run through ai to see if it's a cold email - const res = await aiIsColdEmail(email, emailAccount, modelType); - - logger.info("AI is cold email?", { - ...loggerOptions, - coldEmail: res.coldEmail, - }); - - return { - isColdEmail: !!res.coldEmail, - reason: "ai", - aiReason: res.reason, - }; -} - async function isKnownColdEmailSender({ from, emailAccountId, @@ -225,7 +158,7 @@ ${stringifyEmail(email, 500)} return response.object; } -export async function runColdEmailBlockerWithProvider(options: { +export async function runColdEmailBlocker(options: { email: EmailForLLM & { threadId: string }; provider: EmailProvider; emailAccount: Pick & @@ -237,7 +170,7 @@ export async function runColdEmailBlockerWithProvider(options: { aiReason?: string | null; coldEmailId?: string | null; }> { - const response = await isColdEmailWithProvider({ + const response = await isColdEmail({ email: options.email, emailAccount: options.emailAccount, provider: options.provider, @@ -246,7 +179,7 @@ export async function runColdEmailBlockerWithProvider(options: { if (!response.isColdEmail) return { ...response, coldEmailId: null }; - const coldEmail = await blockColdEmailWithProvider({ + const coldEmail = await blockColdEmail({ ...options, aiReason: response.aiReason ?? null, }); @@ -254,7 +187,7 @@ export async function runColdEmailBlockerWithProvider(options: { } // New function that works with EmailProvider -export async function blockColdEmailWithProvider(options: { +export async function blockColdEmail(options: { provider: EmailProvider; email: { from: string; id: string; threadId: string }; emailAccount: Pick & EmailAccountWithAI; diff --git a/apps/web/utils/reply-tracker/outbound.ts b/apps/web/utils/reply-tracker/outbound.ts index 4e1341a3c1..7c58b1f0f3 100644 --- a/apps/web/utils/reply-tracker/outbound.ts +++ b/apps/web/utils/reply-tracker/outbound.ts @@ -148,66 +148,6 @@ async function createReplyTrackerOutbound({ } } -async function createReplyTrackerOutboundWithProvider({ - provider, - emailAccountId, - threadId, - messageId, - awaitingReplyLabelId, - sentAt, - logger, -}: { - provider: EmailProvider; - emailAccountId: string; - threadId: string; - messageId: string; - awaitingReplyLabelId: string; - sentAt: Date; - logger: Logger; -}) { - if (!threadId || !messageId) return; - - const upsertPromise = prisma.threadTracker.upsert({ - where: { - emailAccountId_threadId_messageId: { - emailAccountId, - threadId, - messageId, - }, - }, - update: {}, - create: { - emailAccountId, - threadId, - messageId, - type: ThreadTrackerType.AWAITING, - sentAt, - }, - }); - - // For Outlook, if awaitingReplyLabelId is empty, we'll skip labeling - const labelPromise = awaitingReplyLabelId - ? provider.labelMessage(messageId, awaitingReplyLabelId) - : Promise.resolve(); - - const [upsertResult, labelResult] = await Promise.allSettled([ - upsertPromise, - labelPromise, - ]); - - if (upsertResult.status === "rejected") { - logger.error("Failed to upsert reply tracker", { - error: upsertResult.reason, - }); - } - - if (labelResult.status === "rejected") { - logger.error("Failed to label reply tracker", { - error: labelResult.reason, - }); - } -} - async function resolveReplyTrackers( provider: EmailProvider, emailAccountId: string, @@ -230,28 +170,6 @@ async function resolveReplyTrackers( await Promise.allSettled([updateDbPromise, labelPromise]); } -async function resolveReplyTrackersWithProvider( - provider: EmailProvider, - emailAccountId: string, - threadId: string, -) { - const updateDbPromise = prisma.threadTracker.updateMany({ - where: { - emailAccountId, - threadId, - resolved: false, - type: ThreadTrackerType.NEEDS_REPLY, - }, - data: { - resolved: true, - }, - }); - - const labelPromise = provider.removeNeedsReplyLabel(threadId); - - await Promise.allSettled([updateDbPromise, labelPromise]); -} - async function isOutboundTrackingEnabled({ email, }: { From cd919befcd019b79e0bb3bcbfcd8120f755ab229 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 28 Aug 2025 19:37:33 +0300 Subject: [PATCH 05/16] clean up --- .../api/google/webhook/process-history-item.ts | 1 - apps/web/utils/email/google.ts | 8 ++++++++ apps/web/utils/email/types.ts | 4 ++-- apps/web/utils/reply-tracker/outbound.ts | 18 ++++++++---------- 4 files changed, 18 insertions(+), 13 deletions(-) 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 e1112c4bab..1fa7bc0edf 100644 --- a/apps/web/app/api/google/webhook/process-history-item.ts +++ b/apps/web/app/api/google/webhook/process-history-item.ts @@ -40,7 +40,6 @@ export async function processHistoryItem( { gmail, emailAccount, - accessToken, hasAutomationRules, hasAiAccess, rules, diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 3fda90377d..9dde47bfa2 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -300,11 +300,19 @@ export class GmailProvider implements EmailProvider { async removeAwaitingReplyLabel(threadId: string): Promise { const awaitingReplyLabelId = await this.getAwaitingReplyLabel(); + if (!awaitingReplyLabelId) { + logger.warn("No awaiting reply label found"); + return; + } await removeThreadLabel(this.client, threadId, awaitingReplyLabelId); } async removeNeedsReplyLabel(threadId: string): Promise { const needsReplyLabelId = await this.getNeedsReplyLabel(); + if (!needsReplyLabelId) { + logger.warn("No needs reply label found"); + return; + } await removeThreadLabel(this.client, threadId, needsReplyLabelId); } diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 2d32c3e099..67a79bdc9e 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -63,8 +63,8 @@ export interface EmailProvider { awaitingReplyLabelId: string; needsReplyLabelId: string; }>; - getNeedsReplyLabel(): Promise; - getAwaitingReplyLabel(): Promise; + getNeedsReplyLabel(): Promise; + getAwaitingReplyLabel(): Promise; labelAwaitingReply(messageId: string, labelId: string): Promise; labelNeedsReply(messageId: string, labelId: string): Promise; removeAwaitingReplyLabel(threadId: string): Promise; diff --git a/apps/web/utils/reply-tracker/outbound.ts b/apps/web/utils/reply-tracker/outbound.ts index 7c58b1f0f3..746142af29 100644 --- a/apps/web/utils/reply-tracker/outbound.ts +++ b/apps/web/utils/reply-tracker/outbound.ts @@ -23,7 +23,6 @@ export async function handleOutboundReply({ threadId: message.threadId, }); - // 1. Check if feature enabled const isEnabled = await isOutboundTrackingEnabled({ email: emailAccount.email, }); @@ -34,20 +33,15 @@ export async function handleOutboundReply({ logger.info("Checking outbound reply"); - // 2. Get necessary labels - const { awaitingReplyLabelId } = await provider.getReplyTrackingLabels(); - - // 3. Resolve existing NEEDS_REPLY trackers for this thread + // Resolve existing NEEDS_REPLY trackers for this thread await resolveReplyTrackers(provider, emailAccount.userId, message.threadId); - // 4. Get thread context const threadMessages = await provider.getThreadMessages(message.threadId); if (!threadMessages?.length) { logger.error("No thread messages found, cannot proceed."); return; } - // 5. Check if this message is the latest const { isLatest, sortedMessages } = isMessageLatestInThread( message, threadMessages, @@ -60,20 +54,24 @@ export async function handleOutboundReply({ return; // Stop processing if not the latest } - // 6. Prepare data for AI const { messageToSendForLLM, threadContextMessagesForLLM } = prepareDataForAICheck(message, sortedMessages); - // 7. Perform AI check const aiResult = await aiCheckIfNeedsReply({ emailAccount, messageToSend: messageToSendForLLM, threadContextMessages: threadContextMessagesForLLM, }); - // 8. If yes, create a tracker if (aiResult.needsReply) { logger.info("Needs reply. Creating reply tracker outbound"); + + const awaitingReplyLabelId = await provider.getAwaitingReplyLabel(); + if (!awaitingReplyLabelId) { + logger.warn("No awaiting reply label found"); + return; + } + await createReplyTrackerOutbound({ provider, emailAccountId: emailAccount.id, From 1e401e22c9fcc88ccdaea71777da1bc34251984a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 28 Aug 2025 21:55:37 +0300 Subject: [PATCH 06/16] adjustments for reply tracking for outlook --- .../settings/AwaitingReplySetting.tsx | 7 +++-- apps/web/prisma/schema.prisma | 2 +- apps/web/utils/email/google.ts | 31 +++++++++++++------ apps/web/utils/email/microsoft.ts | 31 +++++++++---------- apps/web/utils/email/types.ts | 4 +-- apps/web/utils/reply-tracker/outbound.ts | 14 +-------- 6 files changed, 45 insertions(+), 44 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx index 1e3c9a9379..0f9e2f8c37 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx @@ -8,7 +8,10 @@ import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; import { LoadingContent } from "@/components/LoadingContent"; import { Skeleton } from "@/components/ui/skeleton"; import { SettingCard } from "@/components/SettingCard"; -import { AWAITING_REPLY_LABEL_NAME } from "@/utils/reply-tracker/consts"; +import { + AWAITING_REPLY_LABEL_NAME, + NEEDS_REPLY_LABEL_NAME, +} from "@/utils/reply-tracker/consts"; export function AwaitingReplySetting() { const { @@ -55,7 +58,7 @@ export function AwaitingReplySetting() { return ( { - await labelMessage({ - gmail: this.client, - messageId, - addLabelIds: [labelId], - }); - } - - async labelNeedsReply(messageId: string, labelId: string): Promise { + // async labelNeedsReply(messageId: string): Promise { + // const needsReplyLabelId = await this.getNeedsReplyLabel(); + // if (!needsReplyLabelId) { + // logger.warn("No needs reply label found"); + // return; + // } + + // await labelMessage({ + // gmail: this.client, + // messageId, + // addLabelIds: [needsReplyLabelId], + // }); + // } + + async labelAwaitingReply(messageId: string): Promise { + const awaitingReplyLabelId = await this.getAwaitingReplyLabel(); + if (!awaitingReplyLabelId) { + logger.warn("No awaiting reply label found"); + return; + } await labelMessage({ gmail: this.client, messageId, - addLabelIds: [labelId], + addLabelIds: [awaitingReplyLabelId], }); } diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index a64bb85413..58696e1697 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -36,7 +36,7 @@ import { getThreadsFromSenderWithSubject, } from "@/utils/outlook/thread"; import { getOutlookAttachment } from "@/utils/outlook/attachment"; -import { getOrCreateLabels as getOutlookOrCreateLabels } from "@/utils/outlook/label"; +import { getOrCreateLabels } from "@/utils/outlook/label"; import { AWAITING_REPLY_LABEL_NAME, NEEDS_REPLY_LABEL_NAME, @@ -745,11 +745,10 @@ export class OutlookProvider implements EmailProvider { awaitingReplyLabelId: string; needsReplyLabelId: string; }> { - const [awaitingReplyLabel, needsReplyLabel] = - await getOutlookOrCreateLabels({ - client: this.client, - names: [AWAITING_REPLY_LABEL_NAME, NEEDS_REPLY_LABEL_NAME], - }); + const [awaitingReplyLabel, needsReplyLabel] = await getOrCreateLabels({ + client: this.client, + names: [AWAITING_REPLY_LABEL_NAME, NEEDS_REPLY_LABEL_NAME], + }); return { awaitingReplyLabelId: awaitingReplyLabel.id || "", @@ -758,7 +757,7 @@ export class OutlookProvider implements EmailProvider { } async getNeedsReplyLabel(): Promise { - const [needsReplyLabel] = await getOutlookOrCreateLabels({ + const [needsReplyLabel] = await getOrCreateLabels({ client: this.client, names: [NEEDS_REPLY_LABEL_NAME], }); @@ -767,7 +766,7 @@ export class OutlookProvider implements EmailProvider { } async getAwaitingReplyLabel(): Promise { - const [awaitingReplyLabel] = await getOutlookOrCreateLabels({ + const [awaitingReplyLabel] = await getOrCreateLabels({ client: this.client, names: [AWAITING_REPLY_LABEL_NAME], }); @@ -775,15 +774,15 @@ export class OutlookProvider implements EmailProvider { return awaitingReplyLabel.id || ""; } - async labelNeedsReply(messageId: string, _labelId: string): Promise { - await labelMessage({ - client: this.client, - messageId, - categories: [NEEDS_REPLY_LABEL_NAME], - }); - } + // async labelNeedsReply(messageId: string): Promise { + // await labelMessage({ + // client: this.client, + // messageId, + // categories: [NEEDS_REPLY_LABEL_NAME], + // }); + // } - async labelAwaitingReply(messageId: string, _labelId: string): Promise { + async labelAwaitingReply(messageId: string): Promise { await labelMessage({ client: this.client, messageId, diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 67a79bdc9e..545d592705 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -65,8 +65,8 @@ export interface EmailProvider { }>; getNeedsReplyLabel(): Promise; getAwaitingReplyLabel(): Promise; - labelAwaitingReply(messageId: string, labelId: string): Promise; - labelNeedsReply(messageId: string, labelId: string): Promise; + labelAwaitingReply(messageId: string): Promise; + // labelNeedsReply(messageId: string): Promise; removeAwaitingReplyLabel(threadId: string): Promise; removeNeedsReplyLabel(threadId: string): Promise; draftEmail( diff --git a/apps/web/utils/reply-tracker/outbound.ts b/apps/web/utils/reply-tracker/outbound.ts index 746142af29..7c1b982b9b 100644 --- a/apps/web/utils/reply-tracker/outbound.ts +++ b/apps/web/utils/reply-tracker/outbound.ts @@ -66,18 +66,11 @@ export async function handleOutboundReply({ if (aiResult.needsReply) { logger.info("Needs reply. Creating reply tracker outbound"); - const awaitingReplyLabelId = await provider.getAwaitingReplyLabel(); - if (!awaitingReplyLabelId) { - logger.warn("No awaiting reply label found"); - return; - } - await createReplyTrackerOutbound({ provider, emailAccountId: emailAccount.id, threadId: message.threadId, messageId: message.id, - awaitingReplyLabelId, sentAt: internalDateToDate(message.internalDate), logger, }); @@ -91,7 +84,6 @@ async function createReplyTrackerOutbound({ emailAccountId, threadId, messageId, - awaitingReplyLabelId, sentAt, logger, }: { @@ -99,7 +91,6 @@ async function createReplyTrackerOutbound({ emailAccountId: string; threadId: string; messageId: string; - awaitingReplyLabelId: string; sentAt: Date; logger: Logger; }) { @@ -123,10 +114,7 @@ async function createReplyTrackerOutbound({ }, }); - const labelPromise = provider.labelAwaitingReply( - messageId, - awaitingReplyLabelId, - ); + const labelPromise = provider.labelAwaitingReply(messageId); const [upsertResult, labelResult] = await Promise.allSettled([ upsertPromise, From 60bc44443e117822906834075882491c52a5db06 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 28 Aug 2025 22:02:32 +0300 Subject: [PATCH 07/16] hide auto update reply action for user --- .../(app)/[emailAccountId]/assistant/RuleForm.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx index 1eacf6a992..725a261e5d 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx @@ -147,7 +147,11 @@ export function RuleForm({ ), actions: [ ...rule.actions - .filter((action) => action.type !== ActionType.DIGEST) + .filter( + (action) => + action.type !== ActionType.DIGEST && + action.type !== ActionType.TRACK_THREAD, + ) .map((action) => ({ ...action, delayInMinutes: action.delayInMinutes, @@ -363,14 +367,10 @@ export function RuleForm({ { label: "Mark read", value: ActionType.MARK_READ }, { label: "Mark spam", value: ActionType.MARK_SPAM }, { label: "Call webhook", value: ActionType.CALL_WEBHOOK }, - { - label: `Auto-update reply ${terminology.label.singular}`, - value: ActionType.TRACK_THREAD, - }, ]; return options; - }, [provider, terminology.label.action, terminology.label.singular]); + }, [provider, terminology.label.action]); const [isNameEditMode, setIsNameEditMode] = useState(alwaysEditMode); const [isConditionsEditMode, setIsConditionsEditMode] = From 44a46063cd9ada6163b11fd67da76bb088893f26 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 28 Aug 2025 22:18:58 +0300 Subject: [PATCH 08/16] make auto update to reply its own setting and hide from rule card --- .../assistant/ActionSummaryCard.tsx | 21 ++--- .../settings/AwaitingReplySetting.tsx | 2 +- .../assistant/settings/SettingsTab.tsx | 2 + .../assistant/settings/ToReplySetting.tsx | 76 +++++++++++++++++++ apps/web/utils/actions/settings.ts | 48 ++++++++++++ 5 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 apps/web/app/(app)/[emailAccountId]/assistant/settings/ToReplySetting.tsx diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx index faae4e5126..2875c06a3f 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx @@ -7,10 +7,6 @@ import { ACTION_TYPE_ICONS, } from "@/app/(app)/[emailAccountId]/assistant/constants"; import { TooltipExplanation } from "@/components/TooltipExplanation"; -import { - AWAITING_REPLY_LABEL_NAME, - NEEDS_REPLY_LABEL_NAME, -} from "@/utils/reply-tracker/consts"; import { getEmailTerminology } from "@/utils/terminology"; export function ActionSummaryCard({ @@ -22,6 +18,14 @@ export function ActionSummaryCard({ typeOptions: { label: string; value: ActionType }[]; provider: string; }) { + // don't display + if ( + action.type === ActionType.TRACK_THREAD || + action.type === ActionType.DIGEST + ) { + return null; + } + const terminology = getEmailTerminology(provider); const actionTypeLabel = typeOptions.find((opt) => opt.value === action.type)?.label || action.type; @@ -177,11 +181,6 @@ export function ActionSummaryCard({ "Sends email details and rule execution data to your webhook endpoint when this rule is triggered."; break; - case ActionType.TRACK_THREAD: - summaryContent = `Auto-update reply ${terminology.label.singular}`; - tooltipText = `Our AI will automatically update the thread ${terminology.label.singular} to '${NEEDS_REPLY_LABEL_NAME}' or '${AWAITING_REPLY_LABEL_NAME}' based on whether you need to respond or are awaiting a response from the recipient.`; - break; - case ActionType.ARCHIVE: summaryContent = "Skip Inbox"; break; @@ -194,10 +193,6 @@ export function ActionSummaryCard({ summaryContent = "Mark as spam"; break; - case ActionType.DIGEST: - summaryContent = "Add to digest"; - break; - case ActionType.MOVE_FOLDER: summaryContent = `Folder: ${action.folderName?.value || "unset"}`; break; diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx index 0f9e2f8c37..f16d6c6f69 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx @@ -57,7 +57,7 @@ export function AwaitingReplySetting() { return ( + diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/ToReplySetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/ToReplySetting.tsx new file mode 100644 index 0000000000..196bf90322 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/ToReplySetting.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { Toggle } from "@/components/Toggle"; +import { toastError, toastSuccess } from "@/components/Toast"; +import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; +import { useRules } from "@/hooks/useRules"; +import { LoadingContent } from "@/components/LoadingContent"; +import { Skeleton } from "@/components/ui/skeleton"; +import { SettingCard } from "@/components/SettingCard"; +import { NEEDS_REPLY_LABEL_NAME } from "@/utils/reply-tracker/consts"; +import { toggleToReplyTrackingAction } from "@/utils/actions/settings"; +import { ActionType } from "@prisma/client"; + +export function ToReplySetting() { + const { data: emailAccountData, isLoading, error } = useEmailAccountFull(); + + const { data: rules, mutate: mutateRules } = useRules(); + + // Check if any rules with "To Reply" label have TRACK_THREAD action + const enabled = useMemo(() => { + if (!rules) return false; + return rules.some( + (rule) => + rule.actions.some( + (action) => + action.type === ActionType.LABEL && + action.label === NEEDS_REPLY_LABEL_NAME, + ) && + rule.actions.some((action) => action.type === ActionType.TRACK_THREAD), + ); + }, [rules]); + + const handleToggle = useCallback( + async (enable: boolean) => { + if (!emailAccountData) return null; + + try { + await toggleToReplyTrackingAction(emailAccountData.id, { + enabled: enable, + }); + toastSuccess({ + description: `Auto-remove ${NEEDS_REPLY_LABEL_NAME} ${enable ? "enabled" : "disabled"}`, + }); + mutateRules(); // Refresh rules to update the toggle state + } catch (error) { + toastError({ + description: `Failed to update auto-remove setting: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }); + } + }, + [emailAccountData, mutateRules], + ); + + return ( + } + > + + + } + /> + ); +} diff --git a/apps/web/utils/actions/settings.ts b/apps/web/utils/actions/settings.ts index e5b89d007d..7434a8dd86 100644 --- a/apps/web/utils/actions/settings.ts +++ b/apps/web/utils/actions/settings.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { actionClient } from "@/utils/actions/safe-action"; +import { NEEDS_REPLY_LABEL_NAME } from "@/utils/reply-tracker/consts"; import { saveAiSettingsBody, saveEmailUpdateSettingsBody, @@ -90,6 +91,53 @@ export const updateAwaitingReplyTrackingAction = actionClient return { success: true }; }); +export const toggleToReplyTrackingAction = actionClient + .metadata({ name: "toggleToReplyTracking" }) + .schema(z.object({ enabled: z.boolean() })) + .action(async ({ ctx: { emailAccountId }, parsedInput: { enabled } }) => { + // Find all rules with "To Reply" label + const toReplyRules = await prisma.rule.findMany({ + where: { + emailAccountId, + actions: { + some: { + type: ActionType.LABEL, + label: NEEDS_REPLY_LABEL_NAME, + }, + }, + }, + include: { + actions: true, + }, + }); + + for (const rule of toReplyRules) { + const hasTrackThread = rule.actions.some( + (action) => action.type === ActionType.TRACK_THREAD, + ); + + if (enabled && !hasTrackThread) { + // Add TRACK_THREAD action + await prisma.action.create({ + data: { + type: ActionType.TRACK_THREAD, + ruleId: rule.id, + }, + }); + } else if (!enabled && hasTrackThread) { + // Remove TRACK_THREAD action + await prisma.action.deleteMany({ + where: { + ruleId: rule.id, + type: ActionType.TRACK_THREAD, + }, + }); + } + } + + return { success: true }; + }); + export const updateDigestItemsAction = actionClient .metadata({ name: "updateDigestItems" }) .schema(updateDigestItemsBody) From 259d357f5cb4dd1ce5afa7a1a74db4527b6b9380 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 28 Aug 2025 22:46:03 +0300 Subject: [PATCH 09/16] simplify settings --- .cursor/rules/features/reply-tracker.mdc | 44 ++++++----- .../settings/AwaitingReplySetting.tsx | 64 ++++++++++++---- .../assistant/settings/SettingsTab.tsx | 2 - .../assistant/settings/ToReplySetting.tsx | 76 ------------------- 4 files changed, 74 insertions(+), 112 deletions(-) delete mode 100644 apps/web/app/(app)/[emailAccountId]/assistant/settings/ToReplySetting.tsx diff --git a/.cursor/rules/features/reply-tracker.mdc b/.cursor/rules/features/reply-tracker.mdc index 5aaf78fedf..948a338fce 100644 --- a/.cursor/rules/features/reply-tracker.mdc +++ b/.cursor/rules/features/reply-tracker.mdc @@ -1,31 +1,39 @@ --- -description: +description: Reply tracking system that manages "To Reply" and "Awaiting Reply" labels automatically globs: alwaysApply: false --- # Reply Tracker -Reply Tracker (also known as Reply Zero) lets the user which emails need a reply for them and those they're awaiting a reply on. -It updates the labels for the thread automatically in Gmail. +Reply Tracker (Reply Zero) helps users track which emails need replies and which emails they're awaiting replies on. It automatically manages Gmail/Outlook labels based on email flow. -The database models and fields that are used for this feature: +## Core Components -- ThreadTracker -- User.outboundReplyTracking -- ActionType.TRACK_THREAD +**Database Models:** +- `ThreadTracker` - stores tracking state and timestamps +- `EmailAccount.outboundReplyTracking` - enables outbound reply detection +- `ActionType.TRACK_THREAD` - action that removes "Awaiting Reply" labels -The system uses rules. The AI can choose which rule to use each time an email comes in. Rules are for incoming emails only. Not ongoing. -When enabling the reply tracker, we create a rule for the user that has the following actions associated with it: -- LABEL: "To Reply" -- TRACK_THREAD -- DRAFT_EMAIL (optional) +**Labels:** +- `"To Reply"` - emails that need your response +- `"Awaiting Reply"` - emails you sent that need their response -We'll draft a reply for the user automatically if DRAFT_EMAIL is set. The draft will be generated using the email history for this sender as well as the Knowledge base. See `.cursor/rules/features/knowledge.mdc` for more on the Knowledge Base feature. +## How It Works -Typically rules apply to emails as they come in. Rules have no impact afterwards. So to remove a label from a thread later we use the `TRACK_THREAD` action. This action is in charge of removing the Awaiting Reply or To Reply label. Note, adding labels is handled in the usual manner or with `outboundReplyTracking`. +### Inbound Flow (Receiving Emails) +1. **Adding "To Reply"**: Regular rules with LABEL action add "To Reply" labels +2. **Removing "Awaiting Reply"**: Rules with TRACK_THREAD action remove "Awaiting Reply" labels when replies arrive -Enabling `EmailAccount.outboundReplyTracking` means that when a user sends an email, we'll run an LLM over the email and check if it needs a reply. If it does we'll mark it as "Awaiting Reply". +### Outbound Flow (Sending Emails) +1. **Removing "To Reply"**: Always removes "To Reply" label when you reply (if `outboundReplyTracking` enabled) +2. **Adding "Awaiting Reply"**: AI decides if your sent email needs a response and adds label accordingly -Some relevant files: -- apps/web/utils/reply-tracker/outbound.ts -- apps/web/utils/reply-tracker/inbound.ts \ No newline at end of file +## Settings + +Users control this via one unified setting: +- **"Reply tracking"**: Controls both outbound tracking (`outboundReplyTracking`) and automatically adds TRACK_THREAD actions to "To Reply" rules + +## Key Files +- `apps/web/utils/reply-tracker/outbound.ts` - handles sent emails +- `apps/web/utils/reply-tracker/inbound.ts` - handles received emails +- `apps/web/utils/ai/actions.ts` - executes TRACK_THREAD action diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx index f16d6c6f69..a31b3b7601 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx @@ -1,10 +1,14 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { Toggle } from "@/components/Toggle"; -import { updateAwaitingReplyTrackingAction } from "@/utils/actions/settings"; +import { + updateAwaitingReplyTrackingAction, + toggleToReplyTrackingAction, +} from "@/utils/actions/settings"; import { toastError, toastSuccess } from "@/components/Toast"; import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; +import { useRules } from "@/hooks/useRules"; import { LoadingContent } from "@/components/LoadingContent"; import { Skeleton } from "@/components/ui/skeleton"; import { SettingCard } from "@/components/SettingCard"; @@ -20,6 +24,8 @@ export function AwaitingReplySetting() { error, mutate, } = useEmailAccountFull(); + const { mutate: mutateRules } = useRules(); + const enabled = emailAccountData?.outboundReplyTracking ?? false; const handleToggle = useCallback( @@ -34,31 +40,57 @@ export function AwaitingReplySetting() { false, ); - try { - await updateAwaitingReplyTrackingAction(emailAccountData.id, { + // Update both outbound tracking and TRACK_THREAD actions + const [outboundResult, trackThreadResult] = await Promise.allSettled([ + updateAwaitingReplyTrackingAction(emailAccountData.id, { enabled: enable, + }), + toggleToReplyTrackingAction(emailAccountData.id, { enabled: enable }), + ]); + + // Check for errors + if (outboundResult.status === "rejected") { + mutate(); // Revert optimistic update + toastError({ + description: `Failed to update outbound tracking: ${outboundResult.reason}`, }); - toastSuccess({ - description: `Awaiting reply labels ${enable ? "enabled" : "disabled"}`, - }); - mutate(); - } catch (error) { - // Revert optimistic update on error - mutate(); + return; + } + + if (outboundResult.value?.serverError) { + mutate(); // Revert optimistic update + toastError({ description: outboundResult.value.serverError }); + return; + } + + if (trackThreadResult.status === "rejected") { toastError({ - description: `Failed to update awaiting reply labels: ${ - error instanceof Error ? error.message : "Unknown error" - }`, + description: `Failed to update thread tracking: ${trackThreadResult.reason}`, }); + // Don't return - outbound tracking still worked + } + + if ( + trackThreadResult.status === "fulfilled" && + trackThreadResult.value?.serverError + ) { + toastError({ description: trackThreadResult.value.serverError }); + // Don't return - outbound tracking still worked } + + toastSuccess({ + description: `Reply tracking ${enable ? "enabled" : "disabled"}`, + }); + + await Promise.allSettled([mutate(), mutateRules()]); }, - [emailAccountData, mutate], + [emailAccountData, mutate, mutateRules], ); return ( - diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/ToReplySetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/ToReplySetting.tsx deleted file mode 100644 index 196bf90322..0000000000 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/ToReplySetting.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import { useCallback, useMemo } from "react"; -import { Toggle } from "@/components/Toggle"; -import { toastError, toastSuccess } from "@/components/Toast"; -import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; -import { useRules } from "@/hooks/useRules"; -import { LoadingContent } from "@/components/LoadingContent"; -import { Skeleton } from "@/components/ui/skeleton"; -import { SettingCard } from "@/components/SettingCard"; -import { NEEDS_REPLY_LABEL_NAME } from "@/utils/reply-tracker/consts"; -import { toggleToReplyTrackingAction } from "@/utils/actions/settings"; -import { ActionType } from "@prisma/client"; - -export function ToReplySetting() { - const { data: emailAccountData, isLoading, error } = useEmailAccountFull(); - - const { data: rules, mutate: mutateRules } = useRules(); - - // Check if any rules with "To Reply" label have TRACK_THREAD action - const enabled = useMemo(() => { - if (!rules) return false; - return rules.some( - (rule) => - rule.actions.some( - (action) => - action.type === ActionType.LABEL && - action.label === NEEDS_REPLY_LABEL_NAME, - ) && - rule.actions.some((action) => action.type === ActionType.TRACK_THREAD), - ); - }, [rules]); - - const handleToggle = useCallback( - async (enable: boolean) => { - if (!emailAccountData) return null; - - try { - await toggleToReplyTrackingAction(emailAccountData.id, { - enabled: enable, - }); - toastSuccess({ - description: `Auto-remove ${NEEDS_REPLY_LABEL_NAME} ${enable ? "enabled" : "disabled"}`, - }); - mutateRules(); // Refresh rules to update the toggle state - } catch (error) { - toastError({ - description: `Failed to update auto-remove setting: ${ - error instanceof Error ? error.message : "Unknown error" - }`, - }); - } - }, - [emailAccountData, mutateRules], - ); - - return ( - } - > - - - } - /> - ); -} From 66e9a4ccd8291ad2cc77968d436847808f33fa76 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 28 Aug 2025 22:54:43 +0300 Subject: [PATCH 10/16] update copy based on provider --- .cursor/rules/features/reply-tracker.mdc | 8 ++++++++ .../assistant/settings/AwaitingReplySetting.tsx | 14 +++++++++++--- version.txt | 2 +- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.cursor/rules/features/reply-tracker.mdc b/.cursor/rules/features/reply-tracker.mdc index 948a338fce..e93debc7c8 100644 --- a/.cursor/rules/features/reply-tracker.mdc +++ b/.cursor/rules/features/reply-tracker.mdc @@ -37,3 +37,11 @@ Users control this via one unified setting: - `apps/web/utils/reply-tracker/outbound.ts` - handles sent emails - `apps/web/utils/reply-tracker/inbound.ts` - handles received emails - `apps/web/utils/ai/actions.ts` - executes TRACK_THREAD action + +## Future Improvements +The current implementation works but has some architectural complexity that could be cleaned up: +- TRACK_THREAD action is hidden from users but managed via settings +- Two separate systems (outbound tracking + TRACK_THREAD actions) that need to stay in sync +- Hard-coded label names ("To Reply", "Awaiting Reply") - users can't customize these +- Could potentially be simplified to a more unified label-based approach in the future +- May want to support todos diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx index a31b3b7601..425229a5cc 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; import { Toggle } from "@/components/Toggle"; import { updateAwaitingReplyTrackingAction, @@ -16,8 +16,11 @@ import { AWAITING_REPLY_LABEL_NAME, NEEDS_REPLY_LABEL_NAME, } from "@/utils/reply-tracker/consts"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { getEmailTerminology } from "@/utils/terminology"; export function AwaitingReplySetting() { + const { provider, isLoading: accountLoading } = useAccount(); const { data: emailAccountData, isLoading, @@ -27,6 +30,7 @@ export function AwaitingReplySetting() { const { mutate: mutateRules } = useRules(); const enabled = emailAccountData?.outboundReplyTracking ?? false; + const terminology = getEmailTerminology(provider); const handleToggle = useCallback( async (enable: boolean) => { @@ -89,8 +93,12 @@ export function AwaitingReplySetting() { return ( Date: Thu, 28 Aug 2025 23:25:13 +0300 Subject: [PATCH 11/16] adjust toggle setting into one action --- .../settings/AwaitingReplySetting.tsx | 43 ++----------- apps/web/utils/actions/settings.ts | 63 ++++++++++--------- 2 files changed, 40 insertions(+), 66 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx index 425229a5cc..5ac186de7e 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/AwaitingReplySetting.tsx @@ -2,10 +2,7 @@ import { useCallback } from "react"; import { Toggle } from "@/components/Toggle"; -import { - updateAwaitingReplyTrackingAction, - toggleToReplyTrackingAction, -} from "@/utils/actions/settings"; +import { updateReplyTrackingAction } from "@/utils/actions/settings"; import { toastError, toastSuccess } from "@/components/Toast"; import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; import { useRules } from "@/hooks/useRules"; @@ -44,44 +41,16 @@ export function AwaitingReplySetting() { false, ); - // Update both outbound tracking and TRACK_THREAD actions - const [outboundResult, trackThreadResult] = await Promise.allSettled([ - updateAwaitingReplyTrackingAction(emailAccountData.id, { - enabled: enable, - }), - toggleToReplyTrackingAction(emailAccountData.id, { enabled: enable }), - ]); - - // Check for errors - if (outboundResult.status === "rejected") { - mutate(); // Revert optimistic update - toastError({ - description: `Failed to update outbound tracking: ${outboundResult.reason}`, - }); - return; - } + const result = await updateReplyTrackingAction(emailAccountData.id, { + enabled: enable, + }); - if (outboundResult.value?.serverError) { + if (result?.serverError) { mutate(); // Revert optimistic update - toastError({ description: outboundResult.value.serverError }); + toastError({ description: result.serverError }); return; } - if (trackThreadResult.status === "rejected") { - toastError({ - description: `Failed to update thread tracking: ${trackThreadResult.reason}`, - }); - // Don't return - outbound tracking still worked - } - - if ( - trackThreadResult.status === "fulfilled" && - trackThreadResult.value?.serverError - ) { - toastError({ description: trackThreadResult.value.serverError }); - // Don't return - outbound tracking still worked - } - toastSuccess({ description: `Reply tracking ${enable ? "enabled" : "disabled"}`, }); diff --git a/apps/web/utils/actions/settings.ts b/apps/web/utils/actions/settings.ts index 7434a8dd86..e40e7550ff 100644 --- a/apps/web/utils/actions/settings.ts +++ b/apps/web/utils/actions/settings.ts @@ -80,22 +80,11 @@ export const updateDigestScheduleAction = actionClient return { success: true }; }); -export const updateAwaitingReplyTrackingAction = actionClient - .metadata({ name: "updateAwaitingReplyTracking" }) +export const updateReplyTrackingAction = actionClient + .metadata({ name: "updateReplyTracking" }) .schema(z.object({ enabled: z.boolean() })) .action(async ({ ctx: { emailAccountId }, parsedInput: { enabled } }) => { - await prisma.emailAccount.update({ - where: { id: emailAccountId }, - data: { outboundReplyTracking: enabled }, - }); - return { success: true }; - }); - -export const toggleToReplyTrackingAction = actionClient - .metadata({ name: "toggleToReplyTracking" }) - .schema(z.object({ enabled: z.boolean() })) - .action(async ({ ctx: { emailAccountId }, parsedInput: { enabled } }) => { - // Find all rules with "To Reply" label + // Find all rules with "To Reply" label first const toReplyRules = await prisma.rule.findMany({ where: { emailAccountId, @@ -111,29 +100,45 @@ export const toggleToReplyTrackingAction = actionClient }, }); - for (const rule of toReplyRules) { - const hasTrackThread = rule.actions.some( - (action) => action.type === ActionType.TRACK_THREAD, - ); - - if (enabled && !hasTrackThread) { - // Add TRACK_THREAD action - await prisma.action.create({ + // Prepare the operations + const rulesToAddTrackThread = toReplyRules.filter( + (rule) => + enabled && + !rule.actions.some((action) => action.type === ActionType.TRACK_THREAD), + ); + + const rulesToRemoveTrackThread = toReplyRules.filter( + (rule) => + !enabled && + rule.actions.some((action) => action.type === ActionType.TRACK_THREAD), + ); + + // Execute all updates atomically + await prisma.$transaction([ + // Update email account setting + prisma.emailAccount.update({ + where: { id: emailAccountId }, + data: { outboundReplyTracking: enabled }, + }), + // Add TRACK_THREAD actions + ...rulesToAddTrackThread.map((rule) => + prisma.action.create({ data: { type: ActionType.TRACK_THREAD, ruleId: rule.id, }, - }); - } else if (!enabled && hasTrackThread) { - // Remove TRACK_THREAD action - await prisma.action.deleteMany({ + }), + ), + // Remove TRACK_THREAD actions + ...rulesToRemoveTrackThread.map((rule) => + prisma.action.deleteMany({ where: { ruleId: rule.id, type: ActionType.TRACK_THREAD, }, - }); - } - } + }), + ), + ]); return { success: true }; }); From 76f7724c6b3514b5a4baa0d614f4059225dd2a83 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Thu, 28 Aug 2025 23:41:04 +0300 Subject: [PATCH 12/16] code cleanup --- apps/web/utils/email/google.ts | 26 ++-------------------- apps/web/utils/email/microsoft.ts | 37 +++++-------------------------- apps/web/utils/email/types.ts | 5 ----- apps/web/utils/gmail/label.ts | 19 ++-------------- apps/web/utils/outlook/label.ts | 1 + 5 files changed, 10 insertions(+), 78 deletions(-) diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 292206eaf3..ffff8e4eb0 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -48,7 +48,6 @@ import { getThreadsWithNextPageToken, } from "@/utils/gmail/thread"; import { decodeSnippet } from "@/utils/gmail/decode"; -import { getReplyTrackingLabels } from "@/utils/gmail/label"; import { getDraft, deleteDraft } from "@/utils/gmail/draft"; import { getFiltersList, @@ -290,11 +289,11 @@ export class GmailProvider implements EmailProvider { await removeThreadLabel(this.client, threadId, labelId); } - async getAwaitingReplyLabel(): Promise { + async getAwaitingReplyLabel(): Promise { return getAwaitingReplyLabel(this.client); } - async getNeedsReplyLabel(): Promise { + async getNeedsReplyLabel(): Promise { return getNeedsReplyLabel(this.client); } @@ -631,27 +630,6 @@ export class GmailProvider implements EmailProvider { ); } - async getReplyTrackingLabels(): Promise<{ - awaitingReplyLabelId: string; - needsReplyLabelId: string; - }> { - return getReplyTrackingLabels(this.client); - } - - // async labelNeedsReply(messageId: string): Promise { - // const needsReplyLabelId = await this.getNeedsReplyLabel(); - // if (!needsReplyLabelId) { - // logger.warn("No needs reply label found"); - // return; - // } - - // await labelMessage({ - // gmail: this.client, - // messageId, - // addLabelIds: [needsReplyLabelId], - // }); - // } - async labelAwaitingReply(messageId: string): Promise { const awaitingReplyLabelId = await this.getAwaitingReplyLabel(); if (!awaitingReplyLabelId) { diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 58696e1697..091afd5268 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -741,53 +741,26 @@ export class OutlookProvider implements EmailProvider { return getThreadsFromSenderWithSubject(this.client, sender, limit); } - async getReplyTrackingLabels(): Promise<{ - awaitingReplyLabelId: string; - needsReplyLabelId: string; - }> { - const [awaitingReplyLabel, needsReplyLabel] = await getOrCreateLabels({ - client: this.client, - names: [AWAITING_REPLY_LABEL_NAME, NEEDS_REPLY_LABEL_NAME], - }); - - return { - awaitingReplyLabelId: awaitingReplyLabel.id || "", - needsReplyLabelId: needsReplyLabel.id || "", - }; - } - - async getNeedsReplyLabel(): Promise { + async getNeedsReplyLabel(): Promise { const [needsReplyLabel] = await getOrCreateLabels({ client: this.client, names: [NEEDS_REPLY_LABEL_NAME], }); - return needsReplyLabel.id || ""; + return needsReplyLabel.id || null; } - async getAwaitingReplyLabel(): Promise { + async getAwaitingReplyLabel(): Promise { const [awaitingReplyLabel] = await getOrCreateLabels({ client: this.client, names: [AWAITING_REPLY_LABEL_NAME], }); - return awaitingReplyLabel.id || ""; + return awaitingReplyLabel.id || null; } - // async labelNeedsReply(messageId: string): Promise { - // await labelMessage({ - // client: this.client, - // messageId, - // categories: [NEEDS_REPLY_LABEL_NAME], - // }); - // } - async labelAwaitingReply(messageId: string): Promise { - await labelMessage({ - client: this.client, - messageId, - categories: [AWAITING_REPLY_LABEL_NAME], - }); + await this.labelMessage(messageId, AWAITING_REPLY_LABEL_NAME); } async removeAwaitingReplyLabel(threadId: string): Promise { diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 545d592705..d0600a03d8 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -59,14 +59,9 @@ export interface EmailProvider { ): Promise; labelMessage(messageId: string, labelName: string): Promise; removeThreadLabel(threadId: string, labelId: string): Promise; - getReplyTrackingLabels(): Promise<{ - awaitingReplyLabelId: string; - needsReplyLabelId: string; - }>; getNeedsReplyLabel(): Promise; getAwaitingReplyLabel(): Promise; labelAwaitingReply(messageId: string): Promise; - // labelNeedsReply(messageId: string): Promise; removeAwaitingReplyLabel(threadId: string): Promise; removeNeedsReplyLabel(threadId: string): Promise; draftEmail( diff --git a/apps/web/utils/gmail/label.ts b/apps/web/utils/gmail/label.ts index 11e2045305..787ec9812c 100644 --- a/apps/web/utils/gmail/label.ts +++ b/apps/web/utils/gmail/label.ts @@ -362,7 +362,7 @@ export async function getOrCreateInboxZeroLabel({ export async function getAwaitingReplyLabel( gmail: gmail_v1.Gmail, -): Promise { +): Promise { const [awaitingReplyLabel] = await getOrCreateLabels({ gmail, names: [AWAITING_REPLY_LABEL_NAME], @@ -372,25 +372,10 @@ export async function getAwaitingReplyLabel( export async function getNeedsReplyLabel( gmail: gmail_v1.Gmail, -): Promise { +): Promise { const [toReplyLabel] = await getOrCreateLabels({ gmail, names: [NEEDS_REPLY_LABEL_NAME], }); return toReplyLabel.id || ""; } - -export async function getReplyTrackingLabels(gmail: gmail_v1.Gmail): Promise<{ - awaitingReplyLabelId: string; - needsReplyLabelId: string; -}> { - const [awaitingReplyLabel, needsReplyLabel] = await getOrCreateLabels({ - gmail, - names: [AWAITING_REPLY_LABEL_NAME, NEEDS_REPLY_LABEL_NAME], - }); - - return { - awaitingReplyLabelId: awaitingReplyLabel.id || "", - needsReplyLabelId: needsReplyLabel.id || "", - }; -} diff --git a/apps/web/utils/outlook/label.ts b/apps/web/utils/outlook/label.ts index 7351bb0ce0..292793141d 100644 --- a/apps/web/utils/outlook/label.ts +++ b/apps/web/utils/outlook/label.ts @@ -230,6 +230,7 @@ export async function labelThread({ ); } +// Doesn't use pagination. But this function not really used anyway. Can add in the future of needed. export async function removeThreadLabel({ client, threadId, From 88c419f91f9944ed01a2a0851cf4456abe6923d2 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:51:52 +0300 Subject: [PATCH 13/16] mobile styling setup --- .../web/app/(app)/[emailAccountId]/setup/SetupContent.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/setup/SetupContent.tsx b/apps/web/app/(app)/[emailAccountId]/setup/SetupContent.tsx index c4932b966f..8ce6998ae5 100644 --- a/apps/web/app/(app)/[emailAccountId]/setup/SetupContent.tsx +++ b/apps/web/app/(app)/[emailAccountId]/setup/SetupContent.tsx @@ -129,10 +129,10 @@ const StepItem = ({ href={href} {...linkProps} > -
+
{icon}
@@ -146,7 +146,7 @@ const StepItem = ({
{completed ? ( -
+

Complete your setup

- + {completedCount} of {totalSteps} completed
From 32ebfec50f7a9afc9ea024db26c57aa424a25a8a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:56:05 +0300 Subject: [PATCH 14/16] only run claude code on manual trigger --- .github/workflows/claude-code-review.yml | 43 +++++++++++------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index c754a3e9a8..f754a975d9 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,22 +1,19 @@ name: Claude Code Review on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + workflow_dispatch: # Manual trigger from GitHub UI jobs: claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + # Only run when @claude is mentioned in comments or manually triggered + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) runs-on: ubuntu-latest permissions: @@ -40,16 +37,16 @@ jobs: # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) # model: "claude-opus-4-1-20250805" - # Direct prompt for automated review (no @claude mention needed) - direct_prompt: | - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Be constructive and helpful in your feedback. + # Remove direct_prompt since we're using @claude mentions + # direct_prompt: | + # Please review this pull request and provide feedback on: + # - Code quality and best practices + # - Potential bugs or issues + # - Performance considerations + # - Security concerns + # - Test coverage + # + # Be constructive and helpful in your feedback. # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR # use_sticky_comment: true From b1a75e6d7b74e6dba7b9d315e8958f87ea82e5ef Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Fri, 29 Aug 2025 15:14:56 +0300 Subject: [PATCH 15/16] cr updates --- .github/workflows/claude-code-review.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index f754a975d9..ba86bbab84 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,19 +1,20 @@ name: Claude Code Review on: - issue_comment: - types: [created] pull_request_review_comment: types: [created] workflow_dispatch: # Manual trigger from GitHub UI jobs: claude-review: - # Only run when @claude is mentioned in comments or manually triggered + # Only run when manually triggered or when trusted users mention @claude in PR review comments if: | github.event_name == 'workflow_dispatch' || - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) + ( + github.event_name == 'pull_request_review_comment' && + contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + ) runs-on: ubuntu-latest permissions: From b76b6d54319a97040dae523d62a7af8a6b3656d3 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 31 Aug 2025 20:21:11 +0300 Subject: [PATCH 16/16] hide track thread action --- .../assistant/ProcessResultDisplay.tsx | 67 +++++++++++-------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx index e470877e09..08908b7521 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx @@ -9,6 +9,7 @@ import { HoverCard } from "@/components/HoverCard"; import { Badge } from "@/components/Badge"; import { isAIRule } from "@/utils/condition"; import { prefixPath } from "@/utils/path"; +import { ActionType } from "@prisma/client"; export function ProcessResultDisplay({ result, @@ -49,34 +50,46 @@ export function ProcessResultDisplay({ const MAX_LENGTH = 280; - const aiGeneratedContent = result.actionItems?.map((action, i) => ( -
-
- {capitalCase(action.type)} + const aiGeneratedContent = result.actionItems + ?.filter( + (action) => + action.type !== ActionType.TRACK_THREAD && + action.type !== ActionType.DIGEST, + ) + .map((action, i) => ( +
+
+ {capitalCase(action.type)} +
+ {Object.entries(action) + .filter( + ([key, value]) => + value && + [ + "label", + "subject", + "content", + "to", + "cc", + "bcc", + "url", + ].includes(key), + ) + .map(([key, value]) => ( +
+ + {capitalCase(key)}: + + + {value} + +
+ ))}
- {Object.entries(action) - .filter( - ([key, value]) => - value && - ["label", "subject", "content", "to", "cc", "bcc", "url"].includes( - key, - ), - ) - .map(([key, value]) => ( -
- - {capitalCase(key)}: - - - {value} - -
- ))} -
- )); + )); return (