Skip to content

Move to folder action#668

Merged
edulelis merged 8 commits intoelie222:stagingfrom
edulelis:move-to-folder-action
Aug 11, 2025
Merged

Move to folder action#668
edulelis merged 8 commits intoelie222:stagingfrom
edulelis:move-to-folder-action

Conversation

@edulelis
Copy link
Collaborator

@edulelis edulelis commented Aug 11, 2025

Summary by CodeRabbit

  • New Features

    • Add “Move to folder” rule action for Microsoft accounts, including folder entry and delayed execution support.
    • Action summaries and badges now show folder names.
  • Improvements

    • UI adds icon/colors for folder actions; rule form adapts options by email provider.
    • AI tools and APIs now read/write folderName in rule actions.
    • Outlook supports moving threads to folders; Gmail logs that move-to-folder isn’t supported.
  • Migrations

    • Database now stores folderName for actions and includes a new MOVE_FOLDER action type.

@vercel
Copy link

vercel bot commented Aug 11, 2025

@edulelis is attempting to deploy a commit to the Inbox Zero OSS Program Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 11, 2025

Walkthrough

Adds MOVE_FOLDER action type and folderName field across schema, APIs, UI, validation, and action execution. Implements provider abstraction with Gmail and Outlook providers; Outlook supports moving threads to folders while Gmail logs unsupported. Updates auth token saving flow and introduces provider guards in certain routes. Adjusts tests and migrations.

Changes

Cohort / File(s) Summary
Data model & migration
apps/web/prisma/schema.prisma, apps/web/prisma/migrations/.../migration.sql
Adds ActionType.MOVE_FOLDER and optional folderName to Action, ExecutedAction, ScheduledAction; migration updates enum and tables; drops default on VerificationToken.id.
Email provider abstraction & implementations
apps/web/utils/email/types.ts,
apps/web/utils/email/google.ts,
apps/web/utils/email/microsoft.ts,
apps/web/utils/ai/actions.ts
Introduces EmailProvider interface; adds GmailProvider and OutlookProvider classes; action runner supports MOVE_FOLDER via provider.moveThreadToFolder; updates EmailProvider import path.
MOVE_FOLDER UI and display
apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx,
apps/web/app/(app)/[emailAccountId]/assistant/constants.ts,
apps/web/utils/action-display.ts,
apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx,
apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx
Adds MOVE_FOLDER visuals (colors, icon, text) and display strings; RuleForm injects provider-aware “Move to folder” option for Microsoft; actions accept folderName; badges/types extended to include folderName.
Action fields plumbing & validation
apps/web/utils/action-item.ts,
apps/web/utils/actions/rule.validation.ts,
apps/web/utils/actions/rule.ts,
apps/web/utils/rule/rule.ts,
apps/web/utils/delayed-actions.ts,
apps/web/utils/ai/rule/create-rule-schema.ts,
apps/web/utils/ai/rule/create-prompt-from-rule.ts,
apps/web/utils/ai/assistant/chat.ts,
apps/web/utils/ai/assistant/process-user-request.ts,
apps/web/utils/reply-tracker/enable.ts
Adds folderName field support end-to-end (inputs, sanitize, mapping, creation/update, AI tool schemas/flows); validation requires folderName for MOVE_FOLDER; MOVE_FOLDER added to delayable actions and prompt text; default field mappings include folderName.
API routes and background
apps/web/app/api/user/rules/[id]/route.ts,
apps/web/app/api/ai/analyze-sender-pattern/route.ts,
apps/web/utils/actions/reply-tracking.ts
Rules GET includes folderName in action payloads; analyze-sender-pattern adds provider guard and selects provider; reply-tracking early-returns for non-Google and adjusts email account retrieval.
Outlook helpers
apps/web/utils/outlook/label.ts,
apps/web/utils/outlook/message.ts
Archive now resolves destination by folder name; exports WELL_KNOWN_FOLDERS; adds getOrCreateFolderByName to resolve/create folder paths; uses folder-based moves.
Auth change
apps/web/utils/auth.ts
saveTokens signature and logic revamped: supports refresh_token, emailAccountId or providerAccountId paths, generic provider, Date handling for expires_at, direct nested updates.
Tests and misc
apps/web/__tests__/ai-choose-rule.test.ts,
apps/web/utils/api-auth.test.ts,
apps/web/utils/middleware.test.ts,
apps/web/app/(app)/[emailAccountId]/assistant/settings/DraftReplies.tsx,
apps/web/package.json
Tests/data updated for folderName and Date/ms expiries; adds auth mock in middleware tests; DraftReplies optimistic action includes folderName: null; package.json whitespace tweak.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant WebUI
  participant API as Rules API
  participant Validator
  participant DB
  participant Runner as Action Runner
  participant Provider as EmailProvider

  User->>WebUI: Select MOVE_FOLDER + folderName
  WebUI->>API: Create/Update Rule (folderName)
  API->>Validator: Validate action (MOVE_FOLDER requires folderName)
  Validator-->>API: Valid
  API->>DB: Persist action { type: MOVE_FOLDER, folderName }
  DB-->>API: OK
  Note over Runner,Provider: On rule execution
  Runner->>DB: Load rule actions
  Runner->>Provider: moveThreadToFolder(threadId, userEmail, folderName)
  alt Microsoft
    Provider-->>Runner: Thread moved
  else Google
    Provider-->>Runner: No-op (warn)
  end
  Runner-->>User: Execution complete
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90 minutes

Possibly related PRs

  • Provider refactor #664: Refactors provider types into utils/email/types.ts and aligns imports, overlapping with this PR’s provider/type moves.
  • Outlook support #537: Introduces/expands the EmailProvider abstraction and implementations, touching the same move-to-folder pathways.
  • WIP: better-auth migration #632: Modifies utils/auth.ts and saveTokens behavior, directly related to the auth changes here.

Poem

A rabbit hopped through folders new,
With emerald hues and actions true.
“Move this thread!” I thump and cheer—
Outlook nods, the path is clear.
Gmail shrugs, “Labels only, friend.”
Still I bounce from end to end—
Rules now know where streams should bend. 🐇📂✨

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai generate unit tests to generate unit tests for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 21

🔭 Outside diff range comments (18)
apps/web/utils/llms/model.ts (1)

139-151: Avoid non-null assertions (!) for Bedrock credentials; re-check inside credentialProvider or capture narrowed locals

Non-null assertions violate our TS guidelines. Re-check within the async credentialProvider to satisfy the type system without using !.

-          model: createAmazonBedrock({
+          model: createAmazonBedrock({
             // accessKeyId: env.BEDROCK_ACCESS_KEY,
             // secretAccessKey: env.BEDROCK_SECRET_KEY,
             // sessionToken: undefined,
             region: env.BEDROCK_REGION,
-            credentialProvider: async () => ({
-              accessKeyId: env.BEDROCK_ACCESS_KEY!,
-              secretAccessKey: env.BEDROCK_SECRET_KEY!,
-              sessionToken: undefined,
-            }),
+            credentialProvider: async () => {
+              if (!env.BEDROCK_ACCESS_KEY || !env.BEDROCK_SECRET_KEY) {
+                throw new Error("Missing AWS Bedrock credentials");
+              }
+              return {
+                accessKeyId: env.BEDROCK_ACCESS_KEY,
+                secretAccessKey: env.BEDROCK_SECRET_KEY,
+                sessionToken: undefined,
+              };
+            },
           })(modelName),
apps/web/utils/ai/assistant/process-user-request.ts (2)

158-159: Avoid logging full LLM inputs (risk of PII leakage); log metadata instead

Logging allMessages can expose email content. Prefer logging only metadata (e.g., counts, roles) and omit bodies/headers.

-  logger.trace("Input", { allMessages });
+  logger.trace("Input", {
+    messageCount: allMessages.length,
+    roles: allMessages.map((m) => m.role),
+    hasOriginalEmail: Boolean(originalEmail),
+    hasMatchedRule: Boolean(matchedRule),
+  });

204-215: Add retry wrapper around the LLM call in process-user-request.ts

The createGenerateText helper only falls back to a backup model once—it doesn’t retry the primary model on transient failures. To guard against intermittent API errors, wrap your generateText invocation with withRetry from "@/utils/llms".

• File: apps/web/utils/ai/assistant/process-user-request.ts
– At the top, update the import to include withRetry:
ts import { createGenerateText, withRetry } from "@/utils/llms";
– Around line 210, change your call from:
diff - const result = await generateText({ - ...modelOptions, - messages: allMessages, - stopWhen: stepCountIs(5), - tools: { update_conditional_operator: tool({ /* … */ }) }, - }); + const result = await withRetry( + () => generateText({ + ...modelOptions, + messages: allMessages, + stopWhen: stepCountIs(5), + tools: { update_conditional_operator: tool({ /* … */ }) }, + }), + { retries: 2, retryDelay: 1000 } + );
Adjust retries/retryDelay to match project conventions.

apps/web/components/editor/extensions/LabelMention.tsx (3)

90-92: Fix strictNullChecks: possible use-before-assign for component/popup

Under strict null checks, these locals should be nullable to avoid “used before assignment” and to make the guard checks meaningful.

-        let component: ReactRenderer<MentionListRef>;
-        let popup: HTMLElement;
+        let component: ReactRenderer<MentionListRef> | null = null;
+        let popup: HTMLElement | null = null;
@@
-              popup.parentNode.removeChild(popup);
+              popup.parentNode.removeChild(popup);
@@
-            if (component) {
-              component.destroy();
-            }
+            if (component) {
+              component.destroy();
+              component = null;
+            }

Also applies to: 135-141


103-105: Replace console. with project logger (console disallowed)*

Guidelines forbid console.* in TS/TSX. Replace with createScopedLogger and keep messages at the same levels.

+import { createScopedLogger } from "@/utils/logger";
+const logger = createScopedLogger("editor/LabelMention");
@@
-            // Silently handle cleanup errors to prevent crashes
-            console.warn("Error during mention cleanup:", error);
+            // Silently handle cleanup errors to prevent crashes
+            logger.warn("Error during mention cleanup", { error });
@@
-              console.error("Error during mention start:", error);
+              logger.error("Error during mention start", { error });
@@
-              console.error("Error during mention update:", error);
+              logger.error("Error during mention update", { error });
@@
-              console.error("Error during mention keydown:", error);
+              logger.error("Error during mention keydown", { error });

Also applies to: 126-129, 151-154, 165-169


277-287: Escape attribute values in custom HTML renderer (prevent XSS)

Interpolating id/label directly into HTML can allow attribute injection if label contains quotes or angle brackets. Escape attributes or build via DOM.

-              markdownIt.renderer.rules.mention_open = (
+              const escapeAttr = (val: string) =>
+                val
+                  .replace(/&/g, "&amp;")
+                  .replace(/"/g, "&quot;")
+                  .replace(/</g, "&lt;")
+                  .replace(/>/g, "&gt;");
+
+              markdownIt.renderer.rules.mention_open = (
                 tokens: MarkdownItToken[],
                 idx: number,
               ) => {
                 const token = tokens[idx];
-                const id =
-                  token.attrs.find((attr) => attr[0] === "id")?.[1] || "";
-                const label =
-                  token.attrs.find((attr) => attr[0] === "label")?.[1] || "";
-                return `<span class="mention-label" data-type="mention" data-id="${id}" data-label="${label}" data-mention-suggestion-char="@" contenteditable="false">`;
+                const rawId =
+                  token.attrs.find((attr) => attr[0] === "id")?.[1] || "";
+                const rawLabel =
+                  token.attrs.find((attr) => attr[0] === "label")?.[1] || "";
+                const id = escapeAttr(rawId);
+                const label = escapeAttr(rawLabel);
+                return `<span class="mention-label" data-type="mention" data-id="${id}" data-label="${label}" data-mention-suggestion-char="@" contenteditable="false">`;
               };
apps/web/app/api/ai/analyze-sender-pattern/route.ts (2)

32-35: Fix auth header handling and status code

  • headers() is sync; remove unnecessary await.
  • Return 401 for invalid API key to match auth failure semantics.
-  if (!isValidInternalApiKey(await headers(), logger)) {
+  if (!isValidInternalApiKey(headers(), logger)) {
     logger.error("Invalid API key for sender pattern analysis", json);
-    return NextResponse.json({ error: "Invalid API key" });
+    return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
   }

66-74: Reorder checks: 404 when account missing, then provider guard

Current guard returns 400 for missing accounts due to optional chaining. Ensure not-found takes precedence, then enforce provider=google.

-    if (emailAccount?.account?.provider !== "google") {
-      logger.warn("Unsupported provider", { emailAccountId });
-      return NextResponse.json({ success: false }, { status: 400 });
-    }
-
-    if (!emailAccount) {
+    if (!emailAccount) {
       logger.error("Email account not found", { emailAccountId });
       return NextResponse.json({ success: false }, { status: 404 });
     }
+
+    if (emailAccount.account?.provider !== "google") {
+      logger.warn("Unsupported provider", {
+        emailAccountId,
+        provider: emailAccount.account?.provider,
+      });
+      return NextResponse.json({ success: false }, { status: 400 });
+    }
apps/web/utils/outlook/label.ts (2)

338-360: Fallback path: compute destinationFolderId before moving messages

destinationFolderId is scoped to the primary try and isn’t available here. Compute it again in the fallback path before issuing move requests.

Add this line right after entering the inner try in the fallback path:

const destinationFolderId = await getOrCreateFolderByName(client, folderName);

This ensures both per-message and single-message move calls use a valid folder ID.


328-335: Fallback path: conversationId not selected but used

You call .select("id") and then filter by message.conversationId, which will always be undefined.

Apply this diff to include conversationId:

-        .select("id")
+        .select("id,conversationId")
apps/web/utils/reply-tracker/draft-tracking.ts (2)

98-106: Guard against undefined content before similarity calculation

calculateSimilarity likely expects strings. executedAction.content or sentTextPlain can be undefined/null, leading to NaN or runtime errors. Add null-safe guards or defaults.

-  const similarityScore = calculateSimilarity(
-    executedAction.content,
-    sentTextPlain,
-  );
+  const original = executedAction.content ?? "";
+  const sent = sentTextPlain ?? "";
+  const similarityScore = original && sent ? calculateSimilarity(original, sent) : 0;

41-61: Wrap core flow in try/catch to avoid unhandled errors and log context

getDraft, Prisma calls, and the transaction can throw. Add a try/catch to prevent breaking the caller and to log failures with context (aligns with apps/web error-handling guideline).

 export async function trackSentDraftStatus({
@@
 }) {
-  const { threadId, id: sentMessageId, textPlain: sentTextPlain } = message;
+  const { threadId, id: sentMessageId, textPlain: sentTextPlain } = message;
   const loggerOptions = { threadId, sentMessageId };
-  logger.info(
+  logger.info(
     "Checking if sent message corresponds to an AI draft",
     loggerOptions,
   );
-  if (!sentMessageId) {
+  try {
+    if (!sentMessageId) {
       logger.warn("Sent message missing ID, cannot track draft status", {
         threadId,
       });
       return;
-  }
+    }
@@
-  const executedAction = await prisma.executedAction.findFirst({
+    const executedAction = await prisma.executedAction.findFirst({
@@
-  const draftExists = await getDraft(executedAction.draftId, gmail);
+    const draftExists = await getDraft(executedAction.draftId, gmail);
@@
-  await prisma.$transaction([
+    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(
+    logger.info(
       "Successfully created draft send log and updated action status via transaction",
       loggerOptionsWithAction,
     );
+  } catch (error) {
+    logger.error("trackSentDraftStatus failed", {
+      ...loggerOptions,
+      error: formatError(error),
+    });
+  }

(Consider mirroring the same try/catch structure in trackSentDraftStatusWithProvider.)

Also applies to: 86-94, 108-121

apps/web/utils/gmail/message.ts (1)

213-227: Bug: PUBLIC_DOMAINS contains values with '@', causing non-matches

extractDomainFromEmail returns a domain without '@'. Entries "@me.com" and "@hey.com" will never match, causing domain logic to misclassify.

 const PUBLIC_DOMAINS = new Set([
   "gmail.com",
   "yahoo.com",
   "hotmail.com",
   "outlook.com",
   "aol.com",
   "icloud.com",
-  "@me.com",
+  "me.com",
   "protonmail.com",
   "zoho.com",
   "yandex.com",
   "fastmail.com",
   "gmx.com",
-  "@hey.com",
+  "hey.com",
 ]);

Also applies to: 233-241

apps/web/__tests__/ai/choose-rule/draft-management.test.ts (1)

9-16: Use the shared Prisma mock instead of a custom inline mock

Project convention: vi.mock("@/utils/prisma") and use "@/utils/mocks/prisma" for consistency and maintainability. The custom factory overrides the provided mock.

-vi.mock("@/utils/prisma", () => ({
-  default: {
-    executedAction: {
-      findFirst: vi.fn(),
-      update: vi.fn(),
-    },
-  },
-}));
+vi.mock("@/utils/prisma");
+import prismaMock from "@/utils/__mocks__/prisma";
@@
-  const mockFindFirst = prisma.executedAction.findFirst as Mock;
-  const mockUpdate = prisma.executedAction.update as Mock;
+  const mockFindFirst = prismaMock.executedAction.findFirst as Mock;
+  const mockUpdate = prismaMock.executedAction.update as Mock;

Also applies to: 32-37

apps/web/utils/cold-email/is-cold-email.ts (1)

126-134: Handle provider errors when checking previous communications

provider.hasPreviousCommunicationsWithSenderOrDomain may throw (network/transient). Guard with try/catch to avoid failing the cold-email determination flow and to log appropriately.

-  const hasPreviousEmail =
-    email.date && email.id
-      ? await provider.hasPreviousCommunicationsWithSenderOrDomain({
-          from: email.from,
-          date: email.date,
-          messageId: email.id,
-        })
-      : false;
+  let hasPreviousEmail = false;
+  if (email.date && email.id) {
+    try {
+      hasPreviousEmail =
+        await provider.hasPreviousCommunicationsWithSenderOrDomain({
+          from: email.from,
+          date: email.date,
+          messageId: email.id,
+        });
+    } catch (error) {
+      logger.warn("Provider check failed; proceeding to AI", { error });
+      hasPreviousEmail = false;
+    }
+  }
apps/web/app/api/user/stats/newsletters/route.ts (1)

139-156: Replace Raw Queries with Parameterized Prisma.sql in Newsletter Stats
The endpoint at apps/web/app/api/user/stats/newsletters/route.ts currently builds SQL via string concatenation and calls prisma.$queryRawUnsafe, which is hard to audit and risks injection. Please refactor:

• Lines 139–156 (and similarly 158–175, 177–188, 189–205, 207–213):
– Replace whereConditions: string[] + queryParams with an array of Prisma.Sql fragments (whereSql) and compose them via Prisma.join.
– Swap out prisma.$queryRawUnsafe(query.sql, …params) for prisma.$queryRaw(query) using the Prisma.Sql object you’ve built.
• Ensure getOrderByClause emits only fixed, whitelisted SQL (or refactor it into a Prisma.sql-based switch if it ever becomes dynamic).

Example diff (lines 139–156):

- const whereConditions: string[] = [];
- const queryParams: Array<string | Date> = [];
+ const whereSql: Prisma.Sql[] = [];

- if (options.fromDate) {
-   whereConditions.push(
-     `"date" >= to_timestamp($${queryParams.length + 1}::double precision)`,
-   );
-   queryParams.push((options.fromDate / 1000).toString());
- }
+ if (options.fromDate) {
+   whereSql.push(
+     Prisma.sql`"date" >= to_timestamp(${options.fromDate / 1000}::double precision)`,
+   );
+ }- const results = await prisma.$queryRawUnsafe<NewsletterCountRawResult[]>(
-   query.sql,
-   ...queryParams,
-   ...query.values,
- );
+ const results = await prisma.$queryRaw<NewsletterCountRawResult[]>(query);

Applying this pattern consistently across all newsletter‐stats queries will standardize parameter binding, improve readability, and eliminate injection risks.

apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx (1)

206-215: Remove console.error in client code

Guidelines disallow console usage in app code. You already show a toast; drop the console.error.

-          } catch (error) {
-            console.error(`Failed to process message ${message.id}:`, error);
+          } catch (error) {
             toastError({
               title: "Failed to process email",
               description: `Error processing email from ${message.headers.from}: ${error instanceof Error ? error.message : "Unknown error"}`,
             });
apps/web/utils/email/provider.ts (1)

9-24: Improve error handling and input validation

The function should provide more informative error messages and validate inputs properly.

+const SUPPORTED_PROVIDERS = ["google", "microsoft"] as const;
+type SupportedProvider = typeof SUPPORTED_PROVIDERS[number];
+
 export async function createEmailProvider({
   emailAccountId,
   provider,
 }: {
   emailAccountId: string;
-  provider: string | null;
+  provider: string | null;
 }): Promise<EmailProvider> {
+  if (!emailAccountId?.trim()) {
+    throw new Error("emailAccountId is required and cannot be empty");
+  }
+
   if (provider === "google") {
     const client = await getGmailClientForEmail({ emailAccountId });
     return new GmailProvider(client);
   } else if (provider === "microsoft") {
     const client = await getOutlookClientForEmail({ emailAccountId });
     return new OutlookProvider(client);
   }
-  throw new Error(`Unsupported provider: ${provider}`);
+  throw new Error(
+    `Unsupported provider: ${provider}. Supported providers are: ${SUPPORTED_PROVIDERS.join(", ")}`
+  );
 }
🧹 Nitpick comments (25)
.gitignore (1)

76-78: Pattern ‘logs’ matches at any depth; use ‘/logs/’ if you only want the root folder ignored

Currently, “logs” will ignore any directory/file named logs anywhere in the repo, not just at the top level.

  • If intention is root-only CLI logs: change to “/logs/”.
  • If you want to ignore log files generally, consider adding “.log” (and maybe “.log.*”).
  • If you plan to keep a placeholder committed, add “!logs/.gitkeep”.

Suggested diff:

-# cli logs
-logs
+# cli logs (root only)
+/logs/
+# optionally, also ignore generic log files across the repo
+# *.log
+# *.log.*
+# keep placeholder if needed
+# !logs/.gitkeep
apps/web/utils/llms/model.ts (2)

42-53: Add explicit return type to selectModelByType

LLM-related functions should declare parameter and return types. Add : SelectModel to the function signature.

-function selectModelByType(userAi: UserAIFields, modelType: ModelType) {
+function selectModelByType(
+  userAi: UserAIFields,
+  modelType: ModelType,
+): SelectModel {

367-377: Annotate return type of getProviderApiKey

Explicit return types are required for LLM-related functions.

-function getProviderApiKey(provider: string) {
+function getProviderApiKey(provider: string): string | undefined {
   const providerApiKeys: Record<string, string | undefined> = {
     [Provider.ANTHROPIC]: env.ANTHROPIC_API_KEY,
     [Provider.OPEN_AI]: env.OPENAI_API_KEY,
     [Provider.GOOGLE]: env.GOOGLE_API_KEY,
     [Provider.GROQ]: env.GROQ_API_KEY,
     [Provider.OPENROUTER]: env.OPENROUTER_API_KEY,
   };
 
   return providerApiKeys[provider];
 }
apps/web/app/api/ai/analyze-sender-pattern/route.ts (2)

23-27: Tighten input validation (Zod refine)

Consider validating emailAccountId format (e.g., cuid/uuid) and ensuring extractEmailAddress(from) is non-empty.

-const schema = z.object({
-  emailAccountId: z.string(),
-  from: z.string(),
-});
+const schema = z.object({
+  emailAccountId: z.string().min(1, "emailAccountId required"),
+  from: z.string().min(1, "from required"),
+});
@@
-const from = extractEmailAddress(data.from);
+const from = extractEmailAddress(data.from);
+if (!from) {
+  return NextResponse.json({ error: "Invalid from address" }, { status: 400 });
+}

Also applies to: 37-41


230-252: Optional: parallelize per-thread message fetches

Fetching thread messages sequentially increases latency. For small MAX_RESULTS it’s fine, but you can speed up with Promise.all and short-circuit on conversation detection.

-  for (const thread of threads.threads) {
-    const messages = await getThreadMessages(thread.id, gmail);
+  const all = await Promise.all(
+    threads.threads.map(async (thread) => ({
+      threadId: thread.id,
+      messages: await getThreadMessages(thread.id, gmail),
+    })),
+  );
-    // Check if this is a conversation (multiple senders)
-    const senders = messages.map((msg) =>
+  for (const { threadId, messages } of all) {
+    const senders = messages.map((msg) =>
       extractEmailAddress(msg.headers.from),
     );
@@
-    threadsWithMessages.push({
-      threadId: thread.id,
-      messages,
-    });
+    threadsWithMessages.push({ threadId, messages });
apps/web/__tests__/helpers.ts (1)

4-7: Support nested overrides when building EmailAccountWithAI

Only email is overridable now; allow overriding nested user fields to make tests more flexible.

 export function getEmailAccount(
   overrides: Partial<EmailAccountWithAI> = {},
 ): EmailAccountWithAI {
-  return {
+  const base: EmailAccountWithAI = {
     id: "email-account-id",
     userId: "user1",
-    email: overrides.email || "user@test.com",
+    email: "user@test.com",
     about: null,
     user: {
       aiModel: null,
       aiProvider: null,
       aiApiKey: null,
     },
   };
+  return {
+    ...base,
+    ...overrides,
+    email: overrides.email ?? base.email,
+    user: {
+      ...base.user,
+      ...(overrides.user ?? {}),
+    },
+  };
 }

Also applies to: 10-17

apps/web/utils/outlook/label.ts (1)

301-305: Nit: Include both folderName and destinationFolderId in logs for better observability

Current logs mention folderName; including the resolved destinationFolderId helps diagnose issues when names map to well-known folders or custom-created ones.

Consider adding destinationFolderId to log contexts where available.

Also applies to: 309-313, 379-385, 390-393

apps/web/utils/email/types.ts (3)

137-140: Use T[] consistently instead of Array

The guidelines prefer consistent array typing. This line uses Array<...> whereas most others use T[].

Apply this diff:

-  ): Promise<Array<{ id: string; snippet: string; subject: string }>>;
+  ): Promise<{ id: string; snippet: string; subject: string }[]>;

1-4: Decouple utils types from app-layer validation (imports from app/)

Importing ThreadsQuery from "@/app/api/threads/validation" couples utils to the app layer. It risks circular deps and complicates reuse.

Extract threadsQuery and ThreadsQuery to a shared module under utils (e.g., apps/web/utils/validation/threads.ts). Re-export from app routes as needed so both layers depend on utils, not vice versa. I can draft this refactor if helpful.

Also applies to: 36-165


36-165: Optional: prefer export type over interface for consistency with guidelines

Not urgent, but to align with “Use export type for types,” consider switching interfaces to exported type aliases.

Example:

export type EmailLabel = {
  id: string;
  name: string;
  // ...
};
apps/web/utils/ai/choose-rule/match-rules.test.ts (1)

539-545: Nit: Fix test name typo

Minor spelling fix for readability.

-  it("doens't match wrong static domain", async () => {
+  it("doesn't match wrong static domain", async () => {
apps/web/utils/ai/choose-rule/execute.ts (1)

75-80: Log action execution errors before rethrowing

We flip the executed rule to ERROR and rethrow. Add structured logging for triage.

   } catch (error) {
     await prisma.executedRule.update({
       where: { id: executedRule.id },
       data: { status: ExecutedRuleStatus.ERROR },
     });
-    throw error;
+    logger.error("Action execution failed", {
+      actionId: action.id,
+      actionType: action.type,
+      error,
+    });
+    throw error;
   }
apps/web/utils/ai/choose-rule/draft-management.ts (1)

55-61: Use similarity threshold instead of exact string equality

Exact equality after trimming and simple quote-header stripping is brittle. Using calculateSimilarity with a high threshold (e.g., >= 0.99) reduces false negatives for minor whitespace/punctuation edits and aligns with how other modules assess draft changes.

+import { calculateSimilarity } from "@/utils/similarity-score";
@@
-        if (originalContent === currentReplyContent) {
+        const similarity = calculateSimilarity(
+          originalContent ?? "",
+          currentReplyContent ?? "",
+        );
+        if (similarity >= 0.99) {
           logger.info("Draft content matches, deleting draft.");
@@
-        } else {
-          logger.info("Draft content modified by user, skipping deletion.");
-        }
+        } else {
+          logger.info("Draft content modified by user, skipping deletion.", {
+            similarity,
+          });
+        }

Also applies to: 72-80

apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx (1)

193-196: Refine MOVE_FOLDER summary for consistency and whitespace handling

Minor UX nit: trim folder name and keep display consistent with other actions.

-    case ActionType.MOVE_FOLDER:
-      summaryContent = `Folder: ${action.folderName?.value || "unset"}`;
-      break;
+    case ActionType.MOVE_FOLDER: {
+      const folder = action.folderName?.value?.trim();
+      summaryContent = `Folder: ${folder || "unset"}`;
+      break;
+    }
apps/web/utils/ai/rule/create-prompt-from-rule.ts (1)

95-98: Include folder name in prompt when available

Improves prompt specificity and downstream LLM behavior without changing control flow.

-      case ActionType.MOVE_FOLDER:
-        actions.push("move to folder");
-        break;
+      case ActionType.MOVE_FOLDER:
+        if (action.folderName) {
+          actions.push(`move to folder "${action.folderName}"`);
+        } else {
+          actions.push("move to folder");
+        }
+        break;
apps/web/utils/action-display.ts (1)

26-29: Nit: trim folderName to avoid odd spacing

Minor polish to handle accidental leading/trailing spaces.

-    case ActionType.MOVE_FOLDER:
-      return action.folderName
-        ? `Folder: ${action.folderName}`
-        : "Move to folder";
+    case ActionType.MOVE_FOLDER: {
+      const name = action.folderName?.trim();
+      return name ? `Folder: ${name}` : "Move to folder";
+    }
apps/web/utils/ai/rule/generate-rules-prompt.ts (3)

48-50: System prompt is clear; consider tightening output constraints

You already enforce a schema at generation time; still, consider explicitly stating “Return only JSON matching the provided schema. Do not include prose.” to reduce LLM drift.


129-144: Trim trailing newlines from formatted rules

parseRulesResponse adds a trailing newline to each formatted item. Consider returning items without trailing newlines to simplify downstream rendering.

-    return parsedRules.rules.map(({ rule, snippet }) => {
-      const formattedRule = `* ${rule}\n`;
-      if (snippet) return `${formattedRule}${formatSnippet(snippet)}\n`;
-      return formattedRule;
-    });
+    return parsedRules.rules.map(({ rule, snippet }) => {
+      const formattedRule = `* ${rule}`;
+      if (snippet) return `${formattedRule}\n${formatSnippet(snippet)}`;
+      return formattedRule;
+    });

115-120: Prompt hygiene and logging (optional)

  • Consider trimming and truncating long inputs (about, snippets, emails) before interpolation to keep tokens bounded. You already limit lastSentEmails; you can also trim snippet/email text.
  • createGenerateObject logs inputs/outputs; if needed, add a scoped logger here to log hasSnippets, counts, and lengths rather than content.

Also applies to: 146-151

apps/web/__tests__/ai/reply/reply-context-collector.test.ts (2)

11-13: Use standard AI test timeouts

Guideline suggests 15,000ms for long-running AI tests. If feasible, reduce TEST_TIMEOUT to 15_000; otherwise document why 60_000ms is required.


89-106: Use console.debug for AI test logs

LLM-related tests should prefer console.debug over console.log. Swap these calls accordingly.

-      console.log(
+      console.debug(
         `Basic: LLM issued ${observedQueries.length} search call(s):`,
         observedQueries,
       );
-      console.log("result", result);
+      console.debug("result", result);
@@
-      console.log(
+      console.debug(
         `LLM issued ${observedQueries.length} search call(s):`,
         observedQueries,
       );
-      console.log("result", result);
+      console.debug("result", result);
@@
-      console.log(
+      console.debug(
         `Technical support: LLM issued ${observedQueries.length} search call(s):`,
         observedQueries,
       );
-      console.log("result", result);
+      console.debug("result", result);
@@
-      console.log(
+      console.debug(
         `Escalation: LLM issued ${observedQueries.length} search call(s):`,
         observedQueries,
       );
-      console.log("result", result);
+      console.debug("result", result);
@@
-      console.log(
+      console.debug(
         `Billing: LLM issued ${observedQueries.length} search call(s):`,
         observedQueries,
       );
-      console.log("result", result);
+      console.debug("result", result);
@@
-      console.log(
+      console.debug(
         `Shipping: LLM issued ${observedQueries.length} search call(s):`,
         observedQueries,
       );
-      console.log("result", result);
+      console.debug("result", result);
@@
-      console.log(
+      console.debug(
         `Product inquiry: LLM issued ${observedQueries.length} search call(s):`,
         observedQueries,
       );
-      console.log("result", result);
+      console.debug("result", result);
@@
-      console.log(
+      console.debug(
         `Security: LLM issued ${observedQueries.length} search call(s):`,
         observedQueries,
       );
-      console.log("result", result);
+      console.debug("result", result);
@@
-      console.log(
+      console.debug(
         `No-history: LLM issued ${observedQueries.length} search call(s):`,
         observedQueries,
       );
@@
-      console.log("Subject line search queries:", observedQueries);
+      console.debug("Subject line search queries:", observedQueries);

Also applies to: 150-167, 241-258, 315-332, 389-411, 467-485, 541-558, 615-632, 688-691, 769-769

apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx (1)

104-115: Encode the query using URLSearchParams

Joining IDs with commas risks edge cases and URL length issues. Prefer URLSearchParams with repeated keys.

-const { data: existingRules } = useSWR<BatchExecutedRulesResponse>(
-  messageIdsToFetch.length > 0
-    ? `/api/user/executed-rules/batch?messageIds=${messageIdsToFetch.join(",")}`
-    : null,
-);
+const { data: existingRules } = useSWR<BatchExecutedRulesResponse>(() => {
+  if (messageIdsToFetch.length === 0) return null;
+  const qs = new URLSearchParams();
+  for (const id of messageIdsToFetch) qs.append("messageIds", id);
+  return `/api/user/executed-rules/batch?${qs.toString()}`;
+});
apps/web/utils/actions/rule.validation.ts (1)

73-90: Folder path validation: add simple length bounds

Validation is solid (no empty segments or //). Consider adding length bounds to prevent extreme inputs.

-    value: z
-      .string()
+    value: z
+      .string()
+      .max(256, "Folder path is too long")
       .nullish()
       .refine((val) => {
         if (!val?.trim()) return true;
         // Check for empty folder parts
         if (val.includes("//") || val.split("/").some((part) => !part.trim()))
           return false;

         return true;
       }),
apps/web/utils/ai/rule/create-rule.ts (1)

6-9: Consider adding explicit return type annotation

The function lost its explicit return type annotation. Consider adding it back for better type safety and documentation.

export async function aiCreateRule(
  instructions: string,
  emailAccount: EmailAccountWithAI,
-) {
+): Promise<z.infer<typeof createRuleSchema>> {
apps/web/utils/email/microsoft.ts (1)

689-693: Simplify nextPageToken extraction logic

The nested ternary operator makes the code harder to read. Consider extracting this logic to improve readability.

+    const extractSkipToken = (nextLink: string | undefined): string | undefined => {
+      if (!nextLink) return undefined;
+      try {
+        return new URL(nextLink).searchParams.get("$skiptoken") || undefined;
+      } catch {
+        return undefined;
+      }
+    };
+
     return {
       threads,
-      nextPageToken: response["@odata.nextLink"]
-        ? new URL(response["@odata.nextLink"]).searchParams.get("$skiptoken") ||
-          undefined
-        : undefined,
+      nextPageToken: extractSkipToken(response["@odata.nextLink"]),
     };

import { withEmailAccount } from "@/utils/middleware";
import prisma from "@/utils/prisma";

const batchRequestSchema = z.object({ messageIds: z.array(z.string()) });
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Constrain and sanitize messageIds

Add bounds and sanitization to avoid excessive IN queries and empty IDs.

-const batchRequestSchema = z.object({ messageIds: z.array(z.string()) });
+const batchRequestSchema = z.object({
+  messageIds: z
+    .array(z.string().min(1))
+    .min(1, "Provide at least one messageId")
+    .max(100, "You can query up to 100 IDs at a time")
+    .transform((ids) => Array.from(new Set(ids.filter(Boolean)))),
+});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const batchRequestSchema = z.object({ messageIds: z.array(z.string()) });
-const batchRequestSchema = z.object({ messageIds: z.array(z.string()) });
+const batchRequestSchema = z.object({
+ messageIds: z
+ .array(z.string().min(1))
+ .min(1, "Provide at least one messageId")
+ .max(100, "You can query up to 100 IDs at a time")
+ .transform((ids) => Array.from(new Set(ids.filter(Boolean)))),
+});
🤖 Prompt for AI Agents
In apps/web/app/api/user/executed-rules/batch/route.ts around line 6, the
batchRequestSchema currently allows arbitrary arrays of strings; constrain and
sanitize messageIds by requiring a non-empty array with sensible bounds (e.g.
min 1, max 100), enforce each id is a trimmed, non-empty string with a
reasonable length limit (e.g. max 200), and ensure ids are unique; implement
these rules using zod validators/transforms (trim each string, validate length
and non-empty, then refine the array length and uniqueness) so the server
rejects empty/oversized/duplicate IDs and avoids excessive IN queries.

Comment on lines 116 to 139
<DropdownMenuItem
key={emailAccount.id}
className="gap-2 p-2"
onSelect={() => {
// Force a hard page reload to refresh all data.
// I tried to fix with resetting the SWR cache but it didn't seem to work. This is much more reliable anyway.
window.location.href = getHref(emailAccount.id);
}}
>
<ProfileImage
image={emailAccount.image}
label={emailAccount.name || emailAccount.email}
/>
<div className="flex flex-col">
<span className="truncate font-medium">
{emailAccount.name || emailAccount.email}
</span>
{emailAccount.name && (
<span className="truncate text-xs text-muted-foreground">
{emailAccount.email}
</span>
{emailAccount.name && (
<span className="truncate text-xs text-muted-foreground">
{emailAccount.email}
</span>
)}
</div>
</DropdownMenuItem>
</Link>
)}
</div>
</DropdownMenuItem>
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid window.location; keep native link semantics with full reload via Link + asChild

Using window.location drops a11y (no open-in-new-tab, lost context menu) and bypasses Next’s link semantics. You can still force a hard reload with Link’s reloadDocument while preserving UX/accessibility.

Apply this diff:

-            {emailAccounts.map((emailAccount) => (
-              <DropdownMenuItem
-                key={emailAccount.id}
-                className="gap-2 p-2"
-                onSelect={() => {
-                  // Force a hard page reload to refresh all data.
-                  // I tried to fix with resetting the SWR cache but it didn't seem to work. This is much more reliable anyway.
-                  window.location.href = getHref(emailAccount.id);
-                }}
-              >
-                <ProfileImage
-                  image={emailAccount.image}
-                  label={emailAccount.name || emailAccount.email}
-                />
-                <div className="flex flex-col">
-                  <span className="truncate font-medium">
-                    {emailAccount.name || emailAccount.email}
-                  </span>
-                  {emailAccount.name && (
-                    <span className="truncate text-xs text-muted-foreground">
-                      {emailAccount.email}
-                    </span>
-                  )}
-                </div>
-              </DropdownMenuItem>
-            ))}
+            {emailAccounts.map((emailAccount) => (
+              <DropdownMenuItem key={emailAccount.id} asChild className="p-0">
+                <Link
+                  href={getHref(emailAccount.id)}
+                  prefetch={false}
+                  reloadDocument
+                  className="flex w-full items-center gap-2 p-2"
+                >
+                  <ProfileImage
+                    image={emailAccount.image}
+                    label={emailAccount.name || emailAccount.email}
+                  />
+                  <div className="flex flex-col">
+                    <span className="truncate font-medium">
+                      {emailAccount.name || emailAccount.email}
+                    </span>
+                    {emailAccount.name && (
+                      <span className="truncate text-xs text-muted-foreground">
+                        {emailAccount.email}
+                      </span>
+                    )}
+                  </div>
+                </Link>
+              </DropdownMenuItem>
+            ))}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<DropdownMenuItem
key={emailAccount.id}
className="gap-2 p-2"
onSelect={() => {
// Force a hard page reload to refresh all data.
// I tried to fix with resetting the SWR cache but it didn't seem to work. This is much more reliable anyway.
window.location.href = getHref(emailAccount.id);
}}
>
<ProfileImage
image={emailAccount.image}
label={emailAccount.name || emailAccount.email}
/>
<div className="flex flex-col">
<span className="truncate font-medium">
{emailAccount.name || emailAccount.email}
</span>
{emailAccount.name && (
<span className="truncate text-xs text-muted-foreground">
{emailAccount.email}
</span>
{emailAccount.name && (
<span className="truncate text-xs text-muted-foreground">
{emailAccount.email}
</span>
)}
</div>
</DropdownMenuItem>
</Link>
)}
</div>
</DropdownMenuItem>
{emailAccounts.map((emailAccount) => (
<DropdownMenuItem key={emailAccount.id} asChild className="p-0">
<Link
href={getHref(emailAccount.id)}
prefetch={false}
reloadDocument
className="flex w-full items-center gap-2 p-2"
>
<ProfileImage
image={emailAccount.image}
label={emailAccount.name || emailAccount.email}
/>
<div className="flex flex-col">
<span className="truncate font-medium">
{emailAccount.name || emailAccount.email}
</span>
{emailAccount.name && (
<span className="truncate text-xs text-muted-foreground">
{emailAccount.email}
</span>
)}
</div>
</Link>
</DropdownMenuItem>
))}
🤖 Prompt for AI Agents
In apps/web/components/AccountSwitcher.tsx around lines 116 to 139, replace the
window.location.href hard-reload inside the DropdownMenuItem onSelect with
Next.js Link using asChild and reloadDocument so native link semantics and
accessibility (open-in-new-tab, context menu) are preserved; wrap the
DropdownMenuItem (or make it the child) with Link
href={getHref(emailAccount.id)} reloadDocument and remove the onSelect
window.location call, keeping the ProfileImage and inner markup unchanged.

Comment on lines 119 to 123
onSelect={() => {
// Force a hard page reload to refresh all data.
// I tried to fix with resetting the SWR cache but it didn't seem to work. This is much more reliable anyway.
window.location.href = getHref(emailAccount.id);
}}
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

getHref can mis-replace path segments; make it segment-aware and encode ids

basePath.replace(activeEmailAccountId, emailAccountId) is fragile (e.g., id appears multiple times or not at all). Also consider URL-encoding. Suggest a segment-based approach.

Proposed replacement for getHref (outside this hunk):

const getHref = useCallback(
  (emailAccountId: string) => {
    const tab = searchParams.get("tab") || "";
    const basePath = pathname.split("?")[0] || "/";

    const segments = basePath.split("/").filter(Boolean);
    const nextId = encodeURIComponent(emailAccountId);

    // If we don't have an active id or the first segment isn't the active id,
    // default to the setup route for the target account.
    if (!activeEmailAccountId || segments[0] !== activeEmailAccountId) {
      return `/${nextId}/setup${tab ? `?tab=${encodeURIComponent(tab)}` : ""}`;
    }

    // Replace only the first path segment (account id).
    segments[0] = nextId;
    const newBasePath = `/${segments.join("/")}`;
    return `${newBasePath}${tab ? `?tab=${encodeURIComponent(tab)}` : ""}`;
  },
  [pathname, activeEmailAccountId, searchParams],
);

Please verify behavior when:

  • Current route doesn’t contain the active id (e.g., /accounts).
  • Path contains the id substring in other segments.
  • tab contains characters needing encoding.

🏁 Script executed:

#!/bin/bash
# Search for the getHref definition and any use of basePath.replace in AccountSwitcher.tsx
rg --context=5 "const getHref" -n apps/web/components/AccountSwitcher.tsx
rg --context=5 "basePath.replace" -n apps/web/components/AccountSwitcher.tsx

Length of output: 887


Make getHref segment-aware and URL-encode IDs

The current implementation at apps/web/components/AccountSwitcher.tsx (lines 50–60) uses

const newBasePath = basePath.replace(
  activeEmailAccountId,
  emailAccountId,
);

This approach is fragile because:

  • If the active ID doesn’t appear in the path, replace does nothing (you’ll stay on the wrong route).
  • If the ID appears in multiple segments, all occurrences get replaced.
  • Neither the new account ID nor the tab query value is URL-encoded.

Suggested refactor for getHref:

const getHref = useCallback(
  (emailAccountId: string) => {
    const tab = searchParams.get("tab") || "";
    // Strip off any query string
    const basePath = pathname.split("?")[0] || "/";

    // Split into path segments, dropping empty entries
    const segments = basePath.split("/").filter(Boolean);
    const nextId = encodeURIComponent(emailAccountId);
    const encodedTab = tab ? `?tab=${encodeURIComponent(tab)}` : "";

    // If no active ID in the first segment, go to setup for the target
    if (!activeEmailAccountId || segments[0] !== activeEmailAccountId) {
      return `/${nextId}/setup${encodedTab}`;
    }

    // Replace only the first segment (account ID) and reassemble
    segments[0] = nextId;
    const newBasePath = `/${segments.join("/")}`;
    return `${newBasePath}${encodedTab}`;
  },
  [pathname, activeEmailAccountId, searchParams],
);

• apps/web/components/AccountSwitcher.tsx: adjust lines 50–60
• Ensure encodeURIComponent is used for both emailAccountId and tab
• Handle missing or duplicate IDs by operating on path segments only

🤖 Prompt for AI Agents
In apps/web/components/AccountSwitcher.tsx around lines 119-123, update getHref
to be segment-aware and URL-encode IDs and tab values: strip any query from
pathname, split the path into segments (dropping empty entries), encode the
target emailAccountId and tab with encodeURIComponent, if activeEmailAccountId
is missing or not the first segment return "/{encodedId}/setup{encodedTab}",
otherwise replace only the first segment with the encodedId, reassemble the path
and append the encoded tab query if present; ensure onSelect uses this robust
getHref so IDs aren’t blindly replaced across the whole path and query values
are encoded.

Comment on lines 47 to 55
await createContact(email, name?.split(" ")[0]).catch((error) => {
// ignore if already exists
if (error.message.includes("Email already on list")) {
logger.info("Email already on list", { email });
return;
}

throw error;
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Robustly narrow unknown errors before using error.message.includes

As written, error.message.includes can throw if error is not an Error or message is missing. Guard the shape before calling includes.

-      await createContact(email, name?.split(" ")[0]).catch((error) => {
-        // ignore if already exists
-        if (error.message.includes("Email already on list")) {
-          logger.info("Email already on list", { email });
-          return;
-        }
-
-        throw error;
-      });
+      await createContact(email, name?.split(" ")[0]).catch((err: unknown) => {
+        // ignore if already exists
+        const message =
+          err && typeof err === "object" && "message" in err && typeof (err as any).message === "string"
+            ? (err as any).message
+            : "";
+        if (message.includes("Email already on list")) {
+          logger.info("Email already on list", { email });
+          return;
+        }
+        throw err;
+      });
🤖 Prompt for AI Agents
In apps/web/ee/billing/stripe/loops-events.ts around lines 47 to 55, the catch
block calls error.message.includes which can throw if error is not an Error or
message is missing; update the guard to check the error shape first (e.g., if
(error instanceof Error && typeof error.message === "string" &&
error.message.includes("Email already on list")) { ... }) or coerce safely
(e.g., const msg = String((error && (error as any).message) ?? error); if
(msg.includes(...)) ...) so you only call includes on a guaranteed string, and
retain the existing behavior of logging and rethrowing for other errors.

Comment on lines 95 to 101
}): Promise<any>;
deleteFilter(id: string): Promise<any>;
createAutoArchiveFilter(options: {
from: string;
gmailLabelId?: string;
labelName?: string;
}): Promise<any>;
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid any in public interfaces; return precise types

Using Promise weakens type-safety across the codebase. Define concrete return types for filter operations.

Suggested changes:

  • createFilter: Promise
  • deleteFilter: Promise
  • createAutoArchiveFilter: Promise
-  }): Promise<any>;
+  }): Promise<EmailFilter>;
-  deleteFilter(id: string): Promise<any>;
+  deleteFilter(id: string): Promise<void>;
-  }): Promise<any>;
+  }): Promise<EmailFilter>;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
}): Promise<any>;
deleteFilter(id: string): Promise<any>;
createAutoArchiveFilter(options: {
from: string;
gmailLabelId?: string;
labelName?: string;
}): Promise<any>;
}): Promise<EmailFilter>;
deleteFilter(id: string): Promise<void>;
createAutoArchiveFilter(options: {
from: string;
gmailLabelId?: string;
labelName?: string;
}): Promise<EmailFilter>;
🤖 Prompt for AI Agents
In apps/web/utils/email/types.ts around lines 95 to 101, the interface currently
uses Promise<any> for filter operations which weakens type-safety; change the
method signatures to use concrete types: createFilter(...):
Promise<EmailFilter>, deleteFilter(id: string): Promise<void>,
createAutoArchiveFilter(...): Promise<EmailFilter>; ensure an EmailFilter
type/interface is defined or imported in this file and exported if needed, and
update implementation files and callers to return/resolve the correct
EmailFilter objects (or void for delete) so the type signatures match runtime
behavior.

Comment on lines 43 to 44
if (userAi.aiApiKey) return selectDefaultModel(userAi);

Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Early return now ignores "economy" and "chat" model types when a user API key is present — likely unintended regression

This guard causes selectModelByType to always route to selectDefaultModel for any modelType whenever userAi.aiApiKey is truthy, bypassing selectEconomyModel/selectChatModel. If callers pass modelType = "economy" or "chat", those branches will never execute for users with an API key.

If the intent is to continue honoring modelType even when a user has their own key, remove the guard. If the intent is only to shortcut the "default" path, scope the guard to modelType === "default" and keep routing economy/chat as before.

Option A — revert the guard and add an explicit return type:

-function selectModelByType(userAi: UserAIFields, modelType: ModelType) {
-  if (userAi.aiApiKey) return selectDefaultModel(userAi);
+function selectModelByType(
+  userAi: UserAIFields,
+  modelType: ModelType,
+): SelectModel {
   switch (modelType) {
     case "economy":
       return selectEconomyModel(userAi);
     case "chat":
       return selectChatModel(userAi);
     default:
       return selectDefaultModel(userAi);
   }
 }

Option B — only shortcut the "default" path (still add return type):

-function selectModelByType(userAi: UserAIFields, modelType: ModelType) {
-  if (userAi.aiApiKey) return selectDefaultModel(userAi);
+function selectModelByType(
+  userAi: UserAIFields,
+  modelType: ModelType,
+): SelectModel {
   switch (modelType) {
     case "economy":
       return selectEconomyModel(userAi);
     case "chat":
       return selectChatModel(userAi);
     default:
+      if (userAi.aiApiKey) return selectDefaultModel(userAi);
       return selectDefaultModel(userAi);
   }
 }

To quantify impact of current behavior, run:


🏁 Script executed:

#!/bin/bash
# Find all call sites that request 'economy' or 'chat' models.
rg -n -H -S $'getModel\\([^,]+,\\s*"(economy|chat)"' -A2 -B2

# Also check any direct uses of selectModelByType (if any).
rg -n -H 'selectModelByType\(' -A2 -B2

Length of output: 8542


🏁 Script executed:

#!/bin/bash
# Display the selectModelByType function and its surrounding context
sed -n '1,80p' apps/web/utils/llms/model.ts
echo "----"
sed -n '80,160p' apps/web/utils/llms/model.ts

Length of output: 5213


Adjust early‐return guard in selectModelByType to only shortcut “default”

The unconditional if (userAi.aiApiKey) at lines 43–44 causes every call—regardless of modelType—to fall back to the default model whenever a user API key is set, effectively ignoring "economy" and "chat". Scope this guard to the default branch (or remove it) and add an explicit return type.

• File: apps/web/utils/llms/model.ts
• Location: selectModelByType (lines 42–44)

Suggested change (Option B – only shortcut default, plus return type):

-function selectModelByType(userAi: UserAIFields, modelType: ModelType) {
+function selectModelByType(
+  userAi: UserAIFields,
+  modelType: ModelType,
+): SelectModel {
   switch (modelType) {
     case "economy":
       return selectEconomyModel(userAi);
     case "chat":
       return selectChatModel(userAi);
     default:
-      return selectDefaultModel(userAi);
+      if (userAi.aiApiKey) return selectDefaultModel(userAi);
+      return selectDefaultModel(userAi);
   }
 }

• Add : SelectModel to the selectModelByType signature.
• Verify behavior for users with an API key when requesting "economy"/"chat".

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

🤖 Prompt for AI Agents
In apps/web/utils/llms/model.ts around lines 42 to 44, the early-return guard
"if (userAi.aiApiKey) return selectDefaultModel(userAi);" incorrectly forces the
default model for all modelType values; change the function signature to include
the explicit return type ": SelectModel" and move or restrict the aiApiKey
shortcut so it only triggers when modelType === "default" (or remove it
entirely), ensuring requests for "economy" and "chat" still go through their
normal selection paths and verifying behavior for users with an API key.

try {
const emailAccount = await getEmailAccountWithRules({ emailAccountId });

if (emailAccount?.account?.provider !== "google") {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Analyze sender pattern is not implemented for Outlook

.metadata({ name: "processPreviousSentEmails" })
.action(async ({ ctx: { emailAccountId } }) => {
const emailAccount = await getEmailAccountWithAi({ emailAccountId });
const emailAccount = await prisma.emailAccount.findUnique({
Copy link
Collaborator Author

@edulelis edulelis Aug 11, 2025

Choose a reason for hiding this comment

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

Reply tracking is only implemented for Gmail, so I changed the query here to also fetch the provider and skip it if is not Gmail.

ALTER TABLE "ScheduledAction" ADD COLUMN "folderName" TEXT;

-- AlterTable
ALTER TABLE "VerificationToken" ALTER COLUMN "id" DROP DEFAULT;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixing the default generated from the better-auth migration

}

// Used by Outlook and Gmail providers
export async function saveTokens({
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Bringing back the original saveTokens implementation from main.

async archiveThreadWithLabel(
threadId: string,
ownerEmail: string,
labelId?: string,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This param is never used in outlook.

ownerEmail,
actionSource,
labelId,
folderName = "archive",
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Modified the Outlook that archives to support other folder names (custom and well-known values).
If the folder is well-known, the id is the same as the folder name, e.g., the "archive" folder id is "archive", the inbox folder is "inbox", and so forth. But custom folders have to be referenced by id.

return folderIdCache;
}

export async function getOrCreateFolderByName(
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Create folders by name, or subfolders within folders.
Inbox/Newsletter
Inbox = well-known, so no creation
Newsletter = new or existing folder within the parent inbox

Custom/Some name
Custom = Custom folder (created or retrieved)
Some name = Custom sub-folder created or retrieved within the parent Custom

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
apps/web/utils/actions/reply-tracking.ts (4)

45-47: Confirm desired UX for non-Google accounts (silent no-op vs surfaced “unsupported”)

Other routes in this PR surface provider gating errors; here we return success and no-op for non-Google. If consistency is desired, consider throwing a SafeError to inform the caller; otherwise, document the intentional no-op.


54-55: Revalidate UI after processing to reflect new trackers

This action mutates data shown in Reply Zero; revalidate cache like in enableReplyTrackerAction.

-    return { success: true };
+    revalidatePath(prefixPath(emailAccountId, "/reply-zero"));
+    return { success: true };

33-43: Minimize selected fields to reduce PII exposure (aiApiKey etc.)

If processPreviousSentEmails doesn’t use user.aiProvider/aiModel/aiApiKey here, drop them from the select to avoid handling sensitive data unnecessarily.


18-18: Nit: rename logger scope to match file domain

The scope “enableReplyTracker” is misleading in this module. Consider “reply-tracking”.

-const logger = createScopedLogger("enableReplyTracker");
+const logger = createScopedLogger("reply-tracking");
📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bfe6890 and 8e1aa36.

📒 Files selected for processing (5)
  • apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx (4 hunks)
  • apps/web/prisma/migrations/20250811130806_add_move_folder_action/migration.sql (1 hunks)
  • apps/web/prisma/schema.prisma (4 hunks)
  • apps/web/utils/actions/reply-tracking.ts (1 hunks)
  • apps/web/utils/outlook/label.ts (7 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/prisma/schema.prisma
  • apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx
🧰 Additional context used
📓 Path-based instructions (10)
!{.cursor/rules/*.mdc}

📄 CodeRabbit Inference Engine (.cursor/rules/cursor-rules.mdc)

Never place rule files in the project root, in subdirectories outside .cursor/rules, or in any other location

Files:

  • apps/web/prisma/migrations/20250811130806_add_move_folder_action/migration.sql
  • apps/web/utils/actions/reply-tracking.ts
  • apps/web/utils/outlook/label.ts
!pages/_document.{js,jsx,ts,tsx}

📄 CodeRabbit Inference Engine (.cursor/rules/ultracite.mdc)

!pages/_document.{js,jsx,ts,tsx}: Don't import next/document outside of pages/_document.jsx in Next.js projects.
Don't import next/document outside of pages/_document.jsx in Next.js projects.

Files:

  • apps/web/prisma/migrations/20250811130806_add_move_folder_action/migration.sql
  • apps/web/utils/actions/reply-tracking.ts
  • apps/web/utils/outlook/label.ts
apps/web/**/*.{ts,tsx}

📄 CodeRabbit Inference Engine (apps/web/CLAUDE.md)

apps/web/**/*.{ts,tsx}: Use TypeScript with strict null checks
Path aliases: Use @/ for imports from project root
Use proper error handling with try/catch blocks
Format code with Prettier
Leverage TypeScript inference for better DX

Files:

  • apps/web/utils/actions/reply-tracking.ts
  • apps/web/utils/outlook/label.ts
apps/web/utils/actions/**/*.ts

📄 CodeRabbit Inference Engine (apps/web/CLAUDE.md)

apps/web/utils/actions/**/*.ts: Use server actions for all mutations (create/update/delete operations)
next-safe-action provides centralized error handling
Use Zod schemas for validation on both client and server
Use revalidatePath in server actions for cache invalidation

apps/web/utils/actions/**/*.ts: Use server actions (with next-safe-action) for all mutations (create/update/delete operations); do NOT use POST API routes for mutations.
Use revalidatePath in server actions to invalidate cache after mutations.

Files:

  • apps/web/utils/actions/reply-tracking.ts
**/*.ts

📄 CodeRabbit Inference Engine (.cursor/rules/form-handling.mdc)

**/*.ts: The same validation should be done in the server action too
Define validation schemas using Zod

Files:

  • apps/web/utils/actions/reply-tracking.ts
  • apps/web/utils/outlook/label.ts
**/*.{ts,tsx}

📄 CodeRabbit Inference Engine (.cursor/rules/logging.mdc)

**/*.{ts,tsx}: Use createScopedLogger for logging in backend TypeScript files
Typically add the logger initialization at the top of the file when using createScopedLogger
Only use .with() on a logger instance within a specific function, not for a global logger

Import Prisma in the project using import prisma from "@/utils/prisma";

**/*.{ts,tsx}: Don't use TypeScript enums.
Don't use TypeScript const enum.
Don't use the TypeScript directive @ts-ignore.
Don't use primitive type aliases or misleading types.
Don't use empty type parameters in type aliases and interfaces.
Don't use any or unknown as type constraints.
Don't use implicit any type on variable declarations.
Don't let variables evolve into any type through reassignments.
Don't use non-null assertions with the ! postfix operator.
Don't misuse the non-null assertion operator (!) in TypeScript files.
Don't use user-defined types.
Use as const instead of literal types and type annotations.
Use export type for types.
Use import type for types.
Don't declare empty interfaces.
Don't merge interfaces and classes unsafely.
Don't use overload signatures that aren't next to each other.
Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
Don't use TypeScript namespaces.
Don't export imported variables.
Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
Don't use parameter properties in class constructors.
Use either T[] or Array consistently.
Initialize each enum member value explicitly.
Make sure all enum members are literal values.

Files:

  • apps/web/utils/actions/reply-tracking.ts
  • apps/web/utils/outlook/label.ts
apps/web/utils/actions/*.ts

📄 CodeRabbit Inference Engine (.cursor/rules/server-actions.mdc)

apps/web/utils/actions/*.ts: Implement all server actions using the next-safe-action library for type safety, input validation, context management, and error handling. Refer to apps/web/utils/actions/safe-action.ts for client definitions (actionClient, actionClientUser, adminActionClient).
Use actionClientUser when only authenticated user context (userId) is needed.
Use actionClient when both authenticated user context and a specific emailAccountId are needed. The emailAccountId must be bound when calling the action from the client.
Use adminActionClient for actions restricted to admin users.
Access necessary context (like userId, emailAccountId, etc.) provided by the safe action client via the ctx object in the .action() handler.
Server Actions are strictly for mutations (operations that change data, e.g., creating, updating, deleting). Do NOT use Server Actions for data fetching (GET operations). For data fetching, use dedicated GET API Routes combined with SWR Hooks.
Use SafeError for expected/handled errors within actions if needed. next-safe-action provides centralized error handling.
Use the .metadata({ name: "actionName" }) method to provide a meaningful name for monitoring. Sentry instrumentation is automatically applied via withServerActionInstrumentation within the safe action clients.
If an action modifies data displayed elsewhere, use revalidatePath or revalidateTag from next/cache within the action handler as needed.

Server action files must start with use server

Files:

  • apps/web/utils/actions/reply-tracking.ts
apps/web/utils/**

📄 CodeRabbit Inference Engine (.cursor/rules/project-structure.mdc)

Create utility functions in utils/ folder for reusable logic

Files:

  • apps/web/utils/actions/reply-tracking.ts
  • apps/web/utils/outlook/label.ts
apps/web/utils/**/*.ts

📄 CodeRabbit Inference Engine (.cursor/rules/project-structure.mdc)

apps/web/utils/**/*.ts: Use lodash utilities for common operations (arrays, objects, strings)
Import specific lodash functions to minimize bundle size

Files:

  • apps/web/utils/actions/reply-tracking.ts
  • apps/web/utils/outlook/label.ts
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit Inference Engine (.cursor/rules/ultracite.mdc)

**/*.{js,jsx,ts,tsx}: Don't use elements in Next.js projects.
Don't use elements in Next.js projects.
Don't use namespace imports.
Don't access namespace imports dynamically.
Don't use global eval().
Don't use console.
Don't use debugger.
Don't use var.
Don't use with statements in non-strict contexts.
Don't use the arguments object.
Don't use consecutive spaces in regular expression literals.
Don't use the comma operator.
Don't use unnecessary boolean casts.
Don't use unnecessary callbacks with flatMap.
Use for...of statements instead of Array.forEach.
Don't create classes that only have static members (like a static namespace).
Don't use this and super in static contexts.
Don't use unnecessary catch clauses.
Don't use unnecessary constructors.
Don't use unnecessary continue statements.
Don't export empty modules that don't change anything.
Don't use unnecessary escape sequences in regular expression literals.
Don't use unnecessary labels.
Don't use unnecessary nested block statements.
Don't rename imports, exports, and destructured assignments to the same name.
Don't use unnecessary string or template literal concatenation.
Don't use String.raw in template literals when there are no escape sequences.
Don't use useless case statements in switch statements.
Don't use ternary operators when simpler alternatives exist.
Don't use useless this aliasing.
Don't initialize variables to undefined.
Don't use the void operators (they're not familiar).
Use arrow functions instead of function expressions.
Use Date.now() to get milliseconds since the Unix Epoch.
Use .flatMap() instead of map().flat() when possible.
Use literal property access instead of computed property access.
Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
Use concise optional chaining instead of chained logical expressions.
Use regular expression literals instead of the RegExp constructor when possible.
Don't use number literal object member names th...

Files:

  • apps/web/utils/actions/reply-tracking.ts
  • apps/web/utils/outlook/label.ts
🧠 Learnings (4)
📚 Learning: 2025-07-18T15:04:30.467Z
Learnt from: CR
PR: elie222/inbox-zero#0
File: apps/web/CLAUDE.md:0-0
Timestamp: 2025-07-18T15:04:30.467Z
Learning: Applies to apps/web/app/api/**/route.ts : Use `withEmailAccount` for email-account-level operations

Applied to files:

  • apps/web/utils/actions/reply-tracking.ts
📚 Learning: 2025-07-08T13:14:07.449Z
Learnt from: elie222
PR: elie222/inbox-zero#537
File: apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx:30-34
Timestamp: 2025-07-08T13:14:07.449Z
Learning: The clean onboarding page in apps/web/app/(app)/[emailAccountId]/clean/onboarding/page.tsx is intentionally Gmail-specific and should show an error for non-Google email accounts rather than attempting to support multiple providers.

Applied to files:

  • apps/web/utils/actions/reply-tracking.ts
📚 Learning: 2025-07-18T17:27:58.249Z
Learnt from: CR
PR: elie222/inbox-zero#0
File: .cursor/rules/server-actions.mdc:0-0
Timestamp: 2025-07-18T17:27:58.249Z
Learning: Applies to apps/web/utils/actions/*.ts : Use `actionClient` when both authenticated user context and a specific `emailAccountId` are needed. The `emailAccountId` must be bound when calling the action from the client.

Applied to files:

  • apps/web/utils/actions/reply-tracking.ts
📚 Learning: 2025-07-18T17:27:58.249Z
Learnt from: CR
PR: elie222/inbox-zero#0
File: .cursor/rules/server-actions.mdc:0-0
Timestamp: 2025-07-18T17:27:58.249Z
Learning: Applies to apps/web/utils/actions/*.ts : Access necessary context (like `userId`, `emailAccountId`, etc.) provided by the safe action client via the `ctx` object in the `.action()` handler.

Applied to files:

  • apps/web/utils/actions/reply-tracking.ts
🧬 Code Graph Analysis (1)
apps/web/utils/outlook/label.ts (3)
apps/web/utils/outlook/client.ts (1)
  • OutlookClient (19-63)
packages/tinybird/src/publish.ts (1)
  • TinybirdEmailAction (12-12)
apps/web/utils/outlook/message.ts (1)
  • getOrCreateFolderByName (56-136)
🔇 Additional comments (14)
apps/web/prisma/migrations/20250811130806_add_move_folder_action/migration.sql (2)

2-2: Enum change is irreversible; confirm migration execution strategy

ALTER TYPE ... ADD VALUE is a one-way change. Ensure this is intentional and that your environments won’t need a rollback. Also, on some Postgres versions this cannot run inside a transaction—confirm your migration runner executes this safely (Prisma typically handles this, but double-check your infra/version).

Would you like me to provide a reversible pattern (recreate type + cast) if rollback support is required?


14-14: Dropping VerificationToken.id default is safe given Prisma’s @default(cuid())
The Prisma schema already defines

model VerificationToken {
  id String @id @default(cuid())
  // …
}

so every insert from the application supplies its own id. We didn’t find any raw‐SQL inserts or sequence usages on this table, and there is no orphaned sequence to clean up.

apps/web/utils/outlook/label.ts (12)

4-4: LGTM: Clean import addition for folder resolution.

The import of getOrCreateFolderByName is correctly added to support the dynamic folder destination feature.


237-237: LGTM: Function signature properly updated for dynamic folder support.

The addition of the folderName parameter with a sensible default of "archive" maintains backward compatibility while enabling the new folder-based archiving functionality.

Also applies to: 243-243


245-246: LGTM: Proper folder resolution implementation.

The use of getOrCreateFolderByName correctly resolves the folder name to a folder ID that can be used with the Graph API move operations. This addresses the core requirement for dynamic folder destinations.


265-265: LGTM: Correctly using resolved folder ID for move operation.

The move operation now properly uses destinationFolderId instead of a hardcoded value or the folder name directly. This resolves the bug mentioned in the past review comments.


268-268: LGTM: Consistent logging with resolved folder ID.

The log message correctly references destinationFolderId which provides more precise debugging information than the folder name.


296-299: LGTM: Improved error logging with dynamic folder reference.

The error logging now properly uses the folderName parameter to provide contextual information about which folder the operation was attempting to move to.


304-304: LGTM: Consistent publish error logging.

The publish error message correctly references the dynamic folderName for better debugging context.


341-341: LGTM: Fallback path also uses resolved folder ID.

The fallback move operation correctly uses destinationFolderId, ensuring consistency between the primary and fallback code paths. This addresses the bug mentioned in the past review comments.


345-345: LGTM: Consistent error logging in fallback path.

The fallback error logging properly uses folderName for contextual information, maintaining consistency with the primary code path.


360-360: LGTM: Final fallback also uses resolved folder ID.

Even the last-resort fallback (treating threadId as messageId) correctly uses destinationFolderId, ensuring complete consistency across all code paths.


373-380: LGTM: Enhanced publish error logging.

The publish error logging in the fallback path is comprehensive and uses the dynamic folderName for better debugging context.


385-385: LGTM: Final error logging uses dynamic folder reference.

The final error logging correctly uses folderName to provide contextual information about the failed operation.

Comment on lines +5 to +11
ALTER TABLE "Action" ADD COLUMN "folderName" TEXT;

-- AlterTable
ALTER TABLE "ExecutedAction" ADD COLUMN "folderName" TEXT;

-- AlterTable
ALTER TABLE "ScheduledAction" ADD COLUMN "folderName" TEXT;
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Enforce data integrity for MOVE_FOLDER actions

folderName is nullable, which is correct for non-MOVE_FOLDER actions. To prevent invalid rows, add CHECK constraints requiring folderName when type='MOVE_FOLDER' (and optionally disallowing it otherwise). This catches issues at the DB boundary and simplifies downstream assumptions.

Proposed follow-up migration (apply at the DB level):

ALTER TABLE "Action"
  ADD CONSTRAINT action_move_folder_requires_name
  CHECK (("type" <> 'MOVE_FOLDER') OR ("folderName" IS NOT NULL AND length(trim("folderName")) > 0));

ALTER TABLE "ExecutedAction"
  ADD CONSTRAINT executed_action_move_folder_requires_name
  CHECK (("type" <> 'MOVE_FOLDER') OR ("folderName" IS NOT NULL AND length(trim("folderName")) > 0));

ALTER TABLE "ScheduledAction"
  ADD CONSTRAINT scheduled_action_move_folder_requires_name
  CHECK (("type" <> 'MOVE_FOLDER') OR ("folderName" IS NOT NULL AND length(trim("folderName")) > 0));

Optional: If Outlook mapping relies on stable IDs, consider storing provider folder IDs instead of names (or alongside names) to survive renames and localization.

🤖 Prompt for AI Agents
In
apps/web/prisma/migrations/20250811130806_add_move_folder_action/migration.sql
around lines 5 to 11, the new folderName columns are nullable which allows
invalid rows for MOVE_FOLDER actions; add DB CHECK constraints on Action,
ExecutedAction, and ScheduledAction that require folderName to be non-null and
non-empty (e.g., trimmed length > 0) when type = 'MOVE_FOLDER' (and optionally
enforce folderName IS NULL when type <> 'MOVE_FOLDER' if desired), and implement
these constraints as a follow-up migration so the DB enforces the rule;
optionally consider storing provider folder IDs (or both ID+name) instead of
just names for stability across renames/localization.

Comment on lines +45 to +47
if (emailAccount?.account?.provider !== "google") {
return { success: true };
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Provider gate swallows “account not found” — reorder checks

As written, a missing account (emailAccount === null) will hit the early “not google” return and silently succeed, bypassing the intended SafeError. Reorder the null check before the provider gate.

Apply:

-    if (emailAccount?.account?.provider !== "google") {
-      return { success: true };
-    }
-
-    if (!emailAccount) throw new SafeError("Email account not found");
+    if (!emailAccount) {
+      throw new SafeError("Email account not found");
+    }
+    if (emailAccount.account?.provider !== "google") {
+      return { success: true };
+    }

Also applies to: 49-49

🤖 Prompt for AI Agents
In apps/web/utils/actions/reply-tracking.ts around lines 45-49, the current
provider check runs before verifying emailAccount exists, causing a missing
account (null) to hit the "not google" early return and silently succeed; move
the null/undefined check for emailAccount (and/or emailAccount.account) before
any provider gating and ensure you throw or return the intended SafeError when
the account is missing instead of returning success, then retain the provider
check afterward so non-Google providers still return { success: true }.

@edulelis edulelis changed the title WIP: Move to folder action Move to folder action Aug 11, 2025
@edulelis edulelis merged commit e98db3e into elie222:staging Aug 11, 2025
22 of 23 checks passed
@edulelis edulelis deleted the move-to-folder-action branch August 27, 2025 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments