Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
50 changes: 36 additions & 14 deletions .cursor/rules/features/reply-tracker.mdc
Original file line number Diff line number Diff line change
@@ -1,25 +1,47 @@
---
description:
description: Reply tracking system that manages "To Reply" and "Awaiting Reply" labels automatically
globs:
alwaysApply: false
---
# Reply Tracker

Reply Tracker (also known as Reply Zero) lets the user which emails need a reply for them and those they're awaiting a reply on.
It updates the labels for the thread automatically in Gmail.
Reply Tracker (Reply Zero) helps users track which emails need replies and which emails they're awaiting replies on. It automatically manages Gmail/Outlook labels based on email flow.

The database models and fields that are used for this feature:
## Core Components

- ThreadTracker
- User.outboundReplyTracking
- ActionType.TRACK_THREAD
**Database Models:**
- `ThreadTracker` - stores tracking state and timestamps
- `EmailAccount.outboundReplyTracking` - enables outbound reply detection
- `ActionType.TRACK_THREAD` - action that removes "Awaiting Reply" labels

The system uses rules. The AI can choose which rule to use each time an email comes in. Rules are for incoming emails only. Not ongoing.
When enabling the reply tracker, we create a rule for the user that has the following actions associated with it:
- LABEL: "To Reply"
- TRACK_THREAD
- DRAFT_EMAIL (optional)
**Labels:**
- `"To Reply"` - emails that need your response
- `"Awaiting Reply"` - emails you sent that need their response

We'll draft a reply for the user automatically if DRAFT_EMAIL is set. The draft will be generated using the email history for this sender as well as the Knowledge base. See `.cursor/rules/features/knowledge.mdc` for more on the Knowledge Base feature.
## How It Works

Enabling `User.outboundReplyTracking` means that when a user sends an email, we'll run an LLM over the email and check if it needs a reply. If it does we'll mark it as Awaiting Reply.
### Inbound Flow (Receiving Emails)
1. **Adding "To Reply"**: Regular rules with LABEL action add "To Reply" labels
2. **Removing "Awaiting Reply"**: Rules with TRACK_THREAD action remove "Awaiting Reply" labels when replies arrive

### Outbound Flow (Sending Emails)
1. **Removing "To Reply"**: Always removes "To Reply" label when you reply (if `outboundReplyTracking` enabled)
2. **Adding "Awaiting Reply"**: AI decides if your sent email needs a response and adds label accordingly

## Settings

Users control this via one unified setting:
- **"Reply tracking"**: Controls both outbound tracking (`outboundReplyTracking`) and automatically adds TRACK_THREAD actions to "To Reply" rules

## Key Files
- `apps/web/utils/reply-tracker/outbound.ts` - handles sent emails
- `apps/web/utils/reply-tracker/inbound.ts` - handles received emails
- `apps/web/utils/ai/actions.ts` - executes TRACK_THREAD action

## Future Improvements
The current implementation works but has some architectural complexity that could be cleaned up:
- TRACK_THREAD action is hidden from users but managed via settings
- Two separate systems (outbound tracking + TRACK_THREAD actions) that need to stay in sync
- Hard-coded label names ("To Reply", "Awaiting Reply") - users can't customize these
- Could potentially be simplified to a more unified label-based approach in the future
- May want to support todos
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ import {
ACTION_TYPE_ICONS,
} from "@/app/(app)/[emailAccountId]/assistant/constants";
import { TooltipExplanation } from "@/components/TooltipExplanation";
import {
AWAITING_REPLY_LABEL_NAME,
NEEDS_REPLY_LABEL_NAME,
} from "@/utils/reply-tracker/consts";
import { getEmailTerminology } from "@/utils/terminology";

export function ActionSummaryCard({
Expand All @@ -22,6 +18,14 @@ export function ActionSummaryCard({
typeOptions: { label: string; value: ActionType }[];
provider: string;
}) {
// don't display
if (
action.type === ActionType.TRACK_THREAD ||
action.type === ActionType.DIGEST
) {
return null;
}

const terminology = getEmailTerminology(provider);
const actionTypeLabel =
typeOptions.find((opt) => opt.value === action.type)?.label || action.type;
Expand Down Expand Up @@ -177,11 +181,6 @@ export function ActionSummaryCard({
"Sends email details and rule execution data to your webhook endpoint when this rule is triggered.";
break;

case ActionType.TRACK_THREAD:
summaryContent = `Auto-update reply ${terminology.label.singular}`;
tooltipText = `Our AI will automatically update the thread ${terminology.label.singular} to '${NEEDS_REPLY_LABEL_NAME}' or '${AWAITING_REPLY_LABEL_NAME}' based on whether you need to respond or are awaiting a response from the recipient.`;
break;

case ActionType.ARCHIVE:
summaryContent = "Skip Inbox";
break;
Expand All @@ -194,10 +193,6 @@ export function ActionSummaryCard({
summaryContent = "Mark as spam";
break;

case ActionType.DIGEST:
summaryContent = "Add to digest";
break;

case ActionType.MOVE_FOLDER:
summaryContent = `Folder: ${action.folderName?.value || "unset"}`;
break;
Expand Down
12 changes: 6 additions & 6 deletions apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,11 @@ export function RuleForm({
),
actions: [
...rule.actions
.filter((action) => action.type !== ActionType.DIGEST)
.filter(
(action) =>
action.type !== ActionType.DIGEST &&
action.type !== ActionType.TRACK_THREAD,
)
.map((action) => ({
...action,
delayInMinutes: action.delayInMinutes,
Expand Down Expand Up @@ -363,14 +367,10 @@ export function RuleForm({
{ label: "Mark read", value: ActionType.MARK_READ },
{ label: "Mark spam", value: ActionType.MARK_SPAM },
{ label: "Call webhook", value: ActionType.CALL_WEBHOOK },
{
label: `Auto-update reply ${terminology.label.singular}`,
value: ActionType.TRACK_THREAD,
},
];

return options;
}, [provider, terminology.label.action, terminology.label.singular]);
}, [provider, terminology.label.action]);

const [isNameEditMode, setIsNameEditMode] = useState(alwaysEditMode);
const [isConditionsEditMode, setIsConditionsEditMode] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,32 @@

import { useCallback } from "react";
import { Toggle } from "@/components/Toggle";
import { updateAwaitingReplyTrackingAction } from "@/utils/actions/settings";
import { updateReplyTrackingAction } from "@/utils/actions/settings";
import { toastError, toastSuccess } from "@/components/Toast";
import { useEmailAccountFull } from "@/hooks/useEmailAccountFull";
import { useRules } from "@/hooks/useRules";
import { LoadingContent } from "@/components/LoadingContent";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingCard } from "@/components/SettingCard";
import { AWAITING_REPLY_LABEL_NAME } from "@/utils/reply-tracker/consts";
import {
AWAITING_REPLY_LABEL_NAME,
NEEDS_REPLY_LABEL_NAME,
} from "@/utils/reply-tracker/consts";
import { useAccount } from "@/providers/EmailAccountProvider";
import { getEmailTerminology } from "@/utils/terminology";

export function AwaitingReplySetting() {
const { provider, isLoading: accountLoading } = useAccount();
const {
data: emailAccountData,
isLoading,
error,
mutate,
} = useEmailAccountFull();
const { mutate: mutateRules } = useRules();

const enabled = emailAccountData?.outboundReplyTracking ?? false;
const terminology = getEmailTerminology(provider);

const handleToggle = useCallback(
async (enable: boolean) => {
Expand All @@ -31,31 +41,33 @@ export function AwaitingReplySetting() {
false,
);

try {
await updateAwaitingReplyTrackingAction(emailAccountData.id, {
enabled: enable,
});
toastSuccess({
description: `Awaiting reply labels ${enable ? "enabled" : "disabled"}`,
});
mutate();
} catch (error) {
// Revert optimistic update on error
mutate();
toastError({
description: `Failed to update awaiting reply labels: ${
error instanceof Error ? error.message : "Unknown error"
}`,
});
const result = await updateReplyTrackingAction(emailAccountData.id, {
enabled: enable,
});

if (result?.serverError) {
mutate(); // Revert optimistic update
toastError({ description: result.serverError });
return;
}
Comment thread
elie222 marked this conversation as resolved.

toastSuccess({
description: `Reply tracking ${enable ? "enabled" : "disabled"}`,
});

await Promise.allSettled([mutate(), mutateRules()]);
},
[emailAccountData, mutate],
[emailAccountData, mutate, mutateRules],
);

return (
<SettingCard
title="Label awaiting reply"
description={`Our AI detects when your sent emails need a response and labels them '${AWAITING_REPLY_LABEL_NAME}'.`}
title="Reply tracking"
description={
accountLoading
? "Loading..."
: `Adds '${AWAITING_REPLY_LABEL_NAME}' ${terminology.label.singular} to sent emails needing responses. Removes '${NEEDS_REPLY_LABEL_NAME}' when you reply and '${AWAITING_REPLY_LABEL_NAME}' when they reply.`
}
right={
<LoadingContent
loading={isLoading}
Expand Down
30 changes: 13 additions & 17 deletions apps/web/app/api/google/webhook/process-history-item.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import { HistoryEventType } from "./types";
import { ColdEmailSetting } from "@prisma/client";
import type { gmail_v1 } from "@googleapis/gmail";
import { isAssistantEmail } from "@/utils/assistant/is-assistant-email";
import { runColdEmailBlockerWithProvider } from "@/utils/cold-email/is-cold-email";
import { runColdEmailBlocker } from "@/utils/cold-email/is-cold-email";
import { blockUnsubscribedEmails } from "@/app/api/google/webhook/block-unsubscribed-emails";

import { markMessageAsProcessing } from "@/utils/redis/message-processing";
import { GmailLabel } from "@/utils/gmail/label";
import { categorizeSender } from "@/utils/categorize/senders/categorize";
Expand Down Expand Up @@ -53,9 +52,6 @@ vi.mock("@/utils/cold-email/is-cold-email", () => ({
runColdEmailBlocker: vi
.fn()
.mockResolvedValue({ isColdEmail: false, reason: "hasPreviousEmail" }),
runColdEmailBlockerWithProvider: vi
.fn()
.mockResolvedValue({ isColdEmail: false, reason: "hasPreviousEmail" }),
}));
vi.mock("@/app/api/google/webhook/block-unsubscribed-emails", () => ({
blockUnsubscribedEmails: vi.fn().mockResolvedValue(false),
Expand Down Expand Up @@ -193,7 +189,7 @@ describe("processHistoryItem", () => {
await processHistoryItem(createHistoryItem(), options);

expect(blockUnsubscribedEmails).not.toHaveBeenCalled();
expect(runColdEmailBlockerWithProvider).not.toHaveBeenCalled();
expect(runColdEmailBlocker).not.toHaveBeenCalled();
expect(processAssistantEmail).toHaveBeenCalledWith({
message: expect.objectContaining({
headers: expect.objectContaining({
Expand Down Expand Up @@ -235,7 +231,7 @@ describe("processHistoryItem", () => {
await processHistoryItem(createHistoryItem(), options);

expect(blockUnsubscribedEmails).not.toHaveBeenCalled();
expect(runColdEmailBlockerWithProvider).not.toHaveBeenCalled();
expect(runColdEmailBlocker).not.toHaveBeenCalled();
});

it("should skip if email is unsubscribed", async () => {
Expand All @@ -247,7 +243,7 @@ describe("processHistoryItem", () => {
};
await processHistoryItem(createHistoryItem(), options);

expect(runColdEmailBlockerWithProvider).not.toHaveBeenCalled();
expect(runColdEmailBlocker).not.toHaveBeenCalled();
});

it("should run cold email blocker when enabled", async () => {
Expand All @@ -262,7 +258,7 @@ describe("processHistoryItem", () => {

await processHistoryItem(createHistoryItem(), options);

expect(runColdEmailBlockerWithProvider).toHaveBeenCalledWith({
expect(runColdEmailBlocker).toHaveBeenCalledWith({
email: expect.objectContaining({
from: "sender@example.com",
to: "",
Expand All @@ -279,7 +275,7 @@ describe("processHistoryItem", () => {
});

it("should skip further processing if cold email is detected", async () => {
vi.mocked(runColdEmailBlockerWithProvider).mockResolvedValueOnce({
vi.mocked(runColdEmailBlocker).mockResolvedValueOnce({
isColdEmail: true,
reason: "ai",
aiReason: "This appears to be a cold email",
Expand Down Expand Up @@ -307,7 +303,7 @@ describe("processHistoryItem", () => {
});

it("should add cold email to digest when coldEmailDigest is true and cold email is detected", async () => {
vi.mocked(runColdEmailBlockerWithProvider).mockResolvedValueOnce({
vi.mocked(runColdEmailBlocker).mockResolvedValueOnce({
isColdEmail: true,
reason: "ai",
aiReason: "This appears to be a cold email",
Expand All @@ -328,7 +324,7 @@ describe("processHistoryItem", () => {

await processHistoryItem(createHistoryItem(), options);

expect(runColdEmailBlockerWithProvider).toHaveBeenCalledWith({
expect(runColdEmailBlocker).toHaveBeenCalledWith({
email: expect.objectContaining({
from: "sender@example.com",
to: "",
Expand Down Expand Up @@ -377,13 +373,13 @@ describe("processHistoryItem", () => {

await processHistoryItem(createHistoryItem(), options);

expect(runColdEmailBlockerWithProvider).not.toHaveBeenCalled();
expect(runColdEmailBlocker).not.toHaveBeenCalled();
expect(categorizeSender).toHaveBeenCalled();
expect(runRules).toHaveBeenCalled();
});

it("should process normally when cold email is not detected with coldEmailDigest enabled", async () => {
vi.mocked(runColdEmailBlockerWithProvider).mockResolvedValueOnce({
vi.mocked(runColdEmailBlocker).mockResolvedValueOnce({
isColdEmail: false,
reason: "hasPreviousEmail",
});
Expand All @@ -402,14 +398,14 @@ describe("processHistoryItem", () => {

await processHistoryItem(createHistoryItem(), options);

expect(runColdEmailBlockerWithProvider).toHaveBeenCalled();
expect(runColdEmailBlocker).toHaveBeenCalled();
expect(categorizeSender).toHaveBeenCalled();
expect(runRules).toHaveBeenCalled();
});

it("should add second email from known cold emailer to digest when coldEmailDigest is enabled", async () => {
// Mock the response for a known cold emailer (already in database)
vi.mocked(runColdEmailBlockerWithProvider).mockResolvedValueOnce({
vi.mocked(runColdEmailBlocker).mockResolvedValueOnce({
isColdEmail: true,
reason: "ai-already-labeled",
coldEmailId: "existing-cold-email-456", // Existing cold email entry ID
Expand All @@ -429,7 +425,7 @@ describe("processHistoryItem", () => {

await processHistoryItem(createHistoryItem("456", "thread-456"), options);

expect(runColdEmailBlockerWithProvider).toHaveBeenCalledWith({
expect(runColdEmailBlocker).toHaveBeenCalledWith({
email: expect.objectContaining({
from: "sender@example.com",
to: "",
Expand Down
Loading
Loading