Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
58a762e
Follow up reminders
jshwrnr Jan 8, 2026
7a27102
Fix open issues
jshwrnr Jan 8, 2026
446f9f1
Add 30 days as an option for follow-up reminders
jshwrnr Jan 8, 2026
bf7b06f
Rename function
jshwrnr Jan 8, 2026
0f51985
Merge branch 'main' into feat/follow-up-reminders
jshwrnr Jan 8, 2026
00f4306
Make auto generate follow up drafts optional
jshwrnr Jan 8, 2026
4194b84
Fixes with setting up follow up reminders
jshwrnr Jan 8, 2026
5283726
Add step to the write-tests skill for determining the scope
jshwrnr Jan 8, 2026
cd92f88
early access
elie222 Jan 9, 2026
a48e8f4
Merge branch 'main' into feat/follow-up-reminders
elie222 Jan 9, 2026
c2ba759
Merge branch 'main' into feat/follow-up-reminders
elie222 Jan 9, 2026
aa9af0c
simplify code
elie222 Jan 9, 2026
a3518a8
Merge branch 'main' into feat/follow-up-reminders
elie222 Jan 9, 2026
1907cee
Merge branch 'main' into feat/follow-up-reminders
elie222 Jan 9, 2026
e810a7c
Refactor follow-up reminders processing to utilize database labels
elie222 Jan 9, 2026
6264a3c
Merge branch 'main' into feat/follow-up-reminders
elie222 Jan 10, 2026
a574d86
Merge branch 'main' into feat/follow-up-reminders
elie222 Jan 10, 2026
f629e0d
copy
elie222 Jan 10, 2026
dbb3b04
clean up follow up form
elie222 Jan 10, 2026
b1228a4
Merge branch 'main' into feat/follow-up-reminders
elie222 Jan 10, 2026
3821212
use number input for follow up
elie222 Jan 11, 2026
6e394f8
Merge branch 'main' into feat/follow-up-reminders
elie222 Jan 11, 2026
ae7a278
refactor: polish follow-up reminders PR, optimize performance, and cl…
elie222 Jan 11, 2026
ec885c1
refactor: update follow-up reminders settings to use nullable days an…
elie222 Jan 11, 2026
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
8 changes: 8 additions & 0 deletions .claude/commands/write-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ import { getEmail, getEmailAccount, getRule } from "@/__tests__/helpers";

## Workflow

### Step 0: Determine Scope

Auto-detect: staged → branch diff → specified files

```bash
git diff --cached --name-only # or main...HEAD
```

### Step 1: Identify Test Targets

Look for functions with:
Expand Down
18 changes: 14 additions & 4 deletions .cursor/rules/posthog-feature-flags.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ We use PostHog for two main purposes:
All feature flag hooks should be defined in `apps/web/hooks/useFeatureFlags.ts`:

```typescript
// For early access features (boolean flags)
export function useFeatureName() {
return useFeatureFlagEnabled("feature-flag-key");
// For early access features (boolean flags with env override)
export function useFeatureNameEnabled() {
return useFeatureFlagEnabled("feature-flag-key") || env.NEXT_PUBLIC_FEATURE_NAME_ENABLED;
}

// For A/B test variants
Expand All @@ -39,6 +39,11 @@ export function useFeatureVariant() {
}
```

Early access features should support both PostHog flags AND environment variables using an OR (`||`). This allows:
- Production users to opt-in via PostHog Early Access
- Developers to enable features locally via `.env`
- Self-hosted users to enable features without PostHog

### 2. Early Access Features

Early access features are automatically displayed on the Early Access page (`/early-access`) through the `EarlyAccessFeatures` component. No manual configuration needed.
Expand All @@ -47,7 +52,7 @@ Early access features are automatically displayed on the Early Access page (`/ea
```typescript
// In useFeatureFlags.ts
export function useCleanerEnabled() {
return useFeatureFlagEnabled("inbox-cleaner");
return useFeatureFlagEnabled("inbox-cleaner") || env.NEXT_PUBLIC_CLEANER_ENABLED;
}

// Usage in components
Expand All @@ -62,6 +67,11 @@ function MyComponent() {
}
```

When adding a new early access feature:
1. Add the hook with PostHog flag + env override
2. Add the env variable to `apps/web/env.ts` (schema + runtimeEnv)
3. Gate the UI component with the hook

### 3. A/B Test Variants

For A/B tests, define the variant types and provide a default fallback:
Expand Down
27 changes: 6 additions & 21 deletions apps/web/__tests__/e2e/flows/full-reply-cycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import {
} from "./helpers/polling";
import { logStep, clearLogs, setTestStartTime } from "./helpers/logging";
import type { TestAccount } from "./helpers/accounts";
import prisma from "@/utils/prisma";

describe.skipIf(!shouldRunFlowTests())("Full Reply Cycle", () => {
let gmail: TestAccount;
Expand Down Expand Up @@ -157,26 +156,12 @@ describe.skipIf(!shouldRunFlowTests())("Full Reply Cycle", () => {
);

if (labelAction?.labelId) {
// Look up the label name from the database
const label = await prisma.label.findUnique({
where: { id: labelAction.labelId },
select: { name: true },
});

const message = await outlook.emailProvider.getMessage(
outlookMessage.messageId,
);
expect(message.labelIds).toBeDefined();

// Check if the label name is in the message's labels
// Outlook returns label names (categories), not IDs
if (label?.name) {
expect(message.labelIds).toContain(label.name);
logStep("Label verified on message", {
labelName: label.name,
messageLabels: message.labelIds,
});
}
expect(message.labelIds).toContain(labelAction.labelId);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Jan 11, 2026

Choose a reason for hiding this comment

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

P1: Label verification is comparing database ID to label names array. For Outlook, message.labelIds contains category names, not database IDs. The assertion expect(message.labelIds).toContain(labelAction.labelId) will fail because labelAction.labelId is a database ID while message.labelIds contains label names. Need to fetch the label name from the database (as the removed code did) or use a helper that maps IDs to names.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/__tests__/e2e/flows/full-reply-cycle.test.ts, line 163:

<comment>Label verification is comparing database ID to label names array. For Outlook, `message.labelIds` contains category names, not database IDs. The assertion `expect(message.labelIds).toContain(labelAction.labelId)` will fail because `labelAction.labelId` is a database ID while `message.labelIds` contains label names. Need to fetch the label name from the database (as the removed code did) or use a helper that maps IDs to names.</comment>

<file context>
@@ -157,26 +156,12 @@ describe.skipIf(!shouldRunFlowTests())("Full Reply Cycle", () => {
-            messageLabels: message.labelIds,
-          });
-        }
+        expect(message.labelIds).toContain(labelAction.labelId);
+        logStep("Labels on message", { labels: message.labelIds });
       }
</file context>
Fix with Cubic

logStep("Labels on message", { labels: message.labelIds });
}

// ========================================
Expand Down Expand Up @@ -302,10 +287,10 @@ describe.skipIf(!shouldRunFlowTests())("Full Reply Cycle", () => {
body: "This is the reply from Outlook.",
});

// Wait for Gmail to receive - use fullSubject for unique match
// Wait for Gmail to receive
const gmailReply = await waitForMessageInInbox({
provider: gmail.emailProvider,
subjectContains: initialEmail.fullSubject,
subjectContains: "Thread continuity test",
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Jan 11, 2026

Choose a reason for hiding this comment

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

P1: Test may match emails from previous runs instead of current test. Changed from initialEmail.fullSubject (which includes unique run ID and sequence number) to hardcoded "Thread continuity test". This removes the uniqueness guarantee and could cause waitForMessageInInbox to find stale emails from previous test runs, making the test flaky.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/__tests__/e2e/flows/full-reply-cycle.test.ts, line 293:

<comment>Test may match emails from previous runs instead of current test. Changed from `initialEmail.fullSubject` (which includes unique run ID and sequence number) to hardcoded `"Thread continuity test"`. This removes the uniqueness guarantee and could cause `waitForMessageInInbox` to find stale emails from previous test runs, making the test flaky.</comment>

<file context>
@@ -302,10 +287,10 @@ describe.skipIf(!shouldRunFlowTests())("Full Reply Cycle", () => {
       const gmailReply = await waitForMessageInInbox({
         provider: gmail.emailProvider,
-        subjectContains: initialEmail.fullSubject,
+        subjectContains: "Thread continuity test",
         timeout: TIMEOUTS.EMAIL_DELIVERY,
       });
</file context>
Suggested change
subjectContains: "Thread continuity test",
subjectContains: initialEmail.fullSubject,
Fix with Cubic

timeout: TIMEOUTS.EMAIL_DELIVERY,
});
Comment on lines +290 to 295
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent subject matching could cause flaky tests.

The test uses initialEmail.fullSubject at line 273 but switches to the hardcoded base subject "Thread continuity test" here. This inconsistency could lead to matching unintended messages from previous test runs or concurrent tests.

🔧 Suggested fix for consistency
-      // Wait for Gmail to receive
       const gmailReply = await waitForMessageInInbox({
         provider: gmail.emailProvider,
-        subjectContains: "Thread continuity test",
+        subjectContains: initialEmail.fullSubject,
         timeout: TIMEOUTS.EMAIL_DELIVERY,
       });
🤖 Prompt for AI Agents
In @apps/web/__tests__/e2e/flows/full-reply-cycle.test.ts around lines 290 -
295, The test is using a hardcoded subject string in the waitForMessageInInbox
call which is inconsistent with the earlier use of initialEmail.fullSubject and
can cause flaky matches; update the call to waitForMessageInInbox (the
invocation that assigns gmailReply) to use initialEmail.fullSubject (or the same
variable used at line 273) for subjectContains so the test consistently matches
the exact message created by this run.


Expand All @@ -325,10 +310,10 @@ describe.skipIf(!shouldRunFlowTests())("Full Reply Cycle", () => {
body: "This is the second reply from Gmail.",
});

// Wait for Outlook to receive - use fullSubject for unique match
// Wait for Outlook to receive
const outlookMsg2 = await waitForMessageInInbox({
provider: outlook.emailProvider,
subjectContains: initialEmail.fullSubject,
subjectContains: "Thread continuity test",
timeout: TIMEOUTS.EMAIL_DELIVERY,
});
Comment on lines +313 to 318
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Same inconsistency - use fullSubject for reliable matching.

Similar to the issue at lines 290-295, this should use initialEmail.fullSubject instead of the hardcoded base subject to ensure the test matches the correct message reliably.

🔧 Suggested fix for consistency
-      // Wait for Outlook to receive
       const outlookMsg2 = await waitForMessageInInbox({
         provider: outlook.emailProvider,
-        subjectContains: "Thread continuity test",
+        subjectContains: initialEmail.fullSubject,
         timeout: TIMEOUTS.EMAIL_DELIVERY,
       });
🤖 Prompt for AI Agents
In @apps/web/__tests__/e2e/flows/full-reply-cycle.test.ts around lines 313 -
318, The test uses a hardcoded subject string when calling waitForMessageInInbox
for outlookMsg2 which can cause mismatches; update that call to use
initialEmail.fullSubject (the same pattern used earlier) so
waitForMessageInInbox({ provider: outlook.emailProvider, subjectContains:
initialEmail.fullSubject, timeout: TIMEOUTS.EMAIL_DELIVERY }) reliably matches
the intended message.


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
"use client";

import { useState, useCallback } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { SettingCard } from "@/components/SettingCard";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/Input";
import { Toggle } from "@/components/Toggle";
import { Badge } from "@/components/Badge";
import { useEmailAccountFull } from "@/hooks/useEmailAccountFull";
import { useFollowUpRemindersEnabled } from "@/hooks/useFeatureFlags";
import { useAccount } from "@/providers/EmailAccountProvider";
import { useAction } from "next-safe-action/hooks";
import {
toggleFollowUpRemindersAction,
updateFollowUpSettingsAction,
scanFollowUpRemindersAction,
} from "@/utils/actions/follow-up-reminders";
import {
type SaveFollowUpSettingsFormInput,
DEFAULT_FOLLOW_UP_DAYS,
} from "@/utils/actions/follow-up-reminders.validation";
import { toastError, toastSuccess } from "@/components/Toast";
import { getEmailTerminology } from "@/utils/terminology";

export function FollowUpRemindersSetting() {
const isFeatureEnabled = useFollowUpRemindersEnabled();

if (!isFeatureEnabled) return null;

return <FollowUpRemindersSettingContent />;
}

function FollowUpRemindersSettingContent() {
const [open, setOpen] = useState(false);
const { data, mutate } = useEmailAccountFull();

const enabled =
data?.followUpAwaitingReplyDays !== null ||
data?.followUpNeedsReplyDays !== null;

const { execute: executeToggle } = useAction(
toggleFollowUpRemindersAction.bind(null, data?.id ?? ""),
{
onError: (error) => {
mutate();
toastError({
description: error.error?.serverError ?? "Failed to update settings",
});
},
},
);

const handleToggle = useCallback(
(enable: boolean) => {
if (!data) return;

const optimisticData = {
...data,
followUpAwaitingReplyDays: enable ? DEFAULT_FOLLOW_UP_DAYS : null,
followUpNeedsReplyDays: enable ? DEFAULT_FOLLOW_UP_DAYS : null,
};
mutate(optimisticData, false);
executeToggle({ enabled: enable });
},
[data, mutate, executeToggle],
);

return (
<SettingCard
title="Follow-up Reminders"
description="Get reminded when you haven't heard back or haven't replied."
right={
<div className="flex items-center gap-2">
{enabled && (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
Configure
</Button>
</DialogTrigger>
<FollowUpSettingsDialog
emailAccountId={data?.id ?? ""}
followUpAwaitingReplyDays={data?.followUpAwaitingReplyDays}
followUpNeedsReplyDays={data?.followUpNeedsReplyDays}
followUpAutoDraftEnabled={
data?.followUpAutoDraftEnabled ?? true
}
onSuccess={() => {
mutate();
setOpen(false);
}}
/>
</Dialog>
)}
<Toggle
name="follow-up-enabled"
enabled={enabled}
onChange={handleToggle}
disabled={!data}
/>
</div>
}
/>
);
}

function FollowUpSettingsDialog({
emailAccountId,
followUpAwaitingReplyDays,
followUpNeedsReplyDays,
followUpAutoDraftEnabled,
onSuccess,
}: {
emailAccountId: string;
followUpAwaitingReplyDays: number | null | undefined;
followUpNeedsReplyDays: number | null | undefined;
followUpAutoDraftEnabled: boolean;
onSuccess: () => void;
}) {
const { provider } = useAccount();
const terminology = getEmailTerminology(provider);

const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<SaveFollowUpSettingsFormInput>({
defaultValues: {
followUpAwaitingReplyDays: followUpAwaitingReplyDays?.toString() ?? "",
followUpNeedsReplyDays: followUpNeedsReplyDays?.toString() ?? "",
followUpAutoDraftEnabled,
},
});

const autoDraftValue = watch("followUpAutoDraftEnabled");

const { execute, isExecuting } = useAction(
updateFollowUpSettingsAction.bind(null, emailAccountId),
{
onSuccess: () => {
toastSuccess({ description: "Settings saved!" });
onSuccess();
},
onError: (error) => {
toastError({
description: error.error?.serverError ?? "Failed to save settings",
});
},
},
);

const { execute: executeScan, isExecuting: isScanning } = useAction(
scanFollowUpRemindersAction.bind(null, emailAccountId),
{
onSuccess: () => {
toastSuccess({ description: "Scan complete!" });
},
onError: (error) => {
toastError({
description: error.error?.serverError ?? "Failed to scan",
});
},
},
);

const onSubmit = (formData: SaveFollowUpSettingsFormInput) => {
execute({
followUpAwaitingReplyDays: formData.followUpAwaitingReplyDays
? Number(formData.followUpAwaitingReplyDays)
: null,
followUpNeedsReplyDays: formData.followUpNeedsReplyDays
? Number(formData.followUpNeedsReplyDays)
: null,
followUpAutoDraftEnabled: formData.followUpAutoDraftEnabled,
});
};

return (
<DialogContent>
<DialogHeader>
<DialogTitle>Follow-up Reminders</DialogTitle>
<DialogDescription>
Get reminded about conversations that need attention.
<br />
We'll add a <Badge color="blue">Follow-up</Badge>{" "}
{terminology.label.singular} so you can easily find them.
</DialogDescription>
</DialogHeader>

<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
type="number"
name="followUpAwaitingReplyDays"
label="Remind me when they haven't replied after"
explainText="Leave blank to disable"
registerProps={register("followUpAwaitingReplyDays")}
error={errors.followUpAwaitingReplyDays}
min={0.001}
max={90}
step={0.001}
rightText="days"
/>

<Input
type="number"
name="followUpNeedsReplyDays"
label="Remind me when I haven't replied after"
explainText="Leave blank to disable"
registerProps={register("followUpNeedsReplyDays")}
error={errors.followUpNeedsReplyDays}
min={0.001}
max={90}
step={0.001}
rightText="days"
/>
Comment thread
elie222 marked this conversation as resolved.

<div className="flex items-center justify-between">
<div>
<label
htmlFor="followUpAutoDraftEnabled"
className="block text-sm font-medium text-foreground"
>
Auto-generate drafts
</label>
<p className="text-muted-foreground text-sm">
Draft a nudge when you haven't heard back.
</p>
</div>
<Toggle
name="followUpAutoDraftEnabled"
enabled={autoDraftValue}
onChange={(value) => setValue("followUpAutoDraftEnabled", value)}
/>
</div>
Comment on lines +228 to +245
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fix label → control association for “Auto-generate drafts”.

<label htmlFor="followUpAutoDraftEnabled"> doesn’t appear to target an actual input id, so screen readers won’t have a proper association. As written, the Toggle component also doesn’t receive an id to wire up.

Options:

  • Use Toggle’s built-in label / explainText props instead of a standalone <label>, or
  • Extend Toggle to accept an id and pass it to the underlying Switch, then set htmlFor to that id.

(Based on coding guidelines: “Make sure label elements have text content and are associated with an input”.)

🤖 Prompt for AI Agents
In
@apps/web/app/(app)/[emailAccountId]/assistant/settings/FollowUpRemindersSetting.tsx
around lines 216 - 233, The label's htmlFor ("followUpAutoDraftEnabled") isn't
tied to an input; fix by giving the Toggle an id and wiring it through: update
the Toggle component API to accept an id prop (e.g., id: string) and pass that
id to the underlying input/Switch, then set the <label htmlFor> to the same id
("followUpAutoDraftEnabled"); keep the Toggle usage with
enabled={autoDraftValue} and onChange={(value) =>
setValue("followUpAutoDraftEnabled", value)} so the label properly associates
with the control for screen readers.


<div className="flex items-center gap-2">
<Button type="submit" size="sm" loading={isExecuting}>
Save
</Button>
<Button
type="button"
variant="outline"
size="sm"
loading={isScanning}
onClick={() => executeScan({})}
>
Scan now
</Button>
</div>
</form>
</DialogContent>
);
}
Loading
Loading