diff --git a/apps/web/__tests__/ai-choose-rule.test.ts b/apps/web/__tests__/ai-choose-rule.test.ts index b021841e07..0d6be0d06c 100644 --- a/apps/web/__tests__/ai-choose-rule.test.ts +++ b/apps/web/__tests__/ai-choose-rule.test.ts @@ -76,6 +76,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { cc: null, bcc: null, url: null, + folderName: null, delayInMinutes: null, }, ]); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx index f0c4fe24ed..254f31662a 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSummaryCard.tsx @@ -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; } diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx index f318b1b30f..e393cd3208 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/RuleForm.tsx @@ -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({ resolver: zodResolver(createRuleBody), @@ -140,6 +140,7 @@ export function RuleForm({ ...action.content, setManually: !!action.content?.value, }, + folderName: action.folderName, })), ], } @@ -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 }, @@ -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] = diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx index c7f832c412..407f506032 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx @@ -133,6 +133,7 @@ export function Rules({ size = "md" }: { size?: "sm" | "md" }) { cc: null, bcc: null, url: null, + folderName: null, delayInMinutes: null, }, showArchiveAction @@ -149,6 +150,7 @@ export function Rules({ size = "md" }: { size?: "sm" | "md" }) { cc: null, bcc: null, url: null, + folderName: null, delayInMinutes: null, } : null, @@ -166,6 +168,7 @@ export function Rules({ size = "md" }: { size?: "sm" | "md" }) { cc: null, bcc: null, url: null, + folderName: null, delayInMinutes: null, } : null, @@ -464,6 +467,7 @@ export function ActionBadges({ id: string; type: ActionType; label?: string | null; + folderName?: string | null; }[]; }) { return ( diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts b/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts index 090d3de9ac..89f5d0a1c5 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts +++ b/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts @@ -10,6 +10,7 @@ import { WebhookIcon, EyeIcon, FileTextIcon, + FolderInputIcon, } from "lucide-react"; import { ActionType } from "@prisma/client"; @@ -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 = { @@ -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 = { @@ -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) @@ -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"; diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/DraftReplies.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/DraftReplies.tsx index 3360648aac..1115ef2c92 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/DraftReplies.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/DraftReplies.tsx @@ -88,6 +88,7 @@ export function useDraftReplies() { bcc: null, url: null, delayInMinutes: null, + folderName: null, createdAt: new Date(), updatedAt: new Date(), }, diff --git a/apps/web/app/api/ai/analyze-sender-pattern/route.ts b/apps/web/app/api/ai/analyze-sender-pattern/route.ts index d4f8ea9b0c..7ff37ea7a4 100644 --- a/apps/web/app/api/ai/analyze-sender-pattern/route.ts +++ b/apps/web/app/api/ai/analyze-sender-pattern/route.ts @@ -63,6 +63,11 @@ async function process({ try { const emailAccount = await getEmailAccountWithRules({ emailAccountId }); + if (emailAccount?.account?.provider !== "google") { + 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 }); @@ -270,6 +275,7 @@ async function getEmailAccountWithRules({ }, account: { select: { + provider: true, access_token: true, refresh_token: true, expires_at: true, diff --git a/apps/web/app/api/user/rules/[id]/route.ts b/apps/web/app/api/user/rules/[id]/route.ts index fbaafc0097..d3f670458e 100644 --- a/apps/web/app/api/user/rules/[id]/route.ts +++ b/apps/web/app/api/user/rules/[id]/route.ts @@ -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), diff --git a/apps/web/package.json b/apps/web/package.json index d7f2e3b932..381e0b403f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/prisma/migrations/20250811130806_add_move_folder_action/migration.sql b/apps/web/prisma/migrations/20250811130806_add_move_folder_action/migration.sql new file mode 100644 index 0000000000..0533ba73d6 --- /dev/null +++ b/apps/web/prisma/migrations/20250811130806_add_move_folder_action/migration.sql @@ -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; + +-- AlterTable +ALTER TABLE "VerificationToken" ALTER COLUMN "id" DROP DEFAULT; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 58f084df21..fe3a6ebb94 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -347,6 +347,7 @@ model Action { cc String? bcc String? url String? + folderName String? delayInMinutes Int? } @@ -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 @@ -451,7 +453,7 @@ model ScheduledAction { cc String? bcc String? url String? - + folderName String? scheduledId String? executedAt DateTime? @@ -786,6 +788,7 @@ enum ActionType { MARK_READ TRACK_THREAD DIGEST + MOVE_FOLDER // SUMMARIZE // SNOOZE // ADD_TO_DO diff --git a/apps/web/utils/action-display.ts b/apps/web/utils/action-display.ts index 9f5227a178..675a375c29 100644 --- a/apps/web/utils/action-display.ts +++ b/apps/web/utils/action-display.ts @@ -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: @@ -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); diff --git a/apps/web/utils/action-item.ts b/apps/web/utils/action-item.ts index 07a1c36895..85ca542417 100644 --- a/apps/web/utils/action-item.ts +++ b/apps/web/utils/action-item.ts @@ -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; @@ -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) { @@ -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 @@ -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; } @@ -170,6 +188,7 @@ type ActionFieldsSelection = Pick< | "cc" | "bcc" | "url" + | "folderName" | "delayInMinutes" >; @@ -185,6 +204,7 @@ export function sanitizeActionFields( cc: null, bcc: null, url: null, + folderName: null, delayInMinutes: action.delayInMinutes || null, }; @@ -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, diff --git a/apps/web/utils/actions/reply-tracking.ts b/apps/web/utils/actions/reply-tracking.ts index 430374896a..5973469b63 100644 --- a/apps/web/utils/actions/reply-tracking.ts +++ b/apps/web/utils/actions/reply-tracking.ts @@ -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"); @@ -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({ + 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 }; + } + if (!emailAccount) throw new SafeError("Email account not found"); const gmail = await getGmailClientForEmail({ emailAccountId }); diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index 7f30f92dd1..561717ac6d 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -87,6 +87,7 @@ export const createRuleAction = actionClient cc, bcc, url, + folderName, delayInMinutes, }) => { return sanitizeActionFields({ @@ -98,6 +99,7 @@ export const createRuleAction = actionClient cc: cc?.value, bcc: bcc?.value, url: url?.value, + folderName: folderName?.value, delayInMinutes, }); }, @@ -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, }), }); @@ -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, diff --git a/apps/web/utils/actions/rule.validation.ts b/apps/web/utils/actions/rule.validation.ts index 0ed2d1552e..befb0d57f8 100644 --- a/apps/web/utils/actions/rule.validation.ts +++ b/apps/web/utils/actions/rule.validation.ts @@ -26,6 +26,7 @@ const zodActionType = z.enum([ ActionType.MARK_READ, ActionType.TRACK_THREAD, ActionType.DIGEST, + ActionType.MOVE_FOLDER, ]); const zodConditionType = z.enum([ @@ -69,6 +70,24 @@ const zodField = z }) .nullish(); +const zodFolderNameField = z + .object({ + value: z + .string() + .nullish() + .refine((val) => { + if (!val?.trim()) return true; + // Check for empty folder parts + if (val.includes("//") || val.split("/").some((part) => !part.trim())) + return false; + + return true; + }), + ai: z.boolean().nullish(), + setManually: z.boolean().nullish(), + }) + .nullish(); + const zodAction = z .object({ id: z.string().optional(), @@ -80,6 +99,7 @@ const zodAction = z cc: zodField, bcc: zodField, url: zodField, + folderName: zodFolderNameField, delayInMinutes: delayInMinutesSchema, }) .superRefine((data, ctx) => { @@ -104,6 +124,17 @@ const zodAction = z path: ["url"], }); } + if ( + data.type === ActionType.MOVE_FOLDER && + !data.folderName?.value?.trim() + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Please enter a valid folder name or path to move the emails to", + path: ["folderName"], + }); + } }); export const createRuleBody = z.object({ diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index 5d4bae80d8..bbb2255e9e 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -65,6 +65,8 @@ export const runActionFunction = async (options: { return track_thread(opts); case ActionType.DIGEST: return digest(opts); + case ActionType.MOVE_FOLDER: + return move_folder(opts); default: throw new Error(`Unknown action: ${action}`); } @@ -264,3 +266,12 @@ const digest: ActionFunction = async ({ email, emailAccountId, args }) => { const actionId = args.id; await enqueueDigestItem({ email, emailAccountId, actionId }); }; + +const move_folder: ActionFunction = async ({ + client, + email, + userEmail, + args, +}) => { + await client.moveThreadToFolder(email.threadId, userEmail, args.folderName); +}; diff --git a/apps/web/utils/ai/assistant/chat.ts b/apps/web/utils/ai/assistant/chat.ts index e9637c5e5d..ca49ca787c 100644 --- a/apps/web/utils/ai/assistant/chat.ts +++ b/apps/web/utils/ai/assistant/chat.ts @@ -66,6 +66,7 @@ const getUserRulesAndSettingsTool = ({ bcc: true, subject: true, url: true, + folderName: true, }, }, }, @@ -107,6 +108,7 @@ const getUserRulesAndSettingsTool = ({ bcc: action.bcc, subject: action.subject, url: action.url, + folderName: action.folderName, }), })), enabled: rule.enabled, @@ -204,6 +206,7 @@ const createRuleTool = ({ webhookUrl: action.fields.webhookUrl ?? null, cc: action.fields.cc ?? null, bcc: action.fields.bcc ?? null, + folderName: action.fields.folderName ?? null, } : null, })), @@ -380,6 +383,7 @@ const updateRuleActionsTool = ({ cc: z.string().nullish(), bcc: z.string().nullish(), subject: z.string().nullish(), + folderName: z.string().nullish(), }), delayInMinutes: delayInMinutesSchema, }), @@ -402,6 +406,7 @@ const updateRuleActionsTool = ({ bcc: true, subject: true, url: true, + folderName: true, }, }, }, @@ -427,6 +432,7 @@ const updateRuleActionsTool = ({ bcc: action.bcc, subject: action.subject, webhookUrl: action.url, + folderName: action.folderName, }), })); @@ -442,6 +448,7 @@ const updateRuleActionsTool = ({ subject: action.fields?.subject ?? null, content: action.fields?.content ?? null, webhookUrl: action.fields?.webhookUrl ?? null, + folderName: action.fields?.folderName ?? null, }, delayInMinutes: action.delayInMinutes ?? null, })), diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index da5383e7c9..0e86e6ef2a 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -573,6 +573,7 @@ ${senderCategory || "No category"} subject: action.fields.subject ?? null, content: action.fields.content ?? null, webhookUrl: action.fields.webhookUrl ?? null, + folderName: action.fields.folderName ?? null, } : null, })), diff --git a/apps/web/utils/ai/rule/create-prompt-from-rule.ts b/apps/web/utils/ai/rule/create-prompt-from-rule.ts index e0d32bb619..b2eadb8726 100644 --- a/apps/web/utils/ai/rule/create-prompt-from-rule.ts +++ b/apps/web/utils/ai/rule/create-prompt-from-rule.ts @@ -92,6 +92,9 @@ export function createPromptFromRule(rule: RuleWithRelations): string { case ActionType.DIGEST: actions.push("add to digest"); break; + case ActionType.MOVE_FOLDER: + actions.push("move to folder"); + break; default: logger.warn("Unknown action type", { actionType: action.type }); // biome-ignore lint/correctness/noSwitchDeclarations: intentional exhaustive check diff --git a/apps/web/utils/ai/rule/create-rule-schema.ts b/apps/web/utils/ai/rule/create-rule-schema.ts index f6febbaf37..bca28b4113 100644 --- a/apps/web/utils/ai/rule/create-rule-schema.ts +++ b/apps/web/utils/ai/rule/create-rule-schema.ts @@ -72,6 +72,11 @@ const actionSchema = z.object({ .nullish() .transform((v) => v ?? null) .describe("The webhook URL to call"), + folderName: z + .string() + .nullish() + .transform((v) => v ?? null) + .describe("The folder to move the email to"), }) .nullish() .describe( diff --git a/apps/web/utils/api-auth.test.ts b/apps/web/utils/api-auth.test.ts index 56ab7ea988..84595e6c27 100644 --- a/apps/web/utils/api-auth.test.ts +++ b/apps/web/utils/api-auth.test.ts @@ -197,7 +197,7 @@ describe("api-auth", () => { { access_token: "access-token", refresh_token: "refresh-token", - expires_at: 1_234_567_890, + expires_at: new Date(), providerAccountId: "google-account-id", }, ], @@ -235,7 +235,7 @@ describe("api-auth", () => { { access_token: "access-token", refresh_token: "refresh-token", - expires_at: 1_234_567_890, + expires_at: new Date(1_234_567_890 * 1000), providerAccountId: "google-account-id", }, ], @@ -266,7 +266,7 @@ describe("api-auth", () => { expect(gmailClient.getGmailClientWithRefresh).toHaveBeenCalledWith({ accessToken: "access-token", refreshToken: "refresh-token", - expiresAt: 1_234_567_890, + expiresAt: 1_234_567_890 * 1000, }); }); }); diff --git a/apps/web/utils/auth.ts b/apps/web/utils/auth.ts index 62f248711a..03729f8e73 100644 --- a/apps/web/utils/auth.ts +++ b/apps/web/utils/auth.ts @@ -399,48 +399,80 @@ async function handleLinkAccount(account: Account) { } } -// Used by Outlook and Gmail providers export async function saveTokens({ tokens, accountRefreshToken, + providerAccountId, emailAccountId, provider, }: { tokens: { access_token?: string; + refresh_token?: string; expires_at?: number; }; - accountRefreshToken: string; - emailAccountId: string; - provider: "google" | "microsoft"; -}) { - const account = await prisma.account.findUnique({ - where: { id: emailAccountId }, - }); + accountRefreshToken: string | null; + provider: string; +} & ( // provide one of these: + | { + providerAccountId: string; + emailAccountId?: never; + } + | { + emailAccountId: string; + providerAccountId?: never; + } +)) { + const refreshToken = tokens.refresh_token ?? accountRefreshToken; - if (!account) { - logger.error("Account not found for token save", { emailAccountId }); + if (!refreshToken) { + logger.error("Attempted to save null refresh token", { providerAccountId }); + captureException("Cannot save null refresh token", { + extra: { providerAccountId }, + }); return; } - const updatedAccount = await prisma.account.update({ - where: { id: account.id }, - data: { - access_token: tokens.access_token - ? encryptToken(tokens.access_token) - : account.access_token, - expires_at: tokens.expires_at - ? new Date(tokens.expires_at * 1000) - : account.expires_at, - refresh_token: accountRefreshToken, - }, - }); + const data = { + access_token: tokens.access_token, + expires_at: tokens.expires_at ? new Date(tokens.expires_at * 1000) : null, + refresh_token: refreshToken, + }; - logger.info("Tokens saved for account", { - emailAccountId, - provider, - updatedAccount: updatedAccount.id, - }); + if (emailAccountId) { + // Encrypt tokens in data directly + // Usually we do this in prisma-extensions.ts but we need to do it here because we're updating the account via the emailAccount + // We could also edit prisma-extensions.ts to handle this case but this is easier for now + if (data.access_token) + data.access_token = encryptToken(data.access_token) || undefined; + if (data.refresh_token) + data.refresh_token = encryptToken(data.refresh_token) || ""; + + await prisma.emailAccount.update({ + where: { id: emailAccountId }, + data: { account: { update: data } }, + }); + } else { + if (!providerAccountId) { + logger.error("No providerAccountId found in database", { + emailAccountId, + }); + captureException("No providerAccountId found in database", { + extra: { emailAccountId }, + }); + return; + } + + return await prisma.account.update({ + where: { + provider_providerAccountId: { + provider, + providerAccountId, + }, + }, + data, + }); + } } export const auth = async () => diff --git a/apps/web/utils/delayed-actions.ts b/apps/web/utils/delayed-actions.ts index a28389f7f6..534d9a5d34 100644 --- a/apps/web/utils/delayed-actions.ts +++ b/apps/web/utils/delayed-actions.ts @@ -10,6 +10,7 @@ const SUPPORTED_DELAYED_ACTIONS: ActionType[] = [ ActionType.DRAFT_EMAIL, ActionType.CALL_WEBHOOK, ActionType.MARK_READ, + ActionType.MOVE_FOLDER, ]; export function canActionBeDelayed(actionType: ActionType): boolean { diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 18f1c8335e..d0a60adb46 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -662,4 +662,12 @@ export class GmailProvider implements EmailProvider { isReplyInThread(message: ParsedMessage): boolean { return !!(message.id && message.id !== message.threadId); } + + async moveThreadToFolder( + _threadId: string, + _ownerEmail: string, + _folderName: string, + ): Promise { + logger.warn("Moving thread to folder is not supported for Gmail"); + } } diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index f938ebd826..78e84f1e34 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -169,14 +169,12 @@ export class OutlookProvider implements EmailProvider { async archiveThreadWithLabel( threadId: string, ownerEmail: string, - labelId?: string, ): Promise { await outlookArchiveThread({ client: this.client, threadId, ownerEmail, actionSource: "user", - labelId, }); } @@ -819,4 +817,18 @@ export class OutlookProvider implements EmailProvider { return false; } } + + async moveThreadToFolder( + threadId: string, + ownerEmail: string, + folderName: string, + ): Promise { + await outlookArchiveThread({ + client: this.client, + threadId, + ownerEmail, + actionSource: "automation", + folderName, + }); + } } diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 325e104b08..33674f03e4 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -157,4 +157,9 @@ export interface EmailProvider { } | null>; unwatchEmails(subscriptionId?: string): Promise; isReplyInThread(message: ParsedMessage): boolean; + moveThreadToFolder( + threadId: string, + ownerEmail: string, + folderName: string, + ): Promise; } diff --git a/apps/web/utils/middleware.test.ts b/apps/web/utils/middleware.test.ts index 6e0d37e9a7..b27e8b21f7 100644 --- a/apps/web/utils/middleware.test.ts +++ b/apps/web/utils/middleware.test.ts @@ -30,6 +30,11 @@ vi.mock("better-auth", () => { }; }); +// Mock the auth function from @/utils/auth +vi.mock("@/utils/auth", () => ({ + auth: vi.fn(), +})); + vi.mock("@/utils/redis/account-validation"); // Mock specific functions from @/utils/error, keep original SafeError diff --git a/apps/web/utils/outlook/label.ts b/apps/web/utils/outlook/label.ts index 6a7c951e44..82e675840e 100644 --- a/apps/web/utils/outlook/label.ts +++ b/apps/web/utils/outlook/label.ts @@ -1,6 +1,7 @@ import type { OutlookClient } from "@/utils/outlook/client"; import { createScopedLogger } from "@/utils/logger"; import { publishArchive, type TinybirdEmailAction } from "@inboxzero/tinybird"; +import { getOrCreateFolderByName } from "./message"; import { inboxZeroLabels, type InboxZeroLabel } from "@/utils/label"; const logger = createScopedLogger("outlook/label"); @@ -233,23 +234,25 @@ export async function archiveThread({ threadId, ownerEmail, actionSource, - labelId, + folderName = "archive", }: { client: OutlookClient; threadId: string; ownerEmail: string; actionSource: TinybirdEmailAction["actionSource"]; - labelId?: string; + folderName?: string; }) { + // Get or create the destination folder (handles both well-known and custom folders) + const destinationFolderId = await getOrCreateFolderByName(client, folderName); + try { - // In Outlook, archiving is moving to the Archive folder + // In Outlook, archiving is moving to a folder // We need to move each message in the thread individually - // Escape single quotes in threadId for the filter const escapedThreadId = threadId.replace(/'/g, "''"); const messages = await client .getClient() .api("/me/messages") - .filter(`conversationId eq '${escapedThreadId}'`) + .filter(`conversationId eq '${escapedThreadId}'`) // Escape single quotes in threadId for the filter .get(); const archivePromise = Promise.all( @@ -259,11 +262,10 @@ export async function archiveThread({ .getClient() .api(`/me/messages/${message.id}/move`) .post({ - destinationId: "archive", + destinationId: destinationFolderId, }); } catch (error) { - // Log the error but don't fail the entire operation - logger.warn("Failed to move message to archive", { + logger.warn(`Failed to move message to ${destinationFolderId}`, { messageId: message.id, threadId, error: error instanceof Error ? error.message : error, @@ -291,12 +293,15 @@ export async function archiveThread({ logger.warn("Thread not found", { threadId, userEmail: ownerEmail }); return { status: 404, message: "Thread not found" }; } - logger.error("Failed to archive thread", { threadId, error }); + logger.error(`Failed to move thread to ${folderName}`, { + threadId, + error, + }); throw error; } if (publishResult.status === "rejected") { - logger.error("Failed to publish archive action", { + logger.error(`Failed to publish action to move thread to ${folderName}`, { threadId, error: publishResult.reason, }); @@ -325,7 +330,7 @@ export async function archiveThread({ ); if (threadMessages.length > 0) { - // Move each message in the thread to the archive folder + // Move each message in the thread to the destination folder const movePromises = threadMessages.map( async (message: { id: string }) => { try { @@ -333,11 +338,11 @@ export async function archiveThread({ .getClient() .api(`/me/messages/${message.id}/move`) .post({ - destinationId: "archive", + destinationId: destinationFolderId, }); } catch (moveError) { // Log the error but don't fail the entire operation - logger.warn("Failed to move message to archive", { + logger.warn(`Failed to move message to ${folderName}`, { messageId: message.id, threadId, error: @@ -352,7 +357,7 @@ export async function archiveThread({ } else { // If no messages found, try treating threadId as a messageId await client.getClient().api(`/me/messages/${threadId}/move`).post({ - destinationId: "archive", + destinationId: destinationFolderId, }); } @@ -365,16 +370,19 @@ export async function archiveThread({ timestamp: Date.now(), }); } catch (publishError) { - logger.error("Failed to publish archive action", { - email: ownerEmail, - threadId, - error: publishError, - }); + logger.error( + `Failed to publish action to move thread to ${folderName}`, + { + email: ownerEmail, + threadId, + error: publishError, + }, + ); } return { status: 200 }; } catch (directError) { - logger.error("Failed to archive thread", { + logger.error(`Failed to move thread to ${folderName}`, { threadId, error: directError, }); diff --git a/apps/web/utils/outlook/message.ts b/apps/web/utils/outlook/message.ts index 50eea8d1c9..677677ca65 100644 --- a/apps/web/utils/outlook/message.ts +++ b/apps/web/utils/outlook/message.ts @@ -10,7 +10,7 @@ const logger = createScopedLogger("outlook/message"); let folderIdCache: Record | null = null; // Well-known folder names in Outlook that are consistent across all languages -const WELL_KNOWN_FOLDERS = { +export const WELL_KNOWN_FOLDERS = { inbox: "inbox", sentitems: "sentitems", drafts: "drafts", @@ -53,6 +53,142 @@ export async function getFolderIds(client: OutlookClient) { return folderIdCache; } +export async function getOrCreateFolderByName( + client: OutlookClient, + folderPath: string, +): Promise { + try { + const folderParts = folderPath + .replace(/^\/+|\/+$/g, "") // Remove leading and trailing slashes + .split("/") // Split into parts + .map((part) => part.trim()) // Trim each part + .filter((part) => part); // Remove empty parts + + if (folderParts.length === 0) { + throw new Error("Invalid folder path: empty path"); + } + + // If it's a single folder, check if it's well-known first + if (folderParts.length === 1) { + const folderName = folderParts[0]; + const wellKnownFolderValues = Object.values(WELL_KNOWN_FOLDERS); + const normalizedFolderName = folderName.toLowerCase(); + + // If it's a well-known folder, return its name (Outlook accepts well-known folder names as IDs) + if ( + wellKnownFolderValues.includes( + normalizedFolderName as (typeof wellKnownFolderValues)[number], + ) + ) { + return normalizedFolderName; + } + + // Otherwise create or find custom folder + return await getOrCreateSingleFolder(client, folderName); + } + + // Handle hierarchical folders + let parentFolderId: string | null = null; + + for (let i = 0; i < folderParts.length; i++) { + const folderName = folderParts[i]; + const isFirstLevel = i === 0; + + if (isFirstLevel) { + // Check if first level is a well-known folder + const wellKnownFolderValues = Object.values(WELL_KNOWN_FOLDERS); + const normalizedFolderName = folderName.toLowerCase(); + + if ( + wellKnownFolderValues.includes( + normalizedFolderName as (typeof wellKnownFolderValues)[number], + ) + ) { + // Use the well-known folder ID + const response = await client + .getClient() + .api(`/me/mailFolders/${normalizedFolderName}`) + .select("id") + .get(); + parentFolderId = response.id; + } else { + // Create or find custom top-level folder + parentFolderId = await getOrCreateSingleFolder(client, folderName); + } + } else { + // Create or find subfolder within parent + parentFolderId = await getOrCreateSubfolder( + client, + parentFolderId!, + folderName, + ); + } + } + + return parentFolderId!; + } catch (error) { + logger.error("Error getting or creating folder path", { + folderPath, + error, + }); + throw error; + } +} + +async function getOrCreateSingleFolder( + client: OutlookClient, + folderName: string, +): Promise { + // First try to find the folder by name at root level + const response = await client + .getClient() + .api("/me/mailFolders") + .filter(`displayName eq '${folderName.replace(/'/g, "''")}'`) + .select("id,displayName") + .get(); + + if (response.value.length > 0) { + return response.value[0].id!; + } + + // If folder doesn't exist, create it at root level + const createResponse = await client.getClient().api("/me/mailFolders").post({ + displayName: folderName, + isHidden: false, + }); + + return createResponse.id!; +} + +async function getOrCreateSubfolder( + client: OutlookClient, + parentFolderId: string, + folderName: string, +): Promise { + // First try to find the subfolder within the parent + const response = await client + .getClient() + .api(`/me/mailFolders/${parentFolderId}/childFolders`) + .filter(`displayName eq '${folderName.replace(/'/g, "''")}'`) + .select("id,displayName") + .get(); + + if (response.value.length > 0) { + return response.value[0].id!; + } + + // If subfolder doesn't exist, create it within the parent + const createResponse = await client + .getClient() + .api(`/me/mailFolders/${parentFolderId}/childFolders`) + .post({ + displayName: folderName, + isHidden: false, + }); + + return createResponse.id!; +} + function getOutlookLabels( message: Message, folderIds: Record, diff --git a/apps/web/utils/reply-tracker/enable.ts b/apps/web/utils/reply-tracker/enable.ts index ea7b1cd5ce..42ff4dd7ab 100644 --- a/apps/web/utils/reply-tracker/enable.ts +++ b/apps/web/utils/reply-tracker/enable.ts @@ -156,6 +156,7 @@ export async function createToReplyRule( cc: null, bcc: null, webhookUrl: null, + folderName: null, }, }, ...(addDigest ? [{ type: ActionType.DIGEST }] : []), diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index 6d7f4d7f69..269f2d87f8 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -361,6 +361,7 @@ function mapActionFields( subject: a.fields?.subject, content: a.fields?.content, url: a.fields?.webhookUrl, + folderName: a.fields?.folderName, delayInMinutes: a.delayInMinutes, }), );