diff --git a/apps/web/__tests__/determine-thread-status.test.ts b/apps/web/__tests__/determine-thread-status.test.ts index 8153bb6c50..b203fb7e15 100644 --- a/apps/web/__tests__/determine-thread-status.test.ts +++ b/apps/web/__tests__/determine-thread-status.test.ts @@ -548,4 +548,66 @@ Platform Support`, }, TIMEOUT, ); + + test( + "identifies ACTIONED when user sends informational email (not FYI)", + async () => { + const emailAccount = getEmailAccount(); + const latestMessage = getEmail({ + from: emailAccount.email, + to: "recipient@example.com", + subject: "Great speaking", + content: `Hey, + +Great speaking. To sign up: https://getinboxzero.com + +In your specific case I'd recommend adding custom rules to get the most out of it.`, + }); + + const result = await aiDetermineThreadStatus({ + emailAccount, + threadMessages: [latestMessage], + }); + + console.debug("Result:", result); + // User sent an informational email - should be ACTIONED, not FYI + // FYI is only for emails the user RECEIVES + expect(result.status).toBe(SystemType.ACTIONED); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); + + test( + "auto-converts FYI to ACTIONED when user sends the last email", + async () => { + const emailAccount = getEmailAccount(); + const messages = [ + getEmail({ + from: "recipient@example.com", + to: emailAccount.email, + subject: "Question", + content: "What's your email?", + }), + getEmail({ + from: emailAccount.email, + to: "recipient@example.com", + subject: "Re: Question", + content: "FYI, my email is test@example.com", + }), + ]; + + const result = await aiDetermineThreadStatus({ + emailAccount, + threadMessages: messages, + }); + + console.debug("Result:", result); + // Even if AI determines FYI, it should auto-convert to ACTIONED + // because user sent the last email + expect(result.status).toBe(SystemType.ACTIONED); + expect(result.rationale).toBeDefined(); + }, + TIMEOUT, + ); }); diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding/config.ts b/apps/web/app/(app)/[emailAccountId]/onboarding/config.ts index 3141a7eeb4..2609ff88b1 100644 --- a/apps/web/app/(app)/[emailAccountId]/onboarding/config.ts +++ b/apps/web/app/(app)/[emailAccountId]/onboarding/config.ts @@ -36,10 +36,6 @@ export const usersRolesInfo: Record< label: "Investor", description: "Communications from investors and VCs", }, - { - label: "Team", - description: "Internal team communications", - }, { label: "Urgent", description: "Time-sensitive emails requiring immediate attention", @@ -53,14 +49,6 @@ export const usersRolesInfo: Record< label: "Board", description: "Board meetings, materials, and director communications", }, - { - label: "Strategic Initiative", - description: "High-priority strategic projects and planning", - }, - { - label: "Direct Reports", - description: "Communications from team leaders and direct reports", - }, { label: "Key Stakeholder", description: @@ -75,10 +63,6 @@ export const usersRolesInfo: Record< label: "Customer Feedback", description: "Feedback and suggestions from customers", }, - { - label: "Team", - description: "Internal team communications", - }, { label: "Urgent", description: "Time-sensitive emails requiring immediate attention", @@ -113,18 +97,10 @@ export const usersRolesInfo: Record< label: "Schedule Meeting", description: "Emails that need a meeting to be scheduled", }, - { - label: "To Do", - description: "Tasks and action items to complete", - }, { label: "Travel", description: "Travel arrangements and itineraries", }, - { - label: "Expense", - description: "Receipts and expense reports to process", - }, ], }, Investor: { diff --git a/apps/web/app/api/google/webhook/process-label-removed-event.test.ts b/apps/web/app/api/google/webhook/process-label-removed-event.test.ts index 166b968d4f..0378f4499e 100644 --- a/apps/web/app/api/google/webhook/process-label-removed-event.test.ts +++ b/apps/web/app/api/google/webhook/process-label-removed-event.test.ts @@ -173,6 +173,32 @@ describe("process-label-removed-event", () => { expect(saveLearnedPatterns).not.toHaveBeenCalled(); }); + it("should skip processing when only system labels are removed", async () => { + const historyItem = { + message: { id: "msg-123", threadId: "thread-123" }, + labelIds: ["INBOX", "UNREAD"], // Only system labels + } as gmail_v1.Schema$HistoryLabelRemoved; + + await handleLabelRemovedEvent(historyItem, defaultOptions, logger); + + // Should not try to fetch the message when only system labels removed + expect(mockProvider.getMessage).not.toHaveBeenCalled(); + expect(prisma.coldEmail.upsert).not.toHaveBeenCalled(); + }); + + it("should skip processing when DRAFT label is removed (prevents 404 errors)", async () => { + const historyItem = { + message: { id: "draft-123", threadId: "thread-123" }, + labelIds: ["DRAFT"], // Draft was sent - message no longer exists + } as gmail_v1.Schema$HistoryLabelRemoved; + + await handleLabelRemovedEvent(historyItem, defaultOptions, logger); + + // Should not try to fetch the message (which would fail with 404) + expect(mockProvider.getMessage).not.toHaveBeenCalled(); + expect(prisma.coldEmail.upsert).not.toHaveBeenCalled(); + }); + it("should skip processing when messageId is missing", async () => { const historyItem = { message: { threadId: "thread-123" }, // Missing messageId diff --git a/apps/web/app/api/google/webhook/process-label-removed-event.ts b/apps/web/app/api/google/webhook/process-label-removed-event.ts index f8c4d7efe6..ee6c4afe1c 100644 --- a/apps/web/app/api/google/webhook/process-label-removed-event.ts +++ b/apps/web/app/api/google/webhook/process-label-removed-event.ts @@ -49,8 +49,24 @@ export async function handleLabelRemovedEvent( return; } + // Filter out system labels early - we don't learn from system label removals + // (e.g., archiving removes INBOX, starring adds/removes STARRED, etc.) + const removedLabelIds = allRemovedLabelIds.filter( + (labelId) => !SYSTEM_LABELS.includes(labelId), + ); + + if (removedLabelIds.length === 0) { + logger.trace("No non-system labels removed, skipping", { + messageId, + threadId, + systemLabelsRemoved: allRemovedLabelIds, + }); + return; + } + logger.info("Processing label removal for learning", { - labelCount: allRemovedLabelIds.length, + labelCount: removedLabelIds.length, + removedLabels: removedLabelIds, }); let sender: string | null = null; @@ -67,8 +83,11 @@ export async function handleLabelRemovedEvent( }; const errorMessage = errorObj?.message || errorObj?.error?.message; if (errorMessage === "Requested entity was not found.") { - logger.warn("Message not found", { - removedLabelCount: allRemovedLabelIds.length, + logger.warn("Message not found - may have been deleted or trashed", { + messageId, + threadId, + allRemovedLabels: allRemovedLabelIds, + nonSystemLabels: removedLabelIds, }); return; } @@ -96,16 +115,6 @@ export async function handleLabelRemovedEvent( return; } - // Filter out system labels early as we don't learn from them - const removedLabelIds = (message.labelIds || []).filter( - (labelId) => !SYSTEM_LABELS.includes(labelId), - ); - - if (removedLabelIds.length === 0) { - logger.trace("No non-system labels to process"); - return; - } - const labels = await provider.getLabels(); for (const labelId of removedLabelIds) { diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index 9c8b65d4cd..d52693793f 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -160,7 +160,7 @@ async function findPotentialMatchingRules({ { type: ConditionType.PRESET, systemType: SystemType.CALENDAR }, ], }); - continue; + // Don't continue - let it also be evaluated for AI matching below } // Learned patterns (groups) @@ -387,14 +387,19 @@ async function findMatchingRulesWithReasons( reason: fullResult.reason, }; - // Build combined matches: start with existing static/learned matches, then append AI-selected matches + // Build combined matches: update existing matches with AI reasons if AI also chose them, + // and append new AI-selected matches + const aiRuleIds = new Set(result.rules.map((r) => r.id)); + const combinedMatches = [ - // Map existing matches to the same output shape + // Map existing matches, appending AI match reason if AI also chose this rule ...matches.map((match) => ({ rule: match.rule, - matchReasons: match.matchReasons || [], + matchReasons: aiRuleIds.has(match.rule.id) + ? [...(match.matchReasons || []), { type: ConditionType.AI }] + : match.matchReasons || [], })), - // Append AI-selected matches, deduplicating by rule id + // Append AI-selected matches that weren't already in matches ...result.rules .filter( (aiRule) => diff --git a/apps/web/utils/ai/reply/determine-thread-status.ts b/apps/web/utils/ai/reply/determine-thread-status.ts index 92d25aaedc..4e9bd7e1e9 100644 --- a/apps/web/utils/ai/reply/determine-thread-status.ts +++ b/apps/web/utils/ai/reply/determine-thread-status.ts @@ -33,35 +33,45 @@ DETAILED CRITERIA: - The user needs to provide specific input - Someone follows up on a conversation requiring the user's response - There are ANY unanswered questions/requests in the thread that the user hasn't addressed yet -- The user made a promise to get back to someone and hasn't followed through yet +- The user made a promise/commitment to get back to someone or deliver something and hasn't followed through yet +- IMPORTANT: In multi-person threads, track the USER'S specific commitments even if other people are having separate conversations +- CRITICAL: If the user asked a clarifying question AND got an answer BUT still has a pending commitment/deliverable, it's TO_REPLY (not AWAITING_REPLY) - the answered question was just to help complete the commitment **AWAITING_REPLY**: Waiting for the other person to take action or respond. Use this when: - The user asked a question and is still waiting for an answer - The user requested information/action and is still waiting for it to be delivered -- Someone ELSE promised to do something and hasn't done it yet (e.g., "I'll get back to you tomorrow") +- Someone ELSE promised to do something and hasn't done it yet - The ball is in their court - it's THEIR turn to respond or act - The user is NOT the one who needs to reply next +- CRITICAL: If the user requested something and then received a response fulfilling that request, the user is NO LONGER awaiting a reply - the request was fulfilled -**FYI**: The thread no longer needs any response, but the content in the last messages needs the user's attention. Use this when: -- Important updates, announcements, or information the user should be aware of -- CC'd on important matters for awareness only -- Status updates that are valuable to know but don't need acknowledgment +**FYI**: Information the user RECEIVED that they should be aware of, but doesn't require a response. Use this when: +- Someone sent the user important updates, announcements, or information they should know about +- The user is CC'd on important matters for their awareness only +- Someone sent status updates that are valuable to know but don't need acknowledgment +- Someone provided requested information/instructions and now the ball is in the user's court to optionally act on it - NO questions or requests exist anywhere in the thread +- CRITICAL: FYI is ONLY for emails the user RECEIVED. If the user SENT the last email, it cannot be FYI - from the user's perspective, they already know what they sent. **ACTIONED**: The thread is complete/done. No further action needed from anyone. Use this when: - All questions have been answered - All requests have been fulfilled -- Conversation concluded with "thanks", "got it", "sounds good", etc. +- Conversation concluded naturally with acknowledgment or confirmation - The thread reached a natural conclusion with nothing pending +- The user SENT informational content, recommendations, or helpful resources and isn't waiting for a reply CRITICAL RULES - READ CAREFULLY: 1. **CHECK EVERY MESSAGE**: Don't just look at the latest message. Scan the ENTIRE thread for unanswered questions or pending requests -2. **Unanswered questions persist**: If message #1 asks "Can you send me the report?" and message #2 says "FYI, meeting moved to 3pm", the status is still TO_REPLY because the report request is unanswered +2. **Unanswered questions persist**: If an earlier message contains an unanswered question or request, and a later message contains only informational content, the status is still determined by the unanswered question/request 3. **Promises from different perspectives**: - - If SOMEONE ELSE says "I'll get back to you" → AWAITING_REPLY (waiting for them) - - If YOU said "I'll get back to you" → TO_REPLY (you need to follow through on your promise) -4. **Latest message context matters**: If the latest message is purely FYI but there are unresolved items earlier in the thread, prioritize the unresolved items -5. **FYI is only when nothing is pending**: Use FYI ONLY when there are absolutely no questions, requests, or pending actions in the entire thread + - If SOMEONE ELSE promised to do something → AWAITING_REPLY (waiting for them) + - If YOU promised to do something → TO_REPLY (you need to follow through) +4. **Multi-person threads**: In threads with multiple participants, focus ONLY on what the user (the perspective being analyzed) needs to do. Ignore conversations between other people that don't involve the user's commitments. +5. **Request fulfillment**: If the user asked for something (information, help, etc.) and received it, AND the user has no pending commitments/deliverables, they are no longer awaiting a reply. The status should be FYI (if informational) or ACTIONED (if fully resolved). However, if the user still has a pending commitment, see Rule 6. +6. **Clarifying questions don't cancel commitments**: If the user has a pending commitment/deliverable and asks a clarifying question that gets answered, the status is TO_REPLY (not AWAITING_REPLY). The user needs to complete their original commitment now that they have the clarification. +7. **User sends info/recommendations**: When the user SENDS informational content, advice, or recommendations without asking questions or expecting specific actions, it's ACTIONED (not AWAITING_REPLY). The user completed their action and isn't waiting for anything. +8. **Latest message context matters**: If the latest message is purely informational but there are unresolved items earlier in the thread, prioritize the unresolved items +9. **FYI is only when nothing is pending**: Use FYI ONLY when there are absolutely no questions, requests, or pending actions in the entire thread Respond with a JSON object with: - status: One of TO_REPLY, FYI, AWAITING_REPLY, or ACTIONED diff --git a/version.txt b/version.txt index 439a276e55..a4573d3b69 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.17.13 +v2.17.15