diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index bc44c89bb2..404de4858f 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -371,6 +371,7 @@ export const createRulesOnboardingAction = actionClient hasDigest: false, draftReply: !!ruleConfiguration.draftReply, provider, + logger, }); return ( @@ -404,6 +405,7 @@ export const createRulesOnboardingAction = actionClient hasDigest: false, draftReply: !!ruleConfiguration.draftReply, provider, + logger, }); return prisma.rule @@ -490,6 +492,7 @@ export const createRulesOnboardingAction = actionClient hasDigest: false, draftReply: false, provider, + logger, }); const promise = prisma.rule @@ -787,6 +790,7 @@ async function getActionsFromCategoryAction({ draftReply, hasDigest, provider, + logger, }: { emailAccountId: string; rule: Rule; @@ -795,6 +799,7 @@ async function getActionsFromCategoryAction({ hasDigest: boolean; draftReply: boolean; provider: string; + logger: Logger; }): Promise { const emailProvider = await createEmailProvider({ emailAccountId, @@ -807,6 +812,13 @@ async function getActionsFromCategoryAction({ labelId: null, }); + logger.info("Resolved label ID during onboarding", { + requestedLabel: label, + resolvedLabelName: labelName, + resolvedLabelId: labelId, + ruleName: rule.name, + }); + let actions: Prisma.ActionCreateManyRuleInput[] = [ { type: ActionType.LABEL, label: labelName, labelId }, ]; @@ -828,6 +840,13 @@ async function getActionsFromCategoryAction({ const folderId = await emailProvider.getOrCreateOutlookFolderIdByName( rule.name, ); + + logger.info("Resolved folder ID during onboarding", { + folderName: rule.name, + resolvedFolderId: folderId, + categoryAction, + }); + actions = [ { type: ActionType.MOVE_FOLDER, diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index 11f60033e6..3ffe260110 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -5,6 +5,7 @@ import type { ActionItem, EmailForAction } from "@/utils/ai/types"; import type { EmailProvider } from "@/utils/email/types"; import { enqueueDigestItem } from "@/utils/digest/index"; import { filterNullProperties } from "@/utils"; +import { labelMessageAndSync } from "@/utils/label.server"; const logger = createScopedLogger("ai-actions"); @@ -79,7 +80,7 @@ const archive: ActionFunction> = async ({ const label: ActionFunction<{ label?: string | null; labelId?: string | null; -}> = async ({ client, email, args }) => { +}> = async ({ client, email, args, emailAccountId }) => { let labelIdToUse = args.labelId; // Lazy migration: If no labelId but label name exists, look it up @@ -104,7 +105,13 @@ const label: ActionFunction<{ if (!labelIdToUse) return; - await client.labelMessage({ messageId: email.id, labelId: labelIdToUse }); + await labelMessageAndSync({ + provider: client, + messageId: email.id, + labelId: labelIdToUse, + labelName: args.label || null, + emailAccountId, + }); }; const draft: ActionFunction<{ diff --git a/apps/web/utils/assistant/process-assistant-email.ts b/apps/web/utils/assistant/process-assistant-email.ts index f09a8c852e..3cf226ce45 100644 --- a/apps/web/utils/assistant/process-assistant-email.ts +++ b/apps/web/utils/assistant/process-assistant-email.ts @@ -7,6 +7,7 @@ import { emailToContent } from "@/utils/mail"; import { isAssistantEmail } from "@/utils/assistant/is-assistant-email"; import { internalDateToDate } from "@/utils/date"; import type { EmailProvider } from "@/utils/email/types"; +import { labelMessageAndSync } from "@/utils/label.server"; type ProcessAssistantEmailArgs = { emailAccountId: string; @@ -30,6 +31,7 @@ export async function processAssistantEmail({ return withProcessingLabels( message.id, provider, + emailAccountId, () => processAssistantEmailInternal({ emailAccountId, @@ -230,6 +232,7 @@ function verifyUserSentEmail({ async function withProcessingLabels( messageId: string, provider: EmailProvider, + emailAccountId: string, fn: () => Promise, logger: Logger, ): Promise { @@ -254,14 +257,18 @@ async function withProcessingLabels( } const labels = results - .map((result) => - result.status === "fulfilled" ? result.value?.id : undefined, - ) + .map((result) => (result.status === "fulfilled" ? result.value : undefined)) .filter(isDefined); if (labels.length) { // Fire and forget the initial labeling - provider.labelMessage({ messageId, labelId: labels[0] }).catch((error) => { + labelMessageAndSync({ + provider, + messageId, + labelId: labels[0].id, + labelName: labels[0].name, + emailAccountId, + }).catch((error) => { logger.error("Error labeling message", { error }); }); } diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 0e9f8cdb61..f53446a6ef 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -12,6 +12,7 @@ import { getLabel, getLabelById, createLabel, + getOrCreateLabel, getOrCreateInboxZeroLabel, GmailLabel, } from "@/utils/gmail/label"; @@ -269,15 +270,54 @@ export class GmailProvider implements EmailProvider { async labelMessage({ messageId, labelId, + labelName, }: { messageId: string; labelId: string; - }) { - await labelMessage({ - gmail: this.client, - messageId, - addLabelIds: [labelId], - }); + labelName: string | null; + }): Promise<{ usedFallback?: boolean; actualLabelId?: string }> { + try { + await labelMessage({ + gmail: this.client, + messageId, + addLabelIds: [labelId], + }); + + return {}; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + // Only use fallback for "label not found" errors + if ( + (errorMessage.includes("Requested entity was not found") || + errorMessage.includes("labelId not found")) && + labelName + ) { + logger.warn("Label not found by ID, trying to get or create by name", { + labelId, + labelName, + }); + + const label = await getOrCreateLabel({ + gmail: this.client, + name: labelName, + }); + await labelMessage({ + gmail: this.client, + messageId, + addLabelIds: [label.id!], + }); + + return { + usedFallback: true, + actualLabelId: label.id!, + }; + } + + // Re-throw if not a "not found" error or fallback didn't work + throw error; + } } async getDraft(draftId: string): Promise { diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 6ccb163614..539a4ff7c8 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -334,13 +334,28 @@ export class OutlookProvider implements EmailProvider { async labelMessage({ messageId, labelId, + labelName, }: { messageId: string; labelId: string; - }) { - const category = await this.getLabelById(labelId); + labelName: string | null; + }): Promise<{ usedFallback?: boolean; actualLabelId?: string }> { + let usedFallback = false; + let category = await this.getLabelById(labelId); + + if (!category && labelName) { + logger.warn("Category not found by ID, trying to get by name", { + labelId, + labelName, + }); + category = await this.getLabelByName(labelName); + usedFallback = true; + } + if (!category) { - throw new Error(`Category with ID ${labelId} not found`); + throw new Error( + `Category with ID ${labelId}${labelName ? ` or name ${labelName}` : ""} not found`, + ); } // Get current message categories to avoid replacing them @@ -361,6 +376,11 @@ export class OutlookProvider implements EmailProvider { categories: updatedCategories, }); } + + return { + usedFallback, + actualLabelId: category.id || undefined, + }; } async getDraft(draftId: string): Promise { diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 782f7029bb..8d72375a33 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -92,7 +92,11 @@ export interface EmailProvider { ownerEmail: string, actionSource: "user" | "automation", ): Promise; - labelMessage(options: { messageId: string; labelId: string }): Promise; + labelMessage(options: { + messageId: string; + labelId: string; + labelName: string | null; + }): Promise<{ usedFallback?: boolean; actualLabelId?: string }>; removeThreadLabel(threadId: string, labelId: string): Promise; removeThreadLabels(threadId: string, labelIds: string[]): Promise; draftEmail( diff --git a/apps/web/utils/gmail/label.ts b/apps/web/utils/gmail/label.ts index 5f13778889..003c7e7e86 100644 --- a/apps/web/utils/gmail/label.ts +++ b/apps/web/utils/gmail/label.ts @@ -248,6 +248,20 @@ export async function getLabelById(options: { return (await gmail.users.labels.get({ userId: "me", id })).data; } +export async function getOrCreateLabel({ + gmail, + name, +}: { + gmail: gmail_v1.Gmail; + name: string; +}) { + if (!name?.trim()) throw new Error("Label name cannot be empty"); + const label = await getLabel({ gmail, name }); + if (label) return label; + const createdLabel = await createLabel({ gmail, name }); + return createdLabel; +} + export async function getOrCreateInboxZeroLabel({ gmail, key, diff --git a/apps/web/utils/label.server.ts b/apps/web/utils/label.server.ts new file mode 100644 index 0000000000..908f1500ba --- /dev/null +++ b/apps/web/utils/label.server.ts @@ -0,0 +1,72 @@ +import type { EmailProvider } from "@/utils/email/types"; +import { createScopedLogger } from "@/utils/logger"; +import prisma from "@/utils/prisma"; + +/** + * Labels a message and automatically updates the database if a stale label ID was detected and fixed. + * + * This handles the case where labels/categories are deleted and recreated with new IDs: + * - Tries to label with the provided ID + * - If that fails and labelName is provided, falls back to looking up by name + * - If the actual ID used differs from the stored ID, updates ALL Actions with that stale ID + */ +export async function labelMessageAndSync({ + provider, + messageId, + labelId, + labelName, + emailAccountId, +}: { + provider: EmailProvider; + messageId: string; + labelId: string; + labelName: string | null; + emailAccountId: string; +}): Promise { + const logger = createScopedLogger("label.server").with({ + provider: provider.name, + messageId, + labelId, + labelName, + emailAccountId, + }); + + const result = await provider.labelMessage({ + messageId, + labelId, + labelName, + }); + + // If we had to use fallback and got a different ID, update all Actions with the stale ID + if ( + result.usedFallback && + result.actualLabelId && + result.actualLabelId !== labelId + ) { + logger.info("Detected stale label ID, updating all instances in database", { + oldLabelId: labelId, + newLabelId: result.actualLabelId, + }); + + try { + const updateResult = await prisma.action.updateMany({ + where: { + labelId, + rule: { emailAccountId }, + }, + data: { labelId: result.actualLabelId }, + }); + + logger.info("Updated stale label IDs across all actions", { + newLabelId: result.actualLabelId, + updatedCount: updateResult.count, + }); + } catch (error) { + // Don't fail the whole operation if DB update fails + logger.error("Failed to update stale label IDs", { + newLabelId: result.actualLabelId, + error, + }); + } + } +} diff --git a/apps/web/utils/reply-tracker/label-helpers.test.ts b/apps/web/utils/reply-tracker/label-helpers.test.ts index ce27757133..23a461d53c 100644 --- a/apps/web/utils/reply-tracker/label-helpers.test.ts +++ b/apps/web/utils/reply-tracker/label-helpers.test.ts @@ -261,7 +261,8 @@ describe("applyThreadStatusLabel", () => { // Should use the newly created label ID expect(mockProvider.labelMessage).toHaveBeenCalledWith({ messageId, - labelId: "label-to-reply", // From createLabel mock + labelId: "label-to-reply", + labelName: "To Reply", }); }); diff --git a/apps/web/utils/reply-tracker/label-helpers.ts b/apps/web/utils/reply-tracker/label-helpers.ts index 182a67689d..63b3849f74 100644 --- a/apps/web/utils/reply-tracker/label-helpers.ts +++ b/apps/web/utils/reply-tracker/label-helpers.ts @@ -7,22 +7,29 @@ import { type ConversationStatus, } from "./conversation-status-config"; import { getRuleLabel } from "@/utils/rule/consts"; +import { labelMessageAndSync } from "@/utils/label.server"; -type LabelIds = Record; +type LabelIds = Record< + ConversationStatus, + { + labelId: string | null; + label: string | null; + } +>; export async function removeConflictingThreadStatusLabels({ emailAccountId, threadId, systemType, provider, - dbLabelIds: providedDbLabelIds, + dbLabels: providedDbLabels, providerLabels: providedProviderLabels, }: { emailAccountId: string; threadId: string; systemType: ConversationStatus; provider: EmailProvider; - dbLabelIds?: LabelIds; + dbLabels?: LabelIds; providerLabels?: EmailLabel[]; }): Promise { const logger = createScopedLogger("removeConflictingThreadStatusLabels").with( @@ -34,8 +41,8 @@ export async function removeConflictingThreadStatusLabels({ }, ); - const [dbLabelIds, providerLabels] = await Promise.all([ - providedDbLabelIds ?? getLabelIdsFromDb(emailAccountId), + const [dbLabels, providerLabels] = await Promise.all([ + providedDbLabels ?? getLabelsFromDb(emailAccountId), providedProviderLabels ?? provider.getLabels(), ]); @@ -44,15 +51,21 @@ export async function removeConflictingThreadStatusLabels({ for (const type of CONVERSATION_STATUS_TYPES) { if (type === systemType) continue; - let labelId = dbLabelIds[type as ConversationStatus]; - if (!labelId) { - const label = providerLabels.find((l) => l.name === getRuleLabel(type)); - if (!label?.id) { + let label = dbLabels[type as ConversationStatus]; + if (!label.labelId && !label.label) { + const l = providerLabels.find((l) => l.name === getRuleLabel(type)); + if (!l?.id) { continue; } - labelId = label.id; + label = { + labelId: l.id, + label: l.name, + }; + } + if (!label.labelId) { + continue; } - removeLabelIds.push(labelId); + removeLabelIds.push(label.labelId); } if (removeLabelIds.length === 0) { @@ -100,34 +113,46 @@ export async function applyThreadStatusLabel({ provider, }); - const [dbLabelIds, providerLabels] = await Promise.all([ - getLabelIdsFromDb(emailAccountId), + const [dbLabels, providerLabels] = await Promise.all([ + getLabelsFromDb(emailAccountId), provider.getLabels(), ]); const addLabel = async () => { - let targetLabelId = dbLabelIds[systemType]; + let targetLabel = dbLabels[systemType]; - if (!targetLabelId) { + // If we don't have both labelId and label from DB, fetch/create it + if (!targetLabel.labelId && !targetLabel.label) { const label = providerLabels.find((l) => l.name === getRuleLabel(systemType)) || (await provider.createLabel(getRuleLabel(systemType))); - if (label) targetLabelId = label.id; - - if (!targetLabelId) { - logger.error("Failed to get or create target label"); - return; + if (label) { + targetLabel = { + labelId: label.id, + label: label.name, + }; } } - return provider - .labelMessage({ messageId, labelId: targetLabelId }) - .catch((error) => - logger.error("Failed to apply thread status label", { - labelId: targetLabelId, - error, - }), - ); + // Error only if we still don't have either field after attempting to fetch/create + if (!targetLabel.labelId && !targetLabel.label) { + logger.error("Failed to get or create target label"); + return; + } + + return labelMessageAndSync({ + provider, + messageId, + labelId: targetLabel.labelId || "", + labelName: targetLabel.label, + emailAccountId, + }).catch((error) => + logger.error("Failed to apply thread status label", { + labelId: targetLabel.labelId, + labelName: targetLabel.label, + error, + }), + ); }; await Promise.all([ @@ -136,7 +161,7 @@ export async function applyThreadStatusLabel({ threadId, systemType, provider, - dbLabelIds, + dbLabels, providerLabels, }), addLabel(), @@ -145,7 +170,7 @@ export async function applyThreadStatusLabel({ logger.info("Thread status label applied successfully"); } -async function getLabelIdsFromDb(emailAccountId: string): Promise { +async function getLabelsFromDb(emailAccountId: string): Promise { const rules = await prisma.rule.findMany({ where: { emailAccountId, @@ -155,25 +180,28 @@ async function getLabelIdsFromDb(emailAccountId: string): Promise { systemType: true, actions: { where: { type: ActionType.LABEL }, - select: { type: true, labelId: true }, + select: { type: true, labelId: true, label: true }, }, }, }); - const dbLabelIds: LabelIds = { - TO_REPLY: null, - AWAITING_REPLY: null, - FYI: null, - ACTIONED: null, + const dbLabels: LabelIds = { + TO_REPLY: { labelId: null, label: null }, + AWAITING_REPLY: { labelId: null, label: null }, + FYI: { labelId: null, label: null }, + ACTIONED: { labelId: null, label: null }, }; for (const rule of rules) { if (!rule.systemType) continue; const labelAction = rule.actions.find((a) => a.type === ActionType.LABEL); - if (labelAction?.labelId) { - dbLabelIds[rule.systemType as ConversationStatus] = labelAction.labelId; + if (labelAction?.labelId || labelAction?.label) { + dbLabels[rule.systemType as ConversationStatus] = { + labelId: labelAction.labelId, + label: labelAction.label, + }; } } - return dbLabelIds; + return dbLabels; } diff --git a/version.txt b/version.txt index 782426f763..046dbc5266 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.17.27 +v2.17.28