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
1 change: 1 addition & 0 deletions apps/web/__tests__/ai-choose-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => {
cc: null,
bcc: null,
url: null,
folderName: null,
delayInMinutes: null,
},
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ export function ActionSummaryCard({
summaryContent = "Add to digest";
break;

case ActionType.MOVE_FOLDER:
summaryContent = `Folder: ${action.folderName?.value || "unset"}`;
break;

default:
summaryContent = actionTypeLabel;
}
Expand Down
19 changes: 16 additions & 3 deletions apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export function RuleForm({
isDialog?: boolean;
mutate?: (data?: any, options?: any) => void;
}) {
const { emailAccountId } = useAccount();
const { emailAccountId, provider } = useAccount();

const form = useForm<CreateRuleBody>({
resolver: zodResolver(createRuleBody),
Expand All @@ -140,6 +140,7 @@ export function RuleForm({
...action.content,
setManually: !!action.content?.value,
},
folderName: action.folderName,
})),
],
}
Expand Down Expand Up @@ -319,9 +320,19 @@ export function RuleForm({
const conditionalOperator = watch("conditionalOperator");

const typeOptions = useMemo(() => {
return [
const providerOptions: { label: string; value: ActionType }[] = [];

if (provider === "microsoft") {
providerOptions.push({
label: "Move to folder",
value: ActionType.MOVE_FOLDER,
});
}

const options = [
{ label: "Archive", value: ActionType.ARCHIVE },
{ label: "Label", value: ActionType.LABEL },
...providerOptions,
{ label: "Draft reply", value: ActionType.DRAFT_EMAIL },
{ label: "Reply", value: ActionType.REPLY },
{ label: "Send email", value: ActionType.SEND_EMAIL },
Expand All @@ -332,7 +343,9 @@ export function RuleForm({
{ label: "Call webhook", value: ActionType.CALL_WEBHOOK },
{ label: "Auto-update reply label", value: ActionType.TRACK_THREAD },
];
}, []);

return options;
}, [provider]);

const [isNameEditMode, setIsNameEditMode] = useState(alwaysEditMode);
const [isConditionsEditMode, setIsConditionsEditMode] =
Expand Down
4 changes: 4 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export function Rules({ size = "md" }: { size?: "sm" | "md" }) {
cc: null,
bcc: null,
url: null,
folderName: null,
delayInMinutes: null,
},
showArchiveAction
Expand All @@ -149,6 +150,7 @@ export function Rules({ size = "md" }: { size?: "sm" | "md" }) {
cc: null,
bcc: null,
url: null,
folderName: null,
delayInMinutes: null,
}
: null,
Expand All @@ -166,6 +168,7 @@ export function Rules({ size = "md" }: { size?: "sm" | "md" }) {
cc: null,
bcc: null,
url: null,
folderName: null,
delayInMinutes: null,
}
: null,
Expand Down Expand Up @@ -464,6 +467,7 @@ export function ActionBadges({
id: string;
type: ActionType;
label?: string | null;
folderName?: string | null;
}[];
}) {
return (
Expand Down
7 changes: 7 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/assistant/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
WebhookIcon,
EyeIcon,
FileTextIcon,
FolderInputIcon,
} from "lucide-react";
import { ActionType } from "@prisma/client";

Expand All @@ -25,6 +26,7 @@ const ACTION_TYPE_COLORS = {
[ActionType.CALL_WEBHOOK]: "bg-gray-500",
[ActionType.TRACK_THREAD]: "bg-indigo-500",
[ActionType.DIGEST]: "bg-teal-500",
[ActionType.MOVE_FOLDER]: "bg-emerald-500",
} as const;

export const ACTION_TYPE_TEXT_COLORS = {
Expand All @@ -39,6 +41,7 @@ export const ACTION_TYPE_TEXT_COLORS = {
[ActionType.CALL_WEBHOOK]: "text-gray-500",
[ActionType.TRACK_THREAD]: "text-indigo-500",
[ActionType.DIGEST]: "text-teal-500",
[ActionType.MOVE_FOLDER]: "text-emerald-500",
} as const;

export const ACTION_TYPE_ICONS = {
Expand All @@ -53,6 +56,7 @@ export const ACTION_TYPE_ICONS = {
[ActionType.CALL_WEBHOOK]: WebhookIcon,
[ActionType.TRACK_THREAD]: EyeIcon,
[ActionType.DIGEST]: FileTextIcon,
[ActionType.MOVE_FOLDER]: FolderInputIcon,
} as const;

// Helper function to get action type from string (for RulesPrompt.tsx)
Expand Down Expand Up @@ -83,6 +87,9 @@ export function getActionTypeColor(example: string): string {
if (lowerExample.includes("digest")) {
return ACTION_TYPE_COLORS[ActionType.DIGEST];
}
if (lowerExample.includes("folder")) {
return ACTION_TYPE_COLORS[ActionType.MOVE_FOLDER];
}

// Default fallback
return "bg-gray-500";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function useDraftReplies() {
bcc: null,
url: null,
delayInMinutes: null,
folderName: null,
createdAt: new Date(),
updatedAt: new Date(),
},
Expand Down
6 changes: 6 additions & 0 deletions apps/web/app/api/ai/analyze-sender-pattern/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ async function process({
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

logger.warn("Unsupported provider", { emailAccountId });
return NextResponse.json({ success: false }, { status: 400 });
}

if (!emailAccount) {
logger.error("Email account not found", { emailAccountId });
return NextResponse.json({ success: false }, { status: 404 });
Expand Down Expand Up @@ -270,6 +275,7 @@ async function getEmailAccountWithRules({
},
account: {
select: {
provider: true,
access_token: true,
refresh_token: true,
expires_at: true,
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/api/user/rules/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ async function getRule({
cc: { value: action.cc },
bcc: { value: action.bcc },
url: { value: action.url },
folderName: { value: action.folderName },
})),
categoryFilters: rule.categoryFilters.map((category) => category.id),
conditions: getConditions(rule),
Expand Down
1 change: 0 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/react": "2.0.0",
"@asteasolutions/zod-to-openapi": "7.3.2",

"@dub/analytics": "0.0.27",
"@formkit/auto-animate": "0.8.2",
"@googleapis/gmail": "12.0.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- AlterEnum
ALTER TYPE "ActionType" ADD VALUE 'MOVE_FOLDER';

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

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

-- AlterTable
ALTER TABLE "ScheduledAction" ADD COLUMN "folderName" TEXT;
Comment on lines +5 to +11
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.


-- 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

5 changes: 4 additions & 1 deletion apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ model Action {
cc String?
bcc String?
url String?
folderName String?
delayInMinutes Int?
}

Expand Down Expand Up @@ -422,6 +423,7 @@ model ExecutedAction {
cc String?
bcc String?
url String?
folderName String?

// additional fields as a result of the action
draftId String? // Gmail draft ID created by DRAFT_EMAIL action
Expand Down Expand Up @@ -451,7 +453,7 @@ model ScheduledAction {
cc String?
bcc String?
url String?

folderName String?
scheduledId String?

executedAt DateTime?
Expand Down Expand Up @@ -786,6 +788,7 @@ enum ActionType {
MARK_READ
TRACK_THREAD
DIGEST
MOVE_FOLDER
// SUMMARIZE
// SNOOZE
// ADD_TO_DO
Expand Down
5 changes: 5 additions & 0 deletions apps/web/utils/action-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ActionType } from "@prisma/client";
export function getActionDisplay(action: {
type: ActionType;
label?: string | null;
folderName?: string | null;
}): string {
switch (action.type) {
case ActionType.DRAFT_EMAIL:
Expand All @@ -22,6 +23,10 @@ export function getActionDisplay(action: {
return "Call Webhook";
case ActionType.TRACK_THREAD:
return "Auto-update reply label";
case ActionType.MOVE_FOLDER:
return action.folderName
? `Folder: ${action.folderName}`
: "Move to folder";
default:
// Default to capital case for other action types
return capitalCase(action.type);
Expand Down
28 changes: 27 additions & 1 deletion apps/web/utils/action-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ export const actionInputs: Record<
ActionType,
{
fields: {
name: "label" | "subject" | "content" | "to" | "cc" | "bcc" | "url";
name:
| "label"
| "subject"
| "content"
| "to"
| "cc"
| "bcc"
| "url"
| "folderName";
label: string;
textArea?: boolean;
expandable?: boolean;
Expand Down Expand Up @@ -135,6 +143,14 @@ export const actionInputs: Record<
},
[ActionType.MARK_READ]: { fields: [] },
[ActionType.TRACK_THREAD]: { fields: [] },
[ActionType.MOVE_FOLDER]: {
fields: [
{
name: "folderName",
label: "Folder name",
},
],
},
};

export function getActionFields(fields: Action | ExecutedAction | undefined) {
Expand All @@ -146,6 +162,7 @@ export function getActionFields(fields: Action | ExecutedAction | undefined) {
cc?: string;
bcc?: string;
url?: string;
folderName?: string;
} = {};

// only return fields with a value
Expand All @@ -156,6 +173,7 @@ export function getActionFields(fields: Action | ExecutedAction | undefined) {
if (fields?.cc) res.cc = fields.cc;
if (fields?.bcc) res.bcc = fields.bcc;
if (fields?.url) res.url = fields.url;
if (fields?.folderName) res.folderName = fields.folderName;

return res;
}
Expand All @@ -170,6 +188,7 @@ type ActionFieldsSelection = Pick<
| "cc"
| "bcc"
| "url"
| "folderName"
| "delayInMinutes"
>;

Expand All @@ -185,6 +204,7 @@ export function sanitizeActionFields(
cc: null,
bcc: null,
url: null,
folderName: null,
delayInMinutes: action.delayInMinutes || null,
};

Expand All @@ -195,6 +215,12 @@ export function sanitizeActionFields(
case ActionType.TRACK_THREAD:
case ActionType.DIGEST:
return base;
case ActionType.MOVE_FOLDER: {
return {
...base,
folderName: action.folderName ?? null,
};
}
case ActionType.LABEL: {
return {
...base,
Expand Down
18 changes: 16 additions & 2 deletions apps/web/utils/actions/reply-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { enableReplyTracker } from "@/utils/reply-tracker/enable";
import { actionClient } from "@/utils/actions/safe-action";
import { getGmailClientForEmail } from "@/utils/account";
import { SafeError } from "@/utils/error";
import { getEmailAccountWithAi } from "@/utils/user/get";
import { prefixPath } from "@/utils/path";

const logger = createScopedLogger("enableReplyTracker");
Expand All @@ -31,7 +30,22 @@ export const enableReplyTrackerAction = actionClient
export const processPreviousSentEmailsAction = actionClient
.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.

where: { id: emailAccountId },
select: {
account: { select: { provider: true } },
user: { select: { aiProvider: true, aiModel: true, aiApiKey: true } },
id: true,
email: true,
userId: true,
about: true,
},
});

if (emailAccount?.account?.provider !== "google") {
return { success: true };
}
Comment on lines +45 to +47
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 }.


if (!emailAccount) throw new SafeError("Email account not found");

const gmail = await getGmailClientForEmail({ emailAccountId });
Expand Down
4 changes: 4 additions & 0 deletions apps/web/utils/actions/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const createRuleAction = actionClient
cc,
bcc,
url,
folderName,
delayInMinutes,
}) => {
return sanitizeActionFields({
Expand All @@ -98,6 +99,7 @@ export const createRuleAction = actionClient
cc: cc?.value,
bcc: bcc?.value,
url: url?.value,
folderName: folderName?.value,
delayInMinutes,
});
},
Expand Down Expand Up @@ -230,6 +232,7 @@ export const updateRuleAction = actionClient
cc: a.cc?.value,
bcc: a.bcc?.value,
url: a.url?.value,
folderName: a.folderName?.value,
delayInMinutes: a.delayInMinutes,
}),
});
Expand All @@ -249,6 +252,7 @@ export const updateRuleAction = actionClient
cc: a.cc?.value,
bcc: a.bcc?.value,
url: a.url?.value,
folderName: a.folderName?.value,
delayInMinutes: a.delayInMinutes,
}),
ruleId: id,
Expand Down
Loading