Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/web/utils/actions/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ export const createRulesOnboardingAction = actionClient
hasDigest: false,
draftReply: !!ruleConfiguration.draftReply,
provider,
logger,
});

return (
Expand Down Expand Up @@ -404,6 +405,7 @@ export const createRulesOnboardingAction = actionClient
hasDigest: false,
draftReply: !!ruleConfiguration.draftReply,
provider,
logger,
});

return prisma.rule
Expand Down Expand Up @@ -490,6 +492,7 @@ export const createRulesOnboardingAction = actionClient
hasDigest: false,
draftReply: false,
provider,
logger,
});

const promise = prisma.rule
Expand Down Expand Up @@ -787,6 +790,7 @@ async function getActionsFromCategoryAction({
draftReply,
hasDigest,
provider,
logger,
}: {
emailAccountId: string;
rule: Rule;
Expand All @@ -795,6 +799,7 @@ async function getActionsFromCategoryAction({
hasDigest: boolean;
draftReply: boolean;
provider: string;
logger: Logger;
}): Promise<Prisma.ActionCreateManyRuleInput[]> {
const emailProvider = await createEmailProvider({
emailAccountId,
Expand All @@ -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 },
];
Expand All @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions apps/web/utils/ai/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -79,7 +80,7 @@ const archive: ActionFunction<Record<string, unknown>> = 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
Expand All @@ -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<{
Expand Down
15 changes: 11 additions & 4 deletions apps/web/utils/assistant/process-assistant-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,6 +31,7 @@ export async function processAssistantEmail({
return withProcessingLabels(
message.id,
provider,
emailAccountId,
() =>
processAssistantEmailInternal({
emailAccountId,
Expand Down Expand Up @@ -230,6 +232,7 @@ function verifyUserSentEmail({
async function withProcessingLabels<T>(
messageId: string,
provider: EmailProvider,
emailAccountId: string,
fn: () => Promise<T>,
logger: Logger,
): Promise<T> {
Expand All @@ -254,14 +257,18 @@ async function withProcessingLabels<T>(
}

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 });
});
}
Expand Down
52 changes: 46 additions & 6 deletions apps/web/utils/email/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getLabel,
getLabelById,
createLabel,
getOrCreateLabel,
getOrCreateInboxZeroLabel,
GmailLabel,
} from "@/utils/gmail/label";
Expand Down Expand Up @@ -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<ParsedMessage | null> {
Expand Down
26 changes: 23 additions & 3 deletions apps/web/utils/email/microsoft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -361,6 +376,11 @@ export class OutlookProvider implements EmailProvider {
categories: updatedCategories,
});
}

return {
usedFallback,
actualLabelId: category.id || undefined,
};
}

async getDraft(draftId: string): Promise<ParsedMessage | null> {
Expand Down
6 changes: 5 additions & 1 deletion apps/web/utils/email/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ export interface EmailProvider {
ownerEmail: string,
actionSource: "user" | "automation",
): Promise<void>;
labelMessage(options: { messageId: string; labelId: string }): Promise<void>;
labelMessage(options: {
messageId: string;
labelId: string;
labelName: string | null;
}): Promise<{ usedFallback?: boolean; actualLabelId?: string }>;
removeThreadLabel(threadId: string, labelId: string): Promise<void>;
removeThreadLabels(threadId: string, labelIds: string[]): Promise<void>;
draftEmail(
Expand Down
14 changes: 14 additions & 0 deletions apps/web/utils/gmail/label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
72 changes: 72 additions & 0 deletions apps/web/utils/label.server.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
});
}
}
}
3 changes: 2 additions & 1 deletion apps/web/utils/reply-tracker/label-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
elie222 marked this conversation as resolved.
});
});

Expand Down
Loading
Loading