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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions apps/web/__tests__/determine-thread-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
24 changes: 0 additions & 24 deletions apps/web/app/(app)/[emailAccountId]/onboarding/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand All @@ -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",
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 22 additions & 13 deletions apps/web/app/api/google/webhook/process-label-removed-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 10 additions & 5 deletions apps/web/utils/ai/choose-rule/match-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) =>
Expand Down
34 changes: 22 additions & 12 deletions apps/web/utils/ai/reply/determine-thread-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v2.17.13
v2.17.15
Loading