Email Fields
-
-
+
+
threadId - Gmail/Outlook thread ID
-
-
+
+
messageId - Unique message ID
-
-
+
+
subject - Email subject line
-
-
+
+
from - Sender's email address
-
-
+
+
cc/bcc - Optional CC/BCC recipients
-
-
+
+
headerMessageId - Email Message-ID header
-
+
Rule Execution Fields
-
-
+
+
id - Execution ID
-
-
+
+
ruleId - Rule that was triggered
-
-
+
+
reason - Why the rule was triggered
-
-
+
+
automated - Whether rule ran automatically
-
-
+
+
createdAt - When the rule was executed (ISO 8601)
-
+
diff --git a/apps/web/components/drive/FilingStatusCell.tsx b/apps/web/components/drive/FilingStatusCell.tsx
new file mode 100644
index 0000000000..04fba0d527
--- /dev/null
+++ b/apps/web/components/drive/FilingStatusCell.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { LoaderIcon, InfoIcon } from "lucide-react";
+import { Tooltip } from "@/components/Tooltip";
+import { cn } from "@/utils";
+
+export type FilingStatus = "filing" | "pending" | "skipped" | "error" | "filed";
+
+interface FilingStatusCellProps {
+ status: FilingStatus;
+ skipReason?: string | null;
+ error?: string | null;
+ folderPath?: string | null;
+ className?: string;
+}
+
+export function FilingStatusCell({
+ status,
+ skipReason,
+ error,
+ folderPath,
+ className,
+}: FilingStatusCellProps) {
+ if (status === "filing" || status === "pending") {
+ return (
+
+
+ Analyzing...
+
+ );
+ }
+
+ if (status === "skipped") {
+ const tooltipContent = `Skipped — ${skipReason || "Doesn't match preferences"}`;
+ return (
+
+
+ Skipped
+
+
+
+ );
+ }
+
+ if (status === "error") {
+ const errorMessage = error || "Failed to file";
+ return (
+
+
+ {errorMessage}
+
+
+
+ );
+ }
+
+ // status === "filed"
+ const displayPath = folderPath || "—";
+ return (
+
+
+ {displayPath}
+
+
+
+ );
+}
diff --git a/apps/web/components/drive/TableCellWithTooltip.tsx b/apps/web/components/drive/TableCellWithTooltip.tsx
new file mode 100644
index 0000000000..61178f16bd
--- /dev/null
+++ b/apps/web/components/drive/TableCellWithTooltip.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { InfoIcon } from "lucide-react";
+import { Tooltip } from "@/components/Tooltip";
+import { cn } from "@/utils";
+
+interface TableCellWithTooltipProps {
+ text: string;
+ tooltipContent: string;
+ className?: string;
+ truncate?: boolean;
+}
+
+export function TableCellWithTooltip({
+ text,
+ tooltipContent,
+ className,
+ truncate = true,
+}: TableCellWithTooltipProps) {
+ return (
+
+
+ {truncate ? (
+ <>
+ {text}
+
+ >
+ ) : (
+ <>
+ {text}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/components/drive/YesNoIndicator.tsx b/apps/web/components/drive/YesNoIndicator.tsx
new file mode 100644
index 0000000000..cca5b0eee5
--- /dev/null
+++ b/apps/web/components/drive/YesNoIndicator.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { CheckIcon, XIcon } from "lucide-react";
+import { cn } from "@/utils";
+
+interface YesNoIndicatorProps {
+ value: boolean | null | undefined;
+ onClick?: (value: boolean) => void;
+ size?: "sm" | "md";
+}
+
+export function YesNoIndicator({
+ value,
+ onClick,
+ size = "md",
+}: YesNoIndicatorProps) {
+ const iconSize = size === "sm" ? "size-3.5" : "size-4";
+ const isInteractive = !!onClick;
+
+ if (value === true) {
+ return (
+
onClick(true) : undefined}
+ disabled={!isInteractive}
+ className={cn(
+ "rounded-full p-1.5 transition-colors",
+ isInteractive
+ ? "bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400 hover:opacity-80"
+ : "bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400",
+ !isInteractive && "cursor-default",
+ )}
+ aria-label="Correct"
+ >
+
+
+ );
+ }
+
+ if (value === false) {
+ return (
+
onClick(false) : undefined}
+ disabled={!isInteractive}
+ className={cn(
+ "rounded-full p-1.5 transition-colors",
+ isInteractive
+ ? "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400 hover:opacity-80"
+ : "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400",
+ !isInteractive && "cursor-default",
+ )}
+ aria-label="Wrong"
+ >
+
+
+ );
+ }
+
+ // value is null or undefined
+ if (!isInteractive) {
+ return
— ;
+ }
+
+ return (
+
+ onClick(true)}
+ className="rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+ aria-label="Correct"
+ >
+
+
+ onClick(false)}
+ className="rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
+ aria-label="Wrong"
+ >
+
+
+
+ );
+}
diff --git a/apps/web/components/email-list/EmailMessage.tsx b/apps/web/components/email-list/EmailMessage.tsx
index 915d05d6a6..c6bd9c8ad1 100644
--- a/apps/web/components/email-list/EmailMessage.tsx
+++ b/apps/web/components/email-list/EmailMessage.tsx
@@ -23,7 +23,7 @@ import { EmailDetails } from "@/components/email-list/EmailDetails";
import { HtmlEmail, PlainEmail } from "@/components/email-list/EmailContents";
import { EmailAttachments } from "@/components/email-list/EmailAttachments";
import { Loading } from "@/components/Loading";
-import { MessageText } from "@/components/Typography";
+import { MessageText, MutedText } from "@/components/Typography";
import { useAccount } from "@/providers/EmailAccountProvider";
import { formatReplySubject } from "@/utils/email/subject";
@@ -160,11 +160,11 @@ function TopBar({
)}
-
+
{formatShortDate(new Date(message.headers.date))}
-
+
{showReplyButton && (
diff --git a/apps/web/components/email-list/EmailPanel.tsx b/apps/web/components/email-list/EmailPanel.tsx
index d13cd978a6..bbbd2a23a9 100644
--- a/apps/web/components/email-list/EmailPanel.tsx
+++ b/apps/web/components/email-list/EmailPanel.tsx
@@ -7,6 +7,7 @@ import { PlanExplanation } from "@/components/email-list/PlanExplanation";
import { useIsInAiQueue } from "@/store/ai-queue";
import { EmailThread } from "@/components/email-list/EmailThread";
import { useAccount } from "@/providers/EmailAccountProvider";
+import { MutedText } from "@/components/Typography";
export function EmailPanel({
row,
@@ -40,9 +41,9 @@ export function EmailPanel({
>
{lastMessage.headers.subject}
-
+
{lastMessage.headers.from}
-
+
diff --git a/apps/web/components/kibo-ui/tree/index.tsx b/apps/web/components/kibo-ui/tree/index.tsx
new file mode 100644
index 0000000000..05c18f9f09
--- /dev/null
+++ b/apps/web/components/kibo-ui/tree/index.tsx
@@ -0,0 +1,468 @@
+"use client";
+
+import { ChevronRight, File, Folder, FolderOpen } from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import {
+ type ComponentProps,
+ createContext,
+ type HTMLAttributes,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useId,
+ useState,
+} from "react";
+import { cn } from "@/utils/index";
+
+type TreeContextType = {
+ expandedIds: Set
;
+ selectedIds: string[];
+ toggleExpanded: (nodeId: string) => void;
+ handleSelection: (nodeId: string, ctrlKey: boolean) => void;
+ showLines?: boolean;
+ showIcons?: boolean;
+ selectable?: boolean;
+ multiSelect?: boolean;
+ indent?: number;
+ animateExpand?: boolean;
+};
+
+const TreeContext = createContext(undefined);
+
+export const useTree = () => {
+ const context = useContext(TreeContext);
+ if (!context) {
+ throw new Error("Tree components must be used within a TreeProvider");
+ }
+ return context;
+};
+
+type TreeNodeContextType = {
+ nodeId: string;
+ level: number;
+ isLast: boolean;
+ parentPath: boolean[];
+};
+
+const TreeNodeContext = createContext(
+ undefined,
+);
+
+const useTreeNode = () => {
+ const context = useContext(TreeNodeContext);
+ if (!context) {
+ throw new Error("TreeNode components must be used within a TreeNode");
+ }
+ return context;
+};
+
+export type TreeProviderProps = {
+ children: ReactNode;
+ defaultExpandedIds?: string[];
+ showLines?: boolean;
+ showIcons?: boolean;
+ selectable?: boolean;
+ multiSelect?: boolean;
+ selectedIds?: string[];
+ onSelectionChange?: (selectedIds: string[]) => void;
+ indent?: number;
+ animateExpand?: boolean;
+ className?: string;
+};
+
+export const TreeProvider = ({
+ children,
+ defaultExpandedIds = [],
+ showLines = true,
+ showIcons = true,
+ selectable = true,
+ multiSelect = false,
+ selectedIds,
+ onSelectionChange,
+ indent = 20,
+ animateExpand = true,
+ className,
+}: TreeProviderProps) => {
+ const [expandedIds, setExpandedIds] = useState>(
+ new Set(defaultExpandedIds),
+ );
+ const [internalSelectedIds, setInternalSelectedIds] = useState(
+ selectedIds ?? [],
+ );
+
+ const isControlled =
+ selectedIds !== undefined && onSelectionChange !== undefined;
+ const currentSelectedIds = isControlled ? selectedIds : internalSelectedIds;
+
+ const toggleExpanded = useCallback((nodeId: string) => {
+ setExpandedIds((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(nodeId)) {
+ newSet.delete(nodeId);
+ } else {
+ newSet.add(nodeId);
+ }
+ return newSet;
+ });
+ }, []);
+
+ const handleSelection = useCallback(
+ (nodeId: string, ctrlKey = false) => {
+ if (!selectable) {
+ return;
+ }
+
+ let newSelection: string[];
+
+ if (multiSelect && ctrlKey) {
+ newSelection = currentSelectedIds.includes(nodeId)
+ ? currentSelectedIds.filter((id) => id !== nodeId)
+ : [...currentSelectedIds, nodeId];
+ } else {
+ newSelection = currentSelectedIds.includes(nodeId) ? [] : [nodeId];
+ }
+
+ if (isControlled) {
+ onSelectionChange?.(newSelection);
+ } else {
+ setInternalSelectedIds(newSelection);
+ }
+ },
+ [
+ selectable,
+ multiSelect,
+ currentSelectedIds,
+ isControlled,
+ onSelectionChange,
+ ],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export type TreeViewProps = HTMLAttributes;
+
+export const TreeView = ({ className, children, ...props }: TreeViewProps) => (
+
+ {children}
+
+);
+
+export type TreeNodeProps = HTMLAttributes & {
+ nodeId?: string;
+ level?: number;
+ isLast?: boolean;
+ parentPath?: boolean[];
+ children?: ReactNode;
+};
+
+export const TreeNode = ({
+ nodeId: providedNodeId,
+ level = 0,
+ isLast = false,
+ parentPath = [],
+ children,
+ className,
+ onClick,
+ ...props
+}: TreeNodeProps) => {
+ const generatedId = useId();
+ const nodeId = providedNodeId ?? generatedId;
+
+ // Build the parent path - mark positions where the parent was the last child
+ const currentPath = level === 0 ? [] : [...parentPath];
+ if (level > 0 && parentPath.length < level - 1) {
+ // Fill in missing levels with false (not last)
+ while (currentPath.length < level - 1) {
+ currentPath.push(false);
+ }
+ }
+ if (level > 0) {
+ currentPath[level - 1] = isLast;
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export type TreeNodeTriggerProps = ComponentProps;
+
+export const TreeNodeTrigger = ({
+ children,
+ className,
+ onClick,
+ ...props
+}: TreeNodeTriggerProps) => {
+ const { selectedIds, expandedIds, toggleExpanded, handleSelection, indent } =
+ useTree();
+ const { nodeId, level } = useTreeNode();
+ const isSelected = selectedIds.includes(nodeId);
+ const isExpanded = expandedIds.has(nodeId);
+
+ return (
+ {
+ toggleExpanded(nodeId);
+ handleSelection(nodeId, e.ctrlKey || e.metaKey);
+ onClick?.(e);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ toggleExpanded(nodeId);
+ handleSelection(nodeId, e.ctrlKey || e.metaKey);
+ }
+ }}
+ role="treeitem"
+ tabIndex={0}
+ aria-selected={isSelected}
+ aria-expanded={isExpanded}
+ style={{ paddingLeft: level * (indent ?? 0) + 8 }}
+ whileTap={{ scale: 0.98, transition: { duration: 0.1 } }}
+ {...props}
+ >
+
+ {children as ReactNode}
+
+ );
+};
+
+export const TreeLines = () => {
+ const { showLines, indent } = useTree();
+ const { level, isLast, parentPath } = useTreeNode();
+
+ if (!showLines || level === 0) {
+ return null;
+ }
+
+ return (
+
+ {/* Render vertical lines for all parent levels */}
+ {Array.from({ length: level }, (_, index) => {
+ const shouldHideLine = parentPath[index] === true;
+ if (shouldHideLine && index === level - 1) {
+ return null;
+ }
+
+ return (
+
+ );
+ })}
+
+ {/* Horizontal connector line */}
+
+
+ {/* Vertical line to midpoint for last items */}
+ {isLast && (
+
+ )}
+
+ );
+};
+
+export type TreeNodeContentProps = ComponentProps & {
+ hasChildren?: boolean;
+};
+
+export const TreeNodeContent = ({
+ children,
+ hasChildren = false,
+ className,
+ ...props
+}: TreeNodeContentProps) => {
+ const { animateExpand, expandedIds } = useTree();
+ const { nodeId } = useTreeNode();
+ const isExpanded = expandedIds.has(nodeId);
+
+ return (
+
+ {hasChildren && isExpanded && (
+
+
+ {children}
+
+
+ )}
+
+ );
+};
+
+export type TreeExpanderProps = ComponentProps & {
+ hasChildren?: boolean;
+};
+
+export const TreeExpander = ({
+ hasChildren = false,
+ className,
+ onClick,
+ ...props
+}: TreeExpanderProps) => {
+ const { expandedIds, toggleExpanded } = useTree();
+ const { nodeId } = useTreeNode();
+ const isExpanded = expandedIds.has(nodeId);
+
+ if (!hasChildren) {
+ return
;
+ }
+
+ return (
+ {
+ e.stopPropagation();
+ toggleExpanded(nodeId);
+ onClick?.(e);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleExpanded(nodeId);
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ transition={{ duration: 0.2, ease: "easeInOut" }}
+ {...props}
+ >
+
+
+ );
+};
+
+export type TreeIconProps = ComponentProps & {
+ icon?: ReactNode;
+ hasChildren?: boolean;
+};
+
+export const TreeIcon = ({
+ icon,
+ hasChildren = false,
+ className,
+ ...props
+}: TreeIconProps) => {
+ const { showIcons, expandedIds } = useTree();
+ const { nodeId } = useTreeNode();
+ const isExpanded = expandedIds.has(nodeId);
+
+ if (!showIcons) {
+ return null;
+ }
+
+ const getDefaultIcon = () =>
+ hasChildren ? (
+ isExpanded ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ );
+
+ return (
+
+ {icon || getDefaultIcon()}
+
+ );
+};
+
+export type TreeLabelProps = HTMLAttributes;
+
+export const TreeLabel = ({ className, ...props }: TreeLabelProps) => (
+
+);
diff --git a/apps/web/components/ui/card.tsx b/apps/web/components/ui/card.tsx
index 2b49a578cf..aea288cab1 100644
--- a/apps/web/components/ui/card.tsx
+++ b/apps/web/components/ui/card.tsx
@@ -4,12 +4,13 @@ import { cn } from "@/utils";
const Card = React.forwardRef<
HTMLDivElement,
- React.HTMLAttributes
->(({ className, ...props }, ref) => (
+ React.HTMLAttributes & { size?: "default" | "sm" }
+>(({ className, size = "default", ...props }, ref) => (
(({ className, ...props }, ref) => (
));
@@ -36,7 +40,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
-
+
));
CardContent.displayName = "CardContent";
@@ -70,7 +81,10 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
));
diff --git a/apps/web/env.ts b/apps/web/env.ts
index 5954715c4a..efa70969c8 100644
--- a/apps/web/env.ts
+++ b/apps/web/env.ts
@@ -190,6 +190,7 @@ export const env = createEnv({
NEXT_PUBLIC_DIGEST_ENABLED: z.coerce.boolean().optional(),
NEXT_PUBLIC_MEETING_BRIEFS_ENABLED: z.coerce.boolean().optional(),
NEXT_PUBLIC_INTEGRATIONS_ENABLED: z.coerce.boolean().optional(),
+ NEXT_PUBLIC_SMART_FILING_ENABLED: z.coerce.boolean().optional(),
NEXT_PUBLIC_IS_RESEND_CONFIGURED: z.coerce.boolean().optional(),
},
// For Next.js >= 13.4.4, you only need to destructure client variables:
@@ -252,6 +253,8 @@ export const env = createEnv({
process.env.NEXT_PUBLIC_MEETING_BRIEFS_ENABLED,
NEXT_PUBLIC_INTEGRATIONS_ENABLED:
process.env.NEXT_PUBLIC_INTEGRATIONS_ENABLED,
+ NEXT_PUBLIC_SMART_FILING_ENABLED:
+ process.env.NEXT_PUBLIC_SMART_FILING_ENABLED,
NEXT_PUBLIC_IS_RESEND_CONFIGURED:
process.env.NEXT_PUBLIC_IS_RESEND_CONFIGURED,
},
diff --git a/apps/web/hooks/useDriveConnections.ts b/apps/web/hooks/useDriveConnections.ts
new file mode 100644
index 0000000000..cf5edeed27
--- /dev/null
+++ b/apps/web/hooks/useDriveConnections.ts
@@ -0,0 +1,6 @@
+import useSWR from "swr";
+import type { GetDriveConnectionsResponse } from "@/app/api/user/drive/connections/route";
+
+export function useDriveConnections() {
+ return useSWR("/api/user/drive/connections");
+}
diff --git a/apps/web/hooks/useDriveFolders.ts b/apps/web/hooks/useDriveFolders.ts
new file mode 100644
index 0000000000..c7a939691a
--- /dev/null
+++ b/apps/web/hooks/useDriveFolders.ts
@@ -0,0 +1,6 @@
+import useSWR from "swr";
+import type { GetDriveFoldersResponse } from "@/app/api/user/drive/folders/route";
+
+export function useDriveFolders() {
+ return useSWR("/api/user/drive/folders");
+}
diff --git a/apps/web/hooks/useDriveSubfolders.ts b/apps/web/hooks/useDriveSubfolders.ts
new file mode 100644
index 0000000000..707fad32e4
--- /dev/null
+++ b/apps/web/hooks/useDriveSubfolders.ts
@@ -0,0 +1,15 @@
+import useSWR from "swr";
+import type {
+ GetSubfoldersQuery,
+ GetSubfoldersResponse,
+} from "@/app/api/user/drive/folders/[folderId]/route";
+
+export function useDriveSubfolders(
+ params: (GetSubfoldersQuery & { folderId: string }) | null,
+) {
+ return useSWR(
+ params
+ ? `/api/user/drive/folders/${params.folderId}?driveConnectionId=${params.driveConnectionId}`
+ : null,
+ );
+}
diff --git a/apps/web/hooks/useFeatureFlags.ts b/apps/web/hooks/useFeatureFlags.ts
index 0434bbf34f..85b521bfdd 100644
--- a/apps/web/hooks/useFeatureFlags.ts
+++ b/apps/web/hooks/useFeatureFlags.ts
@@ -13,7 +13,13 @@ export function useMeetingBriefsEnabled() {
}
export function useIntegrationsEnabled() {
- return env.NEXT_PUBLIC_INTEGRATIONS_ENABLED;
+ const posthogEnabled = useFeatureFlagEnabled("integrations");
+ return posthogEnabled || env.NEXT_PUBLIC_INTEGRATIONS_ENABLED;
+}
+
+export function useSmartFilingEnabled() {
+ const posthogEnabled = useFeatureFlagEnabled("smart-filing");
+ return posthogEnabled || env.NEXT_PUBLIC_SMART_FILING_ENABLED;
}
const HERO_FLAG_NAME = "hero-copy-7";
diff --git a/apps/web/hooks/useFilingActivity.ts b/apps/web/hooks/useFilingActivity.ts
new file mode 100644
index 0000000000..19f354d151
--- /dev/null
+++ b/apps/web/hooks/useFilingActivity.ts
@@ -0,0 +1,10 @@
+import useSWR from "swr";
+import type {
+ GetFilingsResponse,
+ GetFilingsQuery,
+} from "@/app/api/user/drive/filings/route";
+
+export function useFilingActivity({ limit, offset }: GetFilingsQuery) {
+ const url = `/api/user/drive/filings?limit=${limit}&offset=${offset}`;
+ return useSWR(url, { revalidateOnFocus: false });
+}
diff --git a/apps/web/hooks/useFilingPreview.ts b/apps/web/hooks/useFilingPreview.ts
new file mode 100644
index 0000000000..d21bd4b6b9
--- /dev/null
+++ b/apps/web/hooks/useFilingPreview.ts
@@ -0,0 +1,12 @@
+import useSWR from "swr";
+import type { GetFilingPreviewResponse } from "@/app/api/user/drive/preview/route";
+
+export function useFilingPreview(shouldFetch: boolean) {
+ return useSWR(
+ shouldFetch ? "/api/user/drive/preview" : null,
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ },
+ );
+}
diff --git a/apps/web/hooks/useFilingPreviewAttachments.ts b/apps/web/hooks/useFilingPreviewAttachments.ts
new file mode 100644
index 0000000000..f5d74f7af8
--- /dev/null
+++ b/apps/web/hooks/useFilingPreviewAttachments.ts
@@ -0,0 +1,16 @@
+import useSWR, { type SWRConfiguration } from "swr";
+import type { GetAttachmentsPreviewResponse } from "@/app/api/user/drive/preview/attachments/route";
+
+export function useFilingPreviewAttachments(
+ shouldFetch: boolean,
+ options?: SWRConfiguration,
+) {
+ return useSWR(
+ shouldFetch ? "/api/user/drive/preview/attachments" : null,
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ ...options,
+ },
+ );
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index e6dd462d46..d58d80854c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -32,6 +32,7 @@
"@dub/analytics": "0.0.32",
"@formkit/auto-animate": "0.9.0",
"@googleapis/calendar": "^14.0.0",
+ "@googleapis/drive": "20.0.0",
"@googleapis/gmail": "16.1.0",
"@googleapis/people": "6.0.0",
"@headlessui/react": "2.2.9",
@@ -126,6 +127,7 @@
"linkifyjs": "4.3.2",
"lodash": "4.17.21",
"lucide-react": "0.555.0",
+ "mammoth": "1.11.0",
"motion": "12.23.25",
"next": "16.1.1",
"next-axiom": "1.9.3",
@@ -170,6 +172,7 @@
"tiptap-markdown": "0.8.10",
"tldts": "^7.0.19",
"typescript": "5.9.3",
+ "unpdf": "1.4.0",
"use-stick-to-bottom": "1.1.1",
"usehooks-ts": "3.1.1",
"zod": "3.25.46"
diff --git a/apps/web/prisma/migrations/20251221132935_drive/migration.sql b/apps/web/prisma/migrations/20251221132935_drive/migration.sql
new file mode 100644
index 0000000000..a7bdb789e6
--- /dev/null
+++ b/apps/web/prisma/migrations/20251221132935_drive/migration.sql
@@ -0,0 +1,104 @@
+-- CreateEnum
+CREATE TYPE "DocumentFilingStatus" AS ENUM ('PENDING', 'FILED', 'REJECTED', 'ERROR');
+
+-- AlterTable
+ALTER TABLE "EmailAccount" ADD COLUMN "filingEnabled" BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN "filingPrompt" TEXT;
+
+-- CreateTable
+CREATE TABLE "DriveConnection" (
+ "id" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "provider" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "accessToken" TEXT,
+ "refreshToken" TEXT,
+ "expiresAt" TIMESTAMP(3),
+ "isConnected" BOOLEAN NOT NULL DEFAULT true,
+ "emailAccountId" TEXT NOT NULL,
+
+ CONSTRAINT "DriveConnection_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "FilingFolder" (
+ "id" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "folderId" TEXT NOT NULL,
+ "folderName" TEXT NOT NULL,
+ "folderPath" TEXT NOT NULL,
+ "driveConnectionId" TEXT NOT NULL,
+ "emailAccountId" TEXT NOT NULL,
+
+ CONSTRAINT "FilingFolder_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DocumentFiling" (
+ "id" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "messageId" TEXT NOT NULL,
+ "attachmentId" TEXT NOT NULL,
+ "filename" TEXT NOT NULL,
+ "folderId" TEXT NOT NULL,
+ "folderPath" TEXT NOT NULL,
+ "fileId" TEXT,
+ "reasoning" TEXT,
+ "confidence" DOUBLE PRECISION,
+ "status" "DocumentFilingStatus" NOT NULL DEFAULT 'FILED',
+ "wasAsked" BOOLEAN NOT NULL DEFAULT false,
+ "wasCorrected" BOOLEAN NOT NULL DEFAULT false,
+ "originalPath" TEXT,
+ "correctedAt" TIMESTAMP(3),
+ "notificationToken" TEXT NOT NULL,
+ "notificationSentAt" TIMESTAMP(3),
+ "driveConnectionId" TEXT NOT NULL,
+ "emailAccountId" TEXT NOT NULL,
+
+ CONSTRAINT "DocumentFiling_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "DriveConnection_emailAccountId_idx" ON "DriveConnection"("emailAccountId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DriveConnection_emailAccountId_provider_key" ON "DriveConnection"("emailAccountId", "provider");
+
+-- CreateIndex
+CREATE INDEX "FilingFolder_driveConnectionId_idx" ON "FilingFolder"("driveConnectionId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "FilingFolder_emailAccountId_folderId_key" ON "FilingFolder"("emailAccountId", "folderId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DocumentFiling_notificationToken_key" ON "DocumentFiling"("notificationToken");
+
+-- CreateIndex
+CREATE INDEX "DocumentFiling_emailAccountId_status_idx" ON "DocumentFiling"("emailAccountId", "status");
+
+-- CreateIndex
+CREATE INDEX "DocumentFiling_driveConnectionId_idx" ON "DocumentFiling"("driveConnectionId");
+
+-- CreateIndex
+CREATE INDEX "DocumentFiling_messageId_idx" ON "DocumentFiling"("messageId");
+
+-- CreateIndex
+CREATE INDEX "DocumentFiling_notificationToken_idx" ON "DocumentFiling"("notificationToken");
+
+-- AddForeignKey
+ALTER TABLE "DriveConnection" ADD CONSTRAINT "DriveConnection_emailAccountId_fkey" FOREIGN KEY ("emailAccountId") REFERENCES "EmailAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FilingFolder" ADD CONSTRAINT "FilingFolder_driveConnectionId_fkey" FOREIGN KEY ("driveConnectionId") REFERENCES "DriveConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "FilingFolder" ADD CONSTRAINT "FilingFolder_emailAccountId_fkey" FOREIGN KEY ("emailAccountId") REFERENCES "EmailAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DocumentFiling" ADD CONSTRAINT "DocumentFiling_driveConnectionId_fkey" FOREIGN KEY ("driveConnectionId") REFERENCES "DriveConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DocumentFiling" ADD CONSTRAINT "DocumentFiling_emailAccountId_fkey" FOREIGN KEY ("emailAccountId") REFERENCES "EmailAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/web/prisma/migrations/20251222222738_add_filing_preview_support/migration.sql b/apps/web/prisma/migrations/20251222222738_add_filing_preview_support/migration.sql
new file mode 100644
index 0000000000..aba75a8d22
--- /dev/null
+++ b/apps/web/prisma/migrations/20251222222738_add_filing_preview_support/migration.sql
@@ -0,0 +1,8 @@
+-- AlterEnum
+ALTER TYPE "DocumentFilingStatus" ADD VALUE 'PREVIEW';
+
+-- AlterTable
+ALTER TABLE "DocumentFiling" ADD COLUMN "feedbackAt" TIMESTAMP(3),
+ADD COLUMN "feedbackPositive" BOOLEAN,
+ALTER COLUMN "folderId" DROP NOT NULL,
+ALTER COLUMN "notificationToken" DROP NOT NULL;
diff --git a/apps/web/prisma/migrations/20251223000001_rename_notification_token_to_message_id/migration.sql b/apps/web/prisma/migrations/20251223000001_rename_notification_token_to_message_id/migration.sql
new file mode 100644
index 0000000000..4371c1c7ec
--- /dev/null
+++ b/apps/web/prisma/migrations/20251223000001_rename_notification_token_to_message_id/migration.sql
@@ -0,0 +1,7 @@
+-- Rename notificationToken column to notificationMessageId
+ALTER TABLE "DocumentFiling" RENAME COLUMN "notificationToken" TO "notificationMessageId";
+
+-- Rename the index (drop old, create new)
+DROP INDEX IF EXISTS "DocumentFiling_notificationToken_idx";
+CREATE INDEX "DocumentFiling_notificationMessageId_idx" ON "DocumentFiling"("notificationMessageId");
+
diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma
index 846623fb59..6e3538b11a 100644
--- a/apps/web/prisma/schema.prisma
+++ b/apps/web/prisma/schema.prisma
@@ -145,6 +145,9 @@ model EmailAccount {
meetingBriefingsEnabled Boolean @default(false)
meetingBriefingsMinutesBefore Int @default(240) // 4 hours in minutes
+ filingEnabled Boolean @default(false)
+ filingPrompt String?
+
digestSchedule Schedule?
userId String
@@ -176,6 +179,9 @@ model EmailAccount {
calendarConnections CalendarConnection[]
mcpConnections McpConnection[]
meetingBriefings MeetingBriefing[]
+ driveConnections DriveConnection[]
+ documentFilings DocumentFiling[]
+ filingFolders FilingFolder[]
@@index([userId])
@@index([lastSummaryEmailAt])
@@ -961,6 +967,90 @@ model MeetingBriefing {
@@index([emailAccountId])
}
+// Drive connection for document auto-organization (Google Drive or OneDrive/SharePoint)
+// One connection per provider. But we can remove unique constraint in the future if we want
+model DriveConnection {
+ id String @id @default(cuid())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ provider String // "google" or "microsoft"
+ email String // can differ from emailAccount - e.g. connect work Drive to personal email
+ accessToken String?
+ refreshToken String?
+ expiresAt DateTime?
+ isConnected Boolean @default(true)
+
+ emailAccountId String
+ emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade)
+
+ documentFilings DocumentFiling[]
+ filingFolders FilingFolder[]
+
+ @@unique([emailAccountId, provider])
+ @@index([emailAccountId])
+}
+
+model FilingFolder {
+ id String @id @default(cuid())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ folderId String
+ folderName String
+ folderPath String
+
+ driveConnectionId String
+ driveConnection DriveConnection @relation(fields: [driveConnectionId], references: [id], onDelete: Cascade)
+ emailAccountId String
+ emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade)
+
+ @@unique([emailAccountId, folderId])
+ @@index([driveConnectionId])
+}
+
+model DocumentFiling {
+ id String @id @default(cuid())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Source email attachment
+ messageId String
+ attachmentId String
+ filename String
+
+ // Result (after filing or prediction)
+ folderId String? // null if AI suggests creating a new folder
+ folderPath String
+ fileId String?
+ reasoning String?
+ confidence Float?
+
+ status DocumentFilingStatus @default(FILED)
+
+ wasAsked Boolean @default(false)
+ wasCorrected Boolean @default(false)
+ originalPath String?
+ correctedAt DateTime?
+
+ // Feedback (for preview and corrections)
+ feedbackPositive Boolean?
+ feedbackAt DateTime?
+
+ notificationMessageId String? @unique
+ notificationSentAt DateTime?
+
+ driveConnectionId String
+ driveConnection DriveConnection @relation(fields: [driveConnectionId], references: [id], onDelete: Cascade)
+ emailAccountId String
+ emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade)
+
+ @@index([emailAccountId, status])
+ @@index([driveConnectionId])
+ @@index([messageId])
+ @@index([notificationMessageId])
+}
+
model Referral {
id String @id @default(cuid())
createdAt DateTime @default(now())
@@ -1087,6 +1177,14 @@ enum ColdEmailStatus {
USER_REJECTED_COLD
}
+enum DocumentFilingStatus {
+ PENDING // Waiting for user input
+ FILED // Document is filed (auto or after user confirmation)
+ REJECTED // User said skip
+ ERROR // Filing failed
+ PREVIEW // Preview feedback only (not filed)
+}
+
// @deprecated - No longer used
enum ColdEmailSetting {
DISABLED
diff --git a/apps/web/utils/actions/drive.ts b/apps/web/utils/actions/drive.ts
new file mode 100644
index 0000000000..a9917d89e0
--- /dev/null
+++ b/apps/web/utils/actions/drive.ts
@@ -0,0 +1,334 @@
+"use server";
+
+import { actionClient } from "@/utils/actions/safe-action";
+import {
+ disconnectDriveBody,
+ updateFilingPromptBody,
+ updateFilingEnabledBody,
+ addFilingFolderBody,
+ removeFilingFolderBody,
+ submitPreviewFeedbackBody,
+ moveFilingBody,
+ createDriveFolderBody,
+ fileAttachmentBody,
+} from "@/utils/actions/drive.validation";
+import prisma from "@/utils/prisma";
+import { SafeError } from "@/utils/error";
+import { createDriveProviderWithRefresh } from "@/utils/drive/provider";
+import { createEmailProvider } from "@/utils/email/provider";
+import {
+ getExtractableAttachments,
+ processAttachment,
+} from "@/utils/drive/filing-engine";
+import type { DriveProviderType } from "@/utils/drive/types";
+
+export const disconnectDriveAction = actionClient
+ .metadata({ name: "disconnectDrive" })
+ .inputSchema(disconnectDriveBody)
+ .action(
+ async ({ ctx: { emailAccountId }, parsedInput: { connectionId } }) => {
+ const connection = await prisma.driveConnection.findUnique({
+ where: {
+ id: connectionId,
+ emailAccountId,
+ },
+ });
+
+ if (!connection) {
+ throw new SafeError("Drive connection not found");
+ }
+
+ await prisma.driveConnection.delete({
+ where: { id: connectionId, emailAccountId },
+ });
+ },
+ );
+
+export const updateFilingPromptAction = actionClient
+ .metadata({ name: "updateFilingPrompt" })
+ .inputSchema(updateFilingPromptBody)
+ .action(
+ async ({ ctx: { emailAccountId }, parsedInput: { filingPrompt } }) => {
+ await prisma.emailAccount.update({
+ where: { id: emailAccountId },
+ data: {
+ filingPrompt: filingPrompt || null,
+ },
+ });
+ },
+ );
+
+export const updateFilingEnabledAction = actionClient
+ .metadata({ name: "updateFilingEnabled" })
+ .inputSchema(updateFilingEnabledBody)
+ .action(
+ async ({ ctx: { emailAccountId }, parsedInput: { filingEnabled } }) => {
+ await prisma.emailAccount.update({
+ where: { id: emailAccountId },
+ data: { filingEnabled },
+ });
+ },
+ );
+
+export const addFilingFolderAction = actionClient
+ .metadata({ name: "addFilingFolder" })
+ .inputSchema(addFilingFolderBody)
+ .action(
+ async ({
+ ctx: { emailAccountId },
+ parsedInput: { folderId, folderName, folderPath, driveConnectionId },
+ }) => {
+ const connection = await prisma.driveConnection.findUnique({
+ where: {
+ id: driveConnectionId,
+ emailAccountId,
+ },
+ });
+
+ if (!connection) {
+ throw new SafeError("Drive connection not found");
+ }
+
+ const data = {
+ folderName,
+ folderPath,
+ driveConnectionId,
+ };
+
+ const folder = await prisma.filingFolder.upsert({
+ where: {
+ emailAccountId_folderId: {
+ emailAccountId,
+ folderId,
+ },
+ },
+ create: {
+ ...data,
+ folderId,
+ emailAccountId,
+ },
+ update: data,
+ });
+
+ return folder;
+ },
+ );
+
+export const removeFilingFolderAction = actionClient
+ .metadata({ name: "removeFilingFolder" })
+ .inputSchema(removeFilingFolderBody)
+ .action(async ({ ctx: { emailAccountId }, parsedInput: { folderId } }) => {
+ await prisma.filingFolder.deleteMany({
+ where: { emailAccountId, folderId },
+ });
+ });
+
+export const submitPreviewFeedbackAction = actionClient
+ .metadata({ name: "submitPreviewFeedback" })
+ .inputSchema(submitPreviewFeedbackBody)
+ .action(
+ async ({
+ ctx: { emailAccountId },
+ parsedInput: { filingId, feedbackPositive },
+ }) => {
+ await prisma.documentFiling.update({
+ where: { id: filingId, emailAccountId },
+ data: {
+ feedbackPositive,
+ feedbackAt: new Date(),
+ },
+ });
+ },
+ );
+
+export const moveFilingAction = actionClient
+ .metadata({ name: "moveFiling" })
+ .inputSchema(moveFilingBody)
+ .action(
+ async ({
+ ctx: { emailAccountId, logger },
+ parsedInput: { filingId, targetFolderId, targetFolderPath },
+ }) => {
+ const filing = await prisma.documentFiling.findUnique({
+ where: { id: filingId, emailAccountId },
+ select: { fileId: true, folderPath: true, driveConnection: true },
+ });
+
+ if (!filing) {
+ throw new SafeError("Filing not found");
+ }
+
+ if (!filing.fileId) {
+ throw new SafeError("Filing has no associated file");
+ }
+
+ const driveProvider = await createDriveProviderWithRefresh(
+ filing.driveConnection,
+ logger,
+ );
+
+ await driveProvider.moveFile(filing.fileId, targetFolderId);
+
+ await prisma.documentFiling.update({
+ where: { id: filingId },
+ data: {
+ folderId: targetFolderId,
+ folderPath: targetFolderPath,
+ originalPath: filing.folderPath,
+ wasCorrected: true,
+ feedbackPositive: false,
+ feedbackAt: new Date(),
+ },
+ });
+ },
+ );
+
+export const createDriveFolderAction = actionClient
+ .metadata({ name: "createDriveFolder" })
+ .inputSchema(createDriveFolderBody)
+ .action(
+ async ({
+ ctx: { emailAccountId, logger },
+ parsedInput: { folderName, driveConnectionId },
+ }) => {
+ const connection = await prisma.driveConnection.findUnique({
+ where: {
+ id: driveConnectionId,
+ emailAccountId,
+ },
+ });
+
+ if (!connection) {
+ logger.error("Drive connection not found", { driveConnectionId });
+ throw new SafeError("Drive connection not found");
+ }
+
+ const driveProvider = await createDriveProviderWithRefresh(
+ connection,
+ logger,
+ );
+
+ const folder = await driveProvider.createFolder(folderName);
+
+ return folder;
+ },
+ );
+
+export type FileAttachmentFiled = {
+ filingId: string;
+ filename: string;
+ folderPath: string;
+ fileId: string | null;
+ filedAt: string;
+ provider: DriveProviderType;
+ skipped?: false;
+};
+
+export type FileAttachmentResult =
+ | FileAttachmentFiled
+ | {
+ skipped: true;
+ skipReason: string;
+ };
+
+export const fileAttachmentAction = actionClient
+ .metadata({ name: "fileAttachment" })
+ .inputSchema(fileAttachmentBody)
+ .action(
+ async ({
+ ctx: { emailAccountId, provider, logger },
+ parsedInput: { messageId, filename },
+ }): Promise => {
+ const emailAccount = await prisma.emailAccount.findUnique({
+ where: { id: emailAccountId },
+ select: {
+ id: true,
+ userId: true,
+ email: true,
+ about: true,
+ multiRuleSelectionEnabled: true,
+ timezone: true,
+ calendarBookingLink: true,
+ filingEnabled: true,
+ filingPrompt: true,
+ user: {
+ select: {
+ aiProvider: true,
+ aiModel: true,
+ aiApiKey: true,
+ },
+ },
+ account: {
+ select: {
+ provider: true,
+ },
+ },
+ },
+ });
+
+ if (!emailAccount) {
+ throw new SafeError("Email account not found");
+ }
+
+ if (!emailAccount.filingPrompt) {
+ throw new SafeError("Filing prompt not configured");
+ }
+
+ const emailProvider = await createEmailProvider({
+ emailAccountId,
+ provider,
+ logger,
+ });
+
+ logger.info("Fetching message for filing", { messageId });
+ const message = await emailProvider.getMessage(messageId);
+
+ if (!message) {
+ throw new SafeError("Message not found");
+ }
+
+ const extractableAttachments = getExtractableAttachments(message);
+ const attachment = extractableAttachments.find(
+ (a) => a.filename === filename,
+ );
+
+ if (!attachment) {
+ throw new SafeError("Attachment not found or not extractable");
+ }
+
+ logger.info("Processing attachment", { filename: attachment.filename });
+ const result = await processAttachment({
+ emailAccount: {
+ ...emailAccount,
+ filingEnabled: true,
+ filingPrompt: emailAccount.filingPrompt,
+ },
+ message,
+ attachment,
+ emailProvider,
+ logger,
+ sendNotification: false,
+ });
+
+ if (result.skipped) {
+ return {
+ skipped: true,
+ skipReason:
+ result.skipReason || "Document doesn't match filing preferences",
+ };
+ }
+
+ if (!result.success || !result.filing) {
+ throw new SafeError(result.error || "Failed to file attachment");
+ }
+
+ return {
+ filingId: result.filing.id,
+ filename: result.filing.filename,
+ folderPath: result.filing.folderPath,
+ fileId: result.filing.fileId,
+ filedAt: new Date().toISOString(),
+ provider: result.filing.provider as DriveProviderType,
+ };
+ },
+ );
diff --git a/apps/web/utils/actions/drive.validation.ts b/apps/web/utils/actions/drive.validation.ts
new file mode 100644
index 0000000000..55333936e3
--- /dev/null
+++ b/apps/web/utils/actions/drive.validation.ts
@@ -0,0 +1,63 @@
+import { z } from "zod";
+
+export const disconnectDriveBody = z.object({
+ connectionId: z.string(),
+});
+export type DisconnectDriveBody = z.infer;
+
+export const updateFilingPromptBody = z.object({
+ filingPrompt: z.string().optional().nullable(),
+});
+export type UpdateFilingPromptBody = z.infer;
+
+export const updateFilingEnabledBody = z.object({
+ filingEnabled: z.boolean(),
+});
+export type UpdateFilingEnabledBody = z.infer;
+
+const filingFolderSchema = z.object({
+ folderId: z.string(),
+ folderName: z.string(),
+ folderPath: z.string(),
+ driveConnectionId: z.string(),
+});
+
+export const updateFilingFoldersBody = z.object({
+ folders: z.array(filingFolderSchema),
+});
+export type UpdateFilingFoldersBody = z.infer;
+
+export const addFilingFolderBody = filingFolderSchema;
+export type AddFilingFolderBody = z.infer;
+
+export const removeFilingFolderBody = z.object({
+ folderId: z.string(),
+});
+export type RemoveFilingFolderBody = z.infer;
+
+export const submitPreviewFeedbackBody = z.object({
+ filingId: z.string(),
+ feedbackPositive: z.boolean(),
+});
+export type SubmitPreviewFeedbackBody = z.infer<
+ typeof submitPreviewFeedbackBody
+>;
+
+export const moveFilingBody = z.object({
+ filingId: z.string(),
+ targetFolderId: z.string(),
+ targetFolderPath: z.string(),
+});
+export type MoveFilingBody = z.infer;
+
+export const createDriveFolderBody = z.object({
+ folderName: z.string().min(1, "Folder name is required"),
+ driveConnectionId: z.string(),
+});
+export type CreateDriveFolderBody = z.infer;
+
+export const fileAttachmentBody = z.object({
+ messageId: z.string(),
+ filename: z.string(),
+});
+export type FileAttachmentBody = z.infer;
diff --git a/apps/web/utils/ai/document-filing/analyze-document.ts b/apps/web/utils/ai/document-filing/analyze-document.ts
new file mode 100644
index 0000000000..3b0ea2cdb7
--- /dev/null
+++ b/apps/web/utils/ai/document-filing/analyze-document.ts
@@ -0,0 +1,163 @@
+import { z } from "zod";
+import type { EmailAccountWithAI } from "@/utils/llms/types";
+import { getModel } from "@/utils/llms/model";
+import { createGenerateObject } from "@/utils/llms";
+import { cleanExtractedText } from "@/utils/drive/document-extraction";
+
+const documentAnalysisSchema = z
+ .object({
+ action: z
+ .enum(["use_existing", "create_new", "skip"])
+ .describe(
+ "Whether to use an existing folder, create a new one, or skip this document.",
+ ),
+ folderId: z
+ .string()
+ .optional()
+ .describe(
+ "Required if action is 'use_existing'. The ID of the existing folder from the provided list.",
+ ),
+ folderPath: z
+ .string()
+ .optional()
+ .describe(
+ "Required if action is 'create_new'. The path for the new folder to create.",
+ ),
+ confidence: z
+ .number()
+ .min(0)
+ .max(1)
+ .describe(
+ "Confidence score from 0 to 1. Use 0.9+ only when very certain.",
+ ),
+ reasoning: z
+ .string()
+ .describe(
+ "Brief explanation for why this folder was chosen or why the document was skipped.",
+ ),
+ })
+ .refine(
+ (data) => {
+ if (data.action === "use_existing") return !!data.folderId;
+ if (data.action === "create_new") return !!data.folderPath;
+ return true;
+ },
+ {
+ message:
+ "folderId required for 'use_existing', folderPath required for 'create_new'",
+ },
+ );
+export type DocumentAnalysisResult = z.infer;
+
+type EmailContext = { subject: string; sender: string };
+type AttachmentContext = { filename: string; content: string };
+type DriveFolder = {
+ id: string;
+ name: string;
+ path: string;
+ driveProvider: string;
+};
+
+export async function analyzeDocument({
+ emailAccount,
+ email,
+ attachment,
+ folders,
+}: {
+ emailAccount: EmailAccountWithAI & { filingPrompt: string };
+ email: EmailContext;
+ attachment: AttachmentContext;
+ folders: DriveFolder[];
+}): Promise {
+ const modelOptions = getModel(emailAccount.user, "economy");
+
+ const generateObject = createGenerateObject({
+ emailAccount,
+ label: "Document filing",
+ modelOptions,
+ });
+
+ const result = await generateObject({
+ ...modelOptions,
+ system: buildSystem(emailAccount.filingPrompt),
+ prompt: buildPrompt({ email, attachment, folders }),
+ schema: documentAnalysisSchema,
+ });
+
+ return result.object;
+}
+
+function buildSystem(filingPrompt: string): string {
+ return `You are a document filing assistant. Your job is to decide where to file documents based on the user's preferences.
+
+
+${filingPrompt}
+
+
+
+Your response must be in valid JSON format.
+
+
+Choose one of:
+1. action: "use_existing" + folderId - Use an existing folder from the list (requires folder ID)
+2. action: "create_new" + folderPath - Create a new folder ONLY if:
+ - The document clearly matches the user's preferences, AND
+ - No existing folder fits, AND
+ - The new folder makes sense for the user's stated filing goals
+3. action: "skip" - Skip this document if:
+ - It doesn't match the user's filing preferences
+ - It's unrelated to what the user wants to organize
+ - You're unsure whether it fits
+
+Examples:
+- User wants "file receipts" → Receipt PDF arrives → File it
+- User wants "file receipts" → CV PDF arrives → SKIP (not a receipt)
+- User wants "organize invoices by vendor" → Invoice arrives but no vendor folder exists → Create new folder for that vendor
+
+Prefer existing folders. Only create folders that align with user preferences. When in doubt, skip.
+Be conservative with confidence scores - only use 0.9+ when very certain.`;
+}
+
+function buildPrompt({
+ email,
+ attachment,
+ folders,
+}: {
+ email: EmailContext;
+ attachment: AttachmentContext;
+ folders: DriveFolder[];
+}): string {
+ const cleanedText = cleanExtractedText(attachment.content);
+ const truncatedText =
+ cleanedText.length > 8000
+ ? `${cleanedText.slice(0, 8000)}\n\n[... document truncated ...]`
+ : cleanedText;
+
+ const foldersText =
+ folders.length > 0
+ ? folders
+ .map(
+ (f) =>
+ ` `,
+ )
+ .join("\n")
+ : "No existing folders found.";
+
+ return `Decide where to file this document:
+
+
+${attachment.filename}
+${email.subject}
+${email.sender}
+
+
+
+${truncatedText}
+
+
+
+${foldersText}
+
+
+Based on the user's filing preferences and the document content, decide where this document should be filed.`;
+}
diff --git a/apps/web/utils/calendar/handle-calendar-callback.ts b/apps/web/utils/calendar/handle-calendar-callback.ts
index 89dbb41f33..ae48acedb4 100644
--- a/apps/web/utils/calendar/handle-calendar-callback.ts
+++ b/apps/web/utils/calendar/handle-calendar-callback.ts
@@ -6,13 +6,15 @@ import {
validateOAuthCallback,
parseAndValidateCalendarState,
buildCalendarRedirectUrl,
- verifyEmailAccountAccess,
checkExistingConnection,
createCalendarConnection,
+} from "./oauth-callback-helpers";
+import {
+ RedirectError,
redirectWithMessage,
redirectWithError,
- RedirectError,
-} from "./oauth-callback-helpers";
+} from "@/utils/oauth/redirect";
+import { verifyEmailAccountAccess } from "@/utils/oauth/verify";
import {
acquireOAuthCodeLock,
getOAuthCodeResult,
diff --git a/apps/web/utils/calendar/oauth-callback-helpers.ts b/apps/web/utils/calendar/oauth-callback-helpers.ts
index a8de76f939..407c9f567b 100644
--- a/apps/web/utils/calendar/oauth-callback-helpers.ts
+++ b/apps/web/utils/calendar/oauth-callback-helpers.ts
@@ -4,7 +4,6 @@ import { z } from "zod";
import prisma from "@/utils/prisma";
import { CALENDAR_STATE_COOKIE_NAME } from "@/utils/calendar/constants";
import { parseOAuthState } from "@/utils/oauth/state";
-import { auth } from "@/utils/auth";
import { prefixPath } from "@/utils/path";
import { env } from "@/env";
import type { Logger } from "@/utils/logger";
@@ -13,6 +12,8 @@ import type {
CalendarOAuthState,
} from "./oauth-types";
+import { RedirectError } from "@/utils/oauth/redirect";
+
const calendarOAuthStateSchema = z.object({
emailAccountId: z.string().min(1).max(64),
type: z.literal("calendar"),
@@ -94,40 +95,6 @@ export function buildCalendarRedirectUrl(emailAccountId: string): URL {
);
}
-/**
- * Verify user owns the email account
- */
-export async function verifyEmailAccountAccess(
- emailAccountId: string,
- logger: Logger,
- redirectUrl: URL,
- responseHeaders: Headers,
-): Promise {
- const session = await auth();
- if (!session?.user?.id) {
- logger.warn("Unauthorized calendar callback - no session");
- redirectUrl.searchParams.set("error", "unauthorized");
- throw new RedirectError(redirectUrl, responseHeaders);
- }
-
- const emailAccount = await prisma.emailAccount.findFirst({
- where: {
- id: emailAccountId,
- userId: session.user.id,
- },
- select: { id: true },
- });
-
- if (!emailAccount) {
- logger.warn("Unauthorized calendar callback - invalid email account", {
- emailAccountId,
- userId: session.user.id,
- });
- redirectUrl.searchParams.set("error", "forbidden");
- throw new RedirectError(redirectUrl, responseHeaders);
- }
-}
-
/**
* Check if calendar connection already exists
*/
@@ -168,42 +135,3 @@ export async function createCalendarConnection(params: {
},
});
}
-
-/**
- * Redirect with success message
- */
-export function redirectWithMessage(
- redirectUrl: URL,
- message: string,
- responseHeaders: Headers,
-): NextResponse {
- redirectUrl.searchParams.set("message", message);
- return NextResponse.redirect(redirectUrl, { headers: responseHeaders });
-}
-
-/**
- * Redirect with error message
- */
-export function redirectWithError(
- redirectUrl: URL,
- error: string,
- responseHeaders: Headers,
-): NextResponse {
- redirectUrl.searchParams.set("error", error);
- return NextResponse.redirect(redirectUrl, { headers: responseHeaders });
-}
-
-/**
- * Custom error class for redirect responses
- */
-export class RedirectError extends Error {
- redirectUrl: URL;
- responseHeaders: Headers;
-
- constructor(redirectUrl: URL, responseHeaders: Headers) {
- super("Redirect required");
- this.name = "RedirectError";
- this.redirectUrl = redirectUrl;
- this.responseHeaders = responseHeaders;
- }
-}
diff --git a/apps/web/utils/drive/client.ts b/apps/web/utils/drive/client.ts
new file mode 100644
index 0000000000..228e5f5ce6
--- /dev/null
+++ b/apps/web/utils/drive/client.ts
@@ -0,0 +1,154 @@
+import { auth } from "@googleapis/drive";
+import { env } from "@/env";
+import { GOOGLE_DRIVE_SCOPES, MICROSOFT_DRIVE_SCOPES } from "./scopes";
+
+// ============================================================================
+// Google Drive OAuth
+// ============================================================================
+
+/**
+ * Creates an OAuth2 client for Google Drive authentication
+ */
+export function getGoogleDriveOAuth2Client() {
+ return new auth.OAuth2({
+ clientId: env.GOOGLE_CLIENT_ID,
+ clientSecret: env.GOOGLE_CLIENT_SECRET,
+ redirectUri: `${env.NEXT_PUBLIC_BASE_URL}/api/google/drive/callback`,
+ });
+}
+
+/**
+ * Generates the OAuth2 URL for Google Drive
+ */
+export function getGoogleDriveOAuth2Url(state: string): string {
+ const oauth2Client = getGoogleDriveOAuth2Client();
+ return oauth2Client.generateAuthUrl({
+ access_type: "offline",
+ scope: [...GOOGLE_DRIVE_SCOPES],
+ state,
+ prompt: "consent",
+ });
+}
+
+/**
+ * Exchange Google OAuth code for tokens
+ */
+export async function exchangeGoogleDriveCode(code: string) {
+ const oauth2Client = getGoogleDriveOAuth2Client();
+ const { tokens } = await oauth2Client.getToken(code);
+
+ if (!tokens.access_token || !tokens.refresh_token) {
+ throw new Error("No access or refresh token returned from Google");
+ }
+
+ // Get user email from ID token
+ if (!tokens.id_token) {
+ throw new Error("No ID token returned from Google");
+ }
+
+ const ticket = await oauth2Client.verifyIdToken({
+ idToken: tokens.id_token,
+ audience: env.GOOGLE_CLIENT_ID,
+ });
+ const payload = ticket.getPayload();
+
+ if (!payload?.email) {
+ throw new Error("Could not get email from Google ID token");
+ }
+
+ return {
+ accessToken: tokens.access_token,
+ refreshToken: tokens.refresh_token,
+ expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null,
+ email: payload.email,
+ };
+}
+
+// ============================================================================
+// Microsoft OneDrive OAuth
+// ============================================================================
+
+/**
+ * Generates the OAuth2 URL for Microsoft OneDrive/SharePoint
+ */
+export function getMicrosoftDriveOAuth2Url(state: string): string {
+ if (!env.MICROSOFT_CLIENT_ID) {
+ throw new Error("Microsoft login not enabled - missing client ID");
+ }
+
+ const baseUrl = `https://login.microsoftonline.com/${env.MICROSOFT_TENANT_ID}/oauth2/v2.0/authorize`;
+ const params = new URLSearchParams({
+ client_id: env.MICROSOFT_CLIENT_ID,
+ response_type: "code",
+ redirect_uri: `${env.NEXT_PUBLIC_BASE_URL}/api/outlook/drive/callback`,
+ scope: MICROSOFT_DRIVE_SCOPES.join(" "),
+ state,
+ });
+
+ return `${baseUrl}?${params.toString()}`;
+}
+
+/**
+ * Exchange Microsoft OAuth code for tokens
+ */
+export async function exchangeMicrosoftDriveCode(code: string) {
+ if (!env.MICROSOFT_CLIENT_ID || !env.MICROSOFT_CLIENT_SECRET) {
+ throw new Error("Microsoft login not enabled - missing credentials");
+ }
+
+ const response = await fetch(
+ `https://login.microsoftonline.com/${env.MICROSOFT_TENANT_ID}/oauth2/v2.0/token`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ client_id: env.MICROSOFT_CLIENT_ID,
+ client_secret: env.MICROSOFT_CLIENT_SECRET,
+ code,
+ redirect_uri: `${env.NEXT_PUBLIC_BASE_URL}/api/outlook/drive/callback`,
+ grant_type: "authorization_code",
+ scope: MICROSOFT_DRIVE_SCOPES.join(" "),
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ const errorBody = await response.json().catch(() => ({}));
+ throw new Error(errorBody.error_description || "Failed to exchange code");
+ }
+
+ const tokens = await response.json();
+
+ if (!tokens.access_token || !tokens.refresh_token) {
+ throw new Error("No access or refresh token returned from Microsoft");
+ }
+
+ // Get user email from Microsoft Graph
+ const profileResponse = await fetch("https://graph.microsoft.com/v1.0/me", {
+ headers: {
+ Authorization: `Bearer ${tokens.access_token}`,
+ },
+ });
+
+ if (!profileResponse.ok) {
+ throw new Error("Failed to get user profile from Microsoft");
+ }
+
+ const profile = await profileResponse.json();
+ const email = profile.mail || profile.userPrincipalName;
+
+ if (!email) {
+ throw new Error("Could not get email from Microsoft profile");
+ }
+
+ return {
+ accessToken: tokens.access_token as string,
+ refreshToken: tokens.refresh_token as string,
+ expiresAt: tokens.expires_in
+ ? new Date(Date.now() + tokens.expires_in * 1000)
+ : null,
+ email: email as string,
+ };
+}
diff --git a/apps/web/utils/drive/constants.ts b/apps/web/utils/drive/constants.ts
new file mode 100644
index 0000000000..9f3ddf5136
--- /dev/null
+++ b/apps/web/utils/drive/constants.ts
@@ -0,0 +1 @@
+export const DRIVE_STATE_COOKIE_NAME = "drive_state";
diff --git a/apps/web/utils/drive/document-extraction.test.ts b/apps/web/utils/drive/document-extraction.test.ts
new file mode 100644
index 0000000000..3ea523c967
--- /dev/null
+++ b/apps/web/utils/drive/document-extraction.test.ts
@@ -0,0 +1,127 @@
+import { describe, it, expect } from "vitest";
+import {
+ isExtractableMimeType,
+ canUseNativePdfSupport,
+ getDocumentPreview,
+ cleanExtractedText,
+} from "./document-extraction";
+
+describe("isExtractableMimeType", () => {
+ it("should return true for PDF", () => {
+ expect(isExtractableMimeType("application/pdf")).toBe(true);
+ });
+
+ it("should return true for DOCX", () => {
+ expect(
+ isExtractableMimeType(
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ ),
+ ).toBe(true);
+ });
+
+ it("should return true for plain text", () => {
+ expect(isExtractableMimeType("text/plain")).toBe(true);
+ });
+
+ it("should return false for unsupported types", () => {
+ expect(isExtractableMimeType("image/png")).toBe(false);
+ expect(isExtractableMimeType("application/json")).toBe(false);
+ expect(isExtractableMimeType("video/mp4")).toBe(false);
+ expect(isExtractableMimeType("application/msword")).toBe(false); // .doc not supported
+ });
+
+ it("should return false for empty string", () => {
+ expect(isExtractableMimeType("")).toBe(false);
+ });
+});
+
+describe("canUseNativePdfSupport", () => {
+ it("should return true for small PDF under limits", () => {
+ const smallBuffer = Buffer.alloc(1024); // 1KB
+ expect(canUseNativePdfSupport(smallBuffer, 10)).toBe(true);
+ });
+
+ it("should return true when pageCount is undefined", () => {
+ const smallBuffer = Buffer.alloc(1024);
+ expect(canUseNativePdfSupport(smallBuffer)).toBe(true);
+ });
+
+ it("should return false for PDF over 32MB", () => {
+ const largeBuffer = Buffer.alloc(33 * 1024 * 1024); // 33MB
+ expect(canUseNativePdfSupport(largeBuffer, 10)).toBe(false);
+ });
+
+ it("should return false for PDF over 100 pages", () => {
+ const smallBuffer = Buffer.alloc(1024);
+ expect(canUseNativePdfSupport(smallBuffer, 101)).toBe(false);
+ });
+
+ it("should return true at exactly 100 pages", () => {
+ const smallBuffer = Buffer.alloc(1024);
+ expect(canUseNativePdfSupport(smallBuffer, 100)).toBe(true);
+ });
+
+ it("should return false when both limits exceeded", () => {
+ const largeBuffer = Buffer.alloc(33 * 1024 * 1024);
+ expect(canUseNativePdfSupport(largeBuffer, 150)).toBe(false);
+ });
+});
+
+describe("getDocumentPreview", () => {
+ it("should return full text if under limit", () => {
+ expect(getDocumentPreview("Hello world", 200)).toBe("Hello world");
+ });
+
+ it("should truncate and add ellipsis if over limit", () => {
+ const text = "a".repeat(300);
+ const preview = getDocumentPreview(text, 200);
+ expect(preview).toBe(`${"a".repeat(200)}...`);
+ expect(preview.length).toBe(203);
+ });
+
+ it("should use default length of 200", () => {
+ const text = "a".repeat(300);
+ const preview = getDocumentPreview(text);
+ expect(preview).toBe(`${"a".repeat(200)}...`);
+ });
+
+ it("should return exact text at limit", () => {
+ const text = "a".repeat(200);
+ expect(getDocumentPreview(text, 200)).toBe(text);
+ });
+
+ it("should handle empty string", () => {
+ expect(getDocumentPreview("")).toBe("");
+ });
+});
+
+describe("cleanExtractedText", () => {
+ it("should normalize CRLF to LF", () => {
+ expect(cleanExtractedText("line1\r\nline2")).toBe("line1\nline2");
+ });
+
+ it("should collapse multiple newlines to max 2", () => {
+ expect(cleanExtractedText("line1\n\n\n\nline2")).toBe("line1\n\nline2");
+ });
+
+ it("should collapse horizontal whitespace", () => {
+ expect(cleanExtractedText("word1 word2\t\tword3")).toBe(
+ "word1 word2 word3",
+ );
+ });
+
+ it("should trim leading and trailing whitespace", () => {
+ expect(cleanExtractedText(" hello world ")).toBe("hello world");
+ });
+
+ it("should handle combined cases", () => {
+ // Note: the function collapses whitespace but doesn't trim line endings
+ const input = " line1\r\n\r\n\r\nline2 word \n\n\nline3 ";
+ const expected = "line1\n\nline2 word \n\nline3";
+ expect(cleanExtractedText(input)).toBe(expected);
+ });
+
+ it("should handle empty string", () => {
+ expect(cleanExtractedText("")).toBe("");
+ });
+});
diff --git a/apps/web/utils/drive/document-extraction.ts b/apps/web/utils/drive/document-extraction.ts
new file mode 100644
index 0000000000..bec3bf8d30
--- /dev/null
+++ b/apps/web/utils/drive/document-extraction.ts
@@ -0,0 +1,240 @@
+/**
+ * Document text extraction utilities for PDF and DOCX files.
+ *
+ * Used to extract text content from email attachments before
+ * sending to AI for document classification.
+ *
+ * Uses `unpdf` for PDF extraction - serverless/edge compatible.
+ * Uses `mammoth` for DOCX extraction.
+ *
+ * Architecture note (from CRE document research):
+ * - Hybrid approach (OCR/extraction → LLM reasoning) outperforms vision-only
+ * - For small PDFs (<10 pages), consider using Claude's native PDF support
+ */
+
+import type { Logger } from "@/utils/logger";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface ExtractionResult {
+ text: string;
+ pageCount?: number;
+ truncated: boolean;
+}
+
+export interface ExtractionOptions {
+ /** Maximum characters to extract (default: 10000) */
+ maxLength?: number;
+ /** Maximum pages to process for PDFs (default: 50) */
+ maxPages?: number;
+ /** Logger for debugging */
+ logger?: Logger;
+}
+
+// Supported MIME types for extraction
+export const EXTRACTABLE_MIME_TYPES = [
+ "application/pdf",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
+ "text/plain",
+] as const;
+
+export type ExtractableMimeType = (typeof EXTRACTABLE_MIME_TYPES)[number];
+
+// ============================================================================
+// Main Extraction Function
+// ============================================================================
+
+/**
+ * Extract text from a document buffer based on MIME type.
+ * Returns null if the MIME type is not supported.
+ */
+export async function extractTextFromDocument(
+ buffer: Buffer,
+ mimeType: string,
+ options: ExtractionOptions = {},
+): Promise {
+ const { maxLength = 10_000, maxPages = 50, logger } = options;
+
+ try {
+ switch (mimeType) {
+ case "application/pdf":
+ return await extractFromPdf(buffer, maxLength, maxPages, logger);
+
+ case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
+ return await extractFromDocx(buffer, maxLength, logger);
+
+ case "text/plain":
+ return extractFromPlainText(buffer, maxLength);
+
+ default:
+ logger?.info("Unsupported MIME type for extraction", { mimeType });
+ return null;
+ }
+ } catch (error) {
+ logger?.error("Error extracting text from document", { error, mimeType });
+ return null;
+ }
+}
+
+/**
+ * Check if a MIME type is supported for extraction.
+ */
+export function isExtractableMimeType(mimeType: string): boolean {
+ return EXTRACTABLE_MIME_TYPES.includes(mimeType as ExtractableMimeType);
+}
+
+/**
+ * Check if a PDF is small enough for Claude's native PDF support.
+ * Claude can process PDFs natively up to 100 pages / 32MB.
+ * For small documents, this can be more accurate than text extraction.
+ */
+export function canUseNativePdfSupport(
+ buffer: Buffer,
+ pageCount?: number,
+): boolean {
+ const MAX_SIZE_MB = 32;
+ const MAX_PAGES = 100;
+
+ const sizeOk = buffer.length < MAX_SIZE_MB * 1024 * 1024;
+ const pagesOk = !pageCount || pageCount <= MAX_PAGES;
+
+ return sizeOk && pagesOk;
+}
+
+// ============================================================================
+// PDF Extraction (using unpdf - serverless compatible)
+// ============================================================================
+
+async function extractFromPdf(
+ buffer: Buffer,
+ maxLength: number,
+ maxPages: number,
+ logger?: Logger,
+): Promise {
+ const { getDocumentProxy } = await import("unpdf");
+
+ const pdf = await getDocumentProxy(new Uint8Array(buffer));
+
+ try {
+ const pageCount = pdf.numPages;
+ const pagesToProcess = Math.min(pageCount, maxPages);
+
+ const textParts: string[] = [];
+ let totalLength = 0;
+ let truncated = false;
+
+ for (let i = 1; i <= pagesToProcess && !truncated; i++) {
+ const page = await pdf.getPage(i);
+ const textContent = await page.getTextContent();
+
+ // Extract text items and join them
+ const pageText = (textContent.items as Array<{ str?: string }>)
+ .map((item) => item.str ?? "")
+ .join(" ");
+
+ if (totalLength + pageText.length > maxLength) {
+ // Truncate to fit within maxLength
+ const remaining = maxLength - totalLength;
+ textParts.push(pageText.slice(0, remaining));
+ truncated = true;
+ } else {
+ textParts.push(pageText);
+ totalLength += pageText.length;
+ }
+ }
+
+ // Check if we hit page limit
+ if (pagesToProcess < pageCount) {
+ truncated = true;
+ }
+
+ const text = textParts.join("\n\n");
+
+ logger?.info("PDF extraction complete", {
+ pageCount,
+ pagesProcessed: pagesToProcess,
+ textLength: text.length,
+ truncated,
+ });
+
+ return {
+ text,
+ pageCount,
+ truncated,
+ };
+ } finally {
+ // Clean up PDF resources
+ pdf.cleanup?.();
+ }
+}
+
+// ============================================================================
+// DOCX Extraction
+// ============================================================================
+
+async function extractFromDocx(
+ buffer: Buffer,
+ maxLength: number,
+ logger?: Logger,
+): Promise {
+ // Dynamic import to avoid loading the library if not needed
+ const mammoth = await import("mammoth");
+
+ const result = await mammoth.extractRawText({ buffer });
+ const text = result.value || "";
+ const truncated = text.length > maxLength;
+
+ logger?.info("DOCX extraction complete", {
+ textLength: text.length,
+ truncated,
+ messageCount: result.messages?.length ?? 0,
+ });
+
+ return {
+ text: truncated ? text.slice(0, maxLength) : text,
+ truncated,
+ };
+}
+
+// ============================================================================
+// Plain Text Extraction
+// ============================================================================
+
+function extractFromPlainText(
+ buffer: Buffer,
+ maxLength: number,
+): ExtractionResult {
+ const text = buffer.toString("utf-8");
+ const truncated = text.length > maxLength;
+
+ return {
+ text: truncated ? text.slice(0, maxLength) : text,
+ truncated,
+ };
+}
+
+// ============================================================================
+// Utility Functions
+// ============================================================================
+
+/**
+ * Get a preview of the document (first N characters).
+ * Useful for logging without exposing full content.
+ */
+export function getDocumentPreview(text: string, length = 200): string {
+ if (text.length <= length) return text;
+ return `${text.slice(0, length)}...`;
+}
+
+/**
+ * Clean extracted text by removing excessive whitespace.
+ */
+export function cleanExtractedText(text: string): string {
+ return text
+ .replace(/\r\n/g, "\n") // Normalize line endings
+ .replace(/\n{3,}/g, "\n\n") // Max 2 consecutive newlines
+ .replace(/[ \t]+/g, " ") // Collapse horizontal whitespace
+ .trim();
+}
diff --git a/apps/web/utils/drive/filing-engine.ts b/apps/web/utils/drive/filing-engine.ts
new file mode 100644
index 0000000000..35224c8501
--- /dev/null
+++ b/apps/web/utils/drive/filing-engine.ts
@@ -0,0 +1,359 @@
+import prisma from "@/utils/prisma";
+import type { DriveConnection } from "@/generated/prisma/client";
+import type { EmailProvider } from "@/utils/email/types";
+import type { ParsedMessage, Attachment } from "@/utils/types";
+import type { EmailAccountWithAI } from "@/utils/llms/types";
+import type { Logger } from "@/utils/logger";
+import { createDriveProviderWithRefresh } from "@/utils/drive/provider";
+import { createAndSaveFilingFolder } from "@/utils/drive/folder-utils";
+import {
+ extractTextFromDocument,
+ isExtractableMimeType,
+} from "@/utils/drive/document-extraction";
+import { analyzeDocument } from "@/utils/ai/document-filing/analyze-document";
+import {
+ sendFiledNotification,
+ sendAskNotification,
+} from "@/utils/drive/filing-notifications";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface FilingResult {
+ success: boolean;
+ skipped?: boolean;
+ skipReason?: string;
+ filing?: {
+ id: string;
+ filename: string;
+ folderPath: string;
+ fileId: string | null;
+ wasAsked: boolean;
+ confidence: number | null;
+ provider: string;
+ };
+ error?: string;
+}
+
+export interface ProcessAttachmentOptions {
+ emailAccount: EmailAccountWithAI & {
+ filingEnabled: boolean;
+ filingPrompt: string | null;
+ email: string;
+ };
+ message: ParsedMessage;
+ attachment: Attachment;
+ emailProvider: EmailProvider;
+ logger: Logger;
+ sendNotification?: boolean;
+}
+
+// ============================================================================
+// Main Filing Engine
+// ============================================================================
+
+/**
+ * Process a single attachment through the filing pipeline:
+ * 1. Download attachment
+ * 2. Extract text
+ * 3. Fetch folders from all connected drives
+ * 4. Analyze with AI
+ * 5. Upload to drive
+ * 6. Create DocumentFiling record
+ */
+export async function processAttachment({
+ emailAccount,
+ message,
+ attachment,
+ emailProvider,
+ logger,
+ sendNotification = true,
+}: ProcessAttachmentOptions): Promise {
+ const log = logger.with({
+ action: "processAttachment",
+ messageId: message.id,
+ filename: attachment.filename,
+ });
+
+ try {
+ // Validate filing is enabled with a prompt
+ if (!emailAccount.filingEnabled || !emailAccount.filingPrompt) {
+ log.info("Filing not enabled or no prompt configured");
+ return { success: false, error: "Filing not enabled" };
+ }
+
+ // Get all connected drives
+ const driveConnections = await prisma.driveConnection.findMany({
+ where: {
+ emailAccountId: emailAccount.id,
+ isConnected: true,
+ },
+ });
+
+ if (driveConnections.length === 0) {
+ log.info("No connected drives");
+ return { success: false, error: "No connected drives" };
+ }
+
+ // Step 1: Download attachment
+ log.info("Downloading attachment");
+ const attachmentData = await emailProvider.getAttachment(
+ message.id,
+ attachment.attachmentId,
+ );
+ const buffer = Buffer.from(attachmentData.data, "base64");
+
+ // Step 2: Extract text
+ log.info("Extracting text from document");
+ const extraction = await extractTextFromDocument(
+ buffer,
+ attachment.mimeType,
+ { logger: log },
+ );
+
+ if (!extraction) {
+ log.warn("Could not extract text from document");
+ return { success: false, error: "Could not extract text" };
+ }
+
+ // Step 3: Get saved filing folders (user-selected, not all folders)
+ log.info("Fetching saved filing folders");
+ const savedFolders = await prisma.filingFolder.findMany({
+ where: { emailAccountId: emailAccount.id },
+ include: { driveConnection: true },
+ });
+
+ const allFolders: FolderWithConnection[] = savedFolders.map((f) => ({
+ id: f.folderId,
+ name: f.folderName,
+ path: f.folderPath,
+ driveConnectionId: f.driveConnectionId,
+ driveProvider: f.driveConnection.provider,
+ }));
+
+ if (allFolders.length === 0) {
+ log.warn("No filing folders configured");
+ }
+
+ // Step 4: Analyze with AI
+ log.info("Analyzing document with AI");
+ const analysis = await analyzeDocument({
+ emailAccount: {
+ ...emailAccount,
+ filingPrompt: emailAccount.filingPrompt,
+ },
+ email: {
+ subject: message.headers.subject || message.subject,
+ sender: message.headers.from,
+ },
+ attachment: {
+ filename: attachment.filename,
+ content: extraction.text,
+ },
+ folders: allFolders,
+ });
+
+ log.info("AI analysis complete", {
+ action: analysis.action,
+ confidence: analysis.confidence,
+ reasoning: analysis.reasoning,
+ });
+
+ // Step 5: Handle skip action
+ if (analysis.action === "skip") {
+ log.info("AI decided to skip this document");
+ return {
+ success: false,
+ skipped: true,
+ skipReason: analysis.reasoning,
+ };
+ }
+
+ // Step 6: Determine target folder and drive connection
+ const { driveConnection, folderId, folderPath, needsToCreateFolder } =
+ resolveFolderTarget(analysis, allFolders, driveConnections, log);
+
+ // Step 6: Create folder if needed
+ const driveProvider = await createDriveProviderWithRefresh(
+ driveConnection,
+ log,
+ );
+ let targetFolderId = folderId;
+ let targetFolderPath = folderPath;
+
+ if (needsToCreateFolder && analysis.folderPath) {
+ log.info("Creating new folder", { path: analysis.folderPath });
+ const newFolder = await createAndSaveFilingFolder({
+ driveProvider,
+ folderPath: analysis.folderPath,
+ emailAccountId: emailAccount.id,
+ driveConnectionId: driveConnection.id,
+ logger: log,
+ });
+ targetFolderId = newFolder.id;
+ targetFolderPath = analysis.folderPath;
+ }
+
+ // Step 7: Determine if we should ask the user first
+ const shouldAsk = analysis.confidence < 0.7;
+
+ // Step 8: Upload file (unless low confidence - then we ask first)
+ let fileId: string | null = null;
+ if (!shouldAsk) {
+ log.info("Uploading file to drive", {
+ folderId: targetFolderId,
+ folderPath: targetFolderPath,
+ });
+ const uploadedFile = await driveProvider.uploadFile({
+ filename: attachment.filename,
+ mimeType: attachment.mimeType,
+ content: buffer,
+ folderId: targetFolderId,
+ });
+ fileId = uploadedFile.id;
+ }
+
+ // Step 9: Create DocumentFiling record
+ const filing = await prisma.documentFiling.create({
+ data: {
+ messageId: message.id,
+ attachmentId: attachment.attachmentId,
+ filename: attachment.filename,
+ folderId: targetFolderId,
+ folderPath: targetFolderPath,
+ fileId,
+ reasoning: analysis.reasoning,
+ confidence: analysis.confidence,
+ status: shouldAsk ? "PENDING" : "FILED",
+ wasAsked: shouldAsk,
+ driveConnectionId: driveConnection.id,
+ emailAccountId: emailAccount.id,
+ },
+ });
+
+ log.info("Filing record created", {
+ filingId: filing.id,
+ status: filing.status,
+ wasAsked: shouldAsk,
+ });
+
+ // Step 10: Send notification email as a reply to the source email
+ if (sendNotification) {
+ const sourceMessage = {
+ threadId: message.threadId,
+ headerMessageId: message.headers["message-id"] || "",
+ references: message.headers.references,
+ };
+
+ try {
+ if (shouldAsk) {
+ await sendAskNotification({
+ emailProvider,
+ userEmail: emailAccount.email,
+ filingId: filing.id,
+ sourceMessage,
+ logger: log,
+ });
+ } else {
+ await sendFiledNotification({
+ emailProvider,
+ userEmail: emailAccount.email,
+ filingId: filing.id,
+ sourceMessage,
+ logger: log,
+ });
+ }
+ } catch (notificationError) {
+ // Don't fail the filing if notification fails
+ log.error("Failed to send notification", { error: notificationError });
+ }
+ }
+
+ return {
+ success: true,
+ filing: {
+ id: filing.id,
+ filename: attachment.filename,
+ folderPath: targetFolderPath,
+ fileId,
+ wasAsked: shouldAsk,
+ confidence: analysis.confidence,
+ provider: driveConnection.provider,
+ },
+ };
+ } catch (error) {
+ log.error("Error processing attachment", { error });
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ };
+ }
+}
+
+/**
+ * Get all extractable attachments from a message.
+ */
+export function getExtractableAttachments(
+ message: ParsedMessage,
+): Attachment[] {
+ return (message.attachments || []).filter((a) =>
+ isExtractableMimeType(a.mimeType),
+ );
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+interface FolderWithConnection {
+ id: string;
+ name: string;
+ path: string;
+ driveConnectionId: string;
+ driveProvider: string;
+}
+
+interface FolderTarget {
+ driveConnection: DriveConnection;
+ folderId: string;
+ folderPath: string;
+ needsToCreateFolder: boolean;
+}
+
+function resolveFolderTarget(
+ analysis: { action: string; folderId?: string; folderPath?: string },
+ folders: FolderWithConnection[],
+ connections: DriveConnection[],
+ logger: Logger,
+): FolderTarget {
+ if (analysis.action === "use_existing" && analysis.folderId) {
+ // Find the folder in our list
+ const folder = folders.find((f) => f.id === analysis.folderId);
+ if (folder) {
+ const connection = connections.find(
+ (c) => c.id === folder.driveConnectionId,
+ );
+ if (connection) {
+ return {
+ driveConnection: connection,
+ folderId: folder.id,
+ folderPath: folder.path || folder.name,
+ needsToCreateFolder: false,
+ };
+ }
+ }
+ logger.warn("Could not find folder from AI response, using first drive", {
+ folderId: analysis.folderId,
+ });
+ }
+
+ // Creating new folder or fallback - use first connection
+ const connection = connections[0];
+ return {
+ driveConnection: connection,
+ folderId: "root",
+ folderPath: analysis.folderPath || "Inbox Zero Filed",
+ needsToCreateFolder: true,
+ };
+}
diff --git a/apps/web/utils/drive/filing-notifications.ts b/apps/web/utils/drive/filing-notifications.ts
new file mode 100644
index 0000000000..ec7a07fbd8
--- /dev/null
+++ b/apps/web/utils/drive/filing-notifications.ts
@@ -0,0 +1,285 @@
+import prisma from "@/utils/prisma";
+import type { EmailProvider } from "@/utils/email/types";
+import type { Logger } from "@/utils/logger";
+import { getFilebotEmail } from "@/utils/filebot/is-filebot-email";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface SourceMessageInfo {
+ threadId: string;
+ headerMessageId: string;
+ references?: string;
+}
+
+interface FilingNotificationParams {
+ emailProvider: EmailProvider;
+ userEmail: string;
+ filingId: string;
+ sourceMessage: SourceMessageInfo;
+ logger: Logger;
+}
+
+// ============================================================================
+// Main Functions
+// ============================================================================
+
+/**
+ * Send a notification email for a successful filing.
+ * "✓ Filed Receipt.pdf to Receipts/2024/December"
+ * Sent as a reply to the source email thread.
+ */
+export async function sendFiledNotification({
+ emailProvider,
+ userEmail,
+ filingId,
+ sourceMessage,
+ logger,
+}: FilingNotificationParams): Promise {
+ const log = logger.with({ action: "sendFiledNotification", filingId });
+
+ const filing = await prisma.documentFiling.findUnique({
+ where: { id: filingId },
+ include: {
+ driveConnection: { select: { provider: true } },
+ },
+ });
+
+ if (!filing) {
+ log.error("Filing not found");
+ return;
+ }
+
+ const replyToAddress = getFilebotEmail({ userEmail });
+
+ const subject = `✓ Filed ${filing.filename}`;
+ const messageHtml = buildFiledEmailHtml({
+ filename: filing.filename,
+ folderPath: filing.folderPath,
+ driveProvider: filing.driveConnection.provider,
+ });
+
+ try {
+ const result = await emailProvider.sendEmailWithHtml({
+ replyToEmail: sourceMessage,
+ to: userEmail,
+ replyTo: replyToAddress,
+ subject,
+ messageHtml,
+ });
+
+ await prisma.documentFiling.update({
+ where: { id: filingId },
+ data: {
+ notificationMessageId: result.messageId,
+ notificationSentAt: new Date(),
+ },
+ });
+
+ log.info("Filed notification sent", { messageId: result.messageId });
+ } catch (error) {
+ log.error("Failed to send filed notification", { error });
+ throw error;
+ }
+}
+
+/**
+ * Send a notification email asking where to file a document.
+ * "📄 Where should I file Contract.pdf?"
+ * Sent as a reply to the source email thread.
+ */
+export async function sendAskNotification({
+ emailProvider,
+ userEmail,
+ filingId,
+ sourceMessage,
+ logger,
+}: FilingNotificationParams): Promise {
+ const log = logger.with({ action: "sendAskNotification", filingId });
+
+ const filing = await prisma.documentFiling.findUnique({
+ where: { id: filingId },
+ });
+
+ if (!filing) {
+ log.error("Filing not found");
+ return;
+ }
+
+ const replyToAddress = getFilebotEmail({ userEmail });
+
+ const subject = `📄 Where should I file ${filing.filename}?`;
+ const messageHtml = buildAskEmailHtml({
+ filename: filing.filename,
+ reasoning: filing.reasoning,
+ });
+
+ try {
+ const result = await emailProvider.sendEmailWithHtml({
+ replyToEmail: sourceMessage,
+ to: userEmail,
+ replyTo: replyToAddress,
+ subject,
+ messageHtml,
+ });
+
+ await prisma.documentFiling.update({
+ where: { id: filingId },
+ data: {
+ notificationMessageId: result.messageId,
+ notificationSentAt: new Date(),
+ },
+ });
+
+ log.info("Ask notification sent", { messageId: result.messageId });
+ } catch (error) {
+ log.error("Failed to send ask notification", { error });
+ throw error;
+ }
+}
+
+/**
+ * Send a confirmation email after a correction.
+ * "Done! Moved to Business/Expenses"
+ * Sent as a reply to the source email thread.
+ */
+export async function sendCorrectionConfirmation({
+ emailProvider,
+ userEmail,
+ filingId,
+ sourceMessage,
+ newFolderPath,
+ logger,
+}: FilingNotificationParams & { newFolderPath: string }): Promise {
+ const log = logger.with({ action: "sendCorrectionConfirmation", filingId });
+
+ const filing = await prisma.documentFiling.findUnique({
+ where: { id: filingId },
+ });
+
+ if (!filing) {
+ log.error("Filing not found");
+ return;
+ }
+
+ const replyToAddress = getFilebotEmail({ userEmail });
+
+ const subject = `Re: ✓ Filed ${filing.filename}`;
+ const messageHtml = buildCorrectionConfirmationHtml({
+ filename: filing.filename,
+ newFolderPath,
+ });
+
+ try {
+ await emailProvider.sendEmailWithHtml({
+ replyToEmail: sourceMessage,
+ to: userEmail,
+ replyTo: replyToAddress,
+ subject,
+ messageHtml,
+ });
+
+ log.info("Correction confirmation sent");
+ } catch (error) {
+ log.error("Failed to send correction confirmation", { error });
+ throw error;
+ }
+}
+
+// ============================================================================
+// Email Templates
+// ============================================================================
+
+function buildFiledEmailHtml({
+ filename,
+ folderPath,
+ driveProvider,
+}: {
+ filename: string;
+ folderPath: string;
+ driveProvider: string;
+}): string {
+ const driveName = driveProvider === "google" ? "Google Drive" : "OneDrive";
+
+ return `
+
+
Filed your document:
+
+
+
+ 📄 ${escapeHtml(filename)}
+
+
+ 📁 → ${escapeHtml(folderPath)}
+
+
+ ${driveName}
+
+
+
+
+ Wrong folder? Just reply with where it should go.
+
+
+ `;
+}
+
+function buildAskEmailHtml({
+ filename,
+ reasoning,
+}: {
+ filename: string;
+ reasoning: string | null;
+}): string {
+ return `
+
+
Got a document I'm not sure about:
+
+
+
+ 📄 ${escapeHtml(filename)}
+
+ ${reasoning ? `
${escapeHtml(reasoning)}
` : ""}
+
+
+
Where should I put it?
+
+
+ Reply with a folder path, e.g.:
+ • "Receipts/2024"
+ • "Projects/Acme Corp/Contracts"
+ • "Skip" to ignore this one
+
+
+ `;
+}
+
+function buildCorrectionConfirmationHtml({
+ filename,
+ newFolderPath,
+}: {
+ filename: string;
+ newFolderPath: string;
+}): string {
+ return `
+
+
✓ Done! Moved ${escapeHtml(filename)} to:
+
+
+
+ 📁 ${escapeHtml(newFolderPath)}
+
+
+
+ `;
+}
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
diff --git a/apps/web/utils/drive/folder-utils.test.ts b/apps/web/utils/drive/folder-utils.test.ts
new file mode 100644
index 0000000000..0babb525bc
--- /dev/null
+++ b/apps/web/utils/drive/folder-utils.test.ts
@@ -0,0 +1,169 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { createFolderPath } from "./folder-utils";
+import type { DriveProvider, DriveFolder } from "./types";
+import type { Logger } from "@/utils/logger";
+
+function createMockFolder(id: string, name: string): DriveFolder {
+ return {
+ id,
+ name,
+ path: name,
+ webUrl: `https://drive.example.com/${id}`,
+ };
+}
+
+function createMockProvider(
+ existingFolders: Map = new Map(),
+): DriveProvider {
+ const createdFolders: DriveFolder[] = [];
+ let folderId = 1;
+
+ return {
+ name: "google",
+ toJSON: () => ({ name: "google", type: "drive" }),
+ getAccessToken: () => "mock-token",
+ listFolders: vi.fn(async (parentId?: string) => {
+ const existing = existingFolders.get(parentId) || [];
+ const created = createdFolders.filter((f) => {
+ if (parentId === undefined) return !f.path?.includes("/");
+ return f.path?.startsWith(parentId);
+ });
+ return [...existing, ...created];
+ }),
+ getFolder: vi.fn(async () => null),
+ createFolder: vi.fn(async (name: string, parentId?: string) => {
+ const folder = createMockFolder(`folder-${folderId++}`, name);
+ createdFolders.push({ ...folder, parentId });
+ return folder;
+ }),
+ uploadFile: vi.fn(async () => ({
+ id: "file-1",
+ name: "test.pdf",
+ mimeType: "application/pdf",
+ webUrl: "https://drive.example.com/file-1",
+ })),
+ getFile: vi.fn(async () => null),
+ moveFile: vi.fn(async (fileId: string, targetFolderId: string) => ({
+ id: fileId,
+ name: "moved-file",
+ mimeType: "application/pdf",
+ folderId: targetFolderId,
+ })),
+ };
+}
+
+function createMockLogger(): Logger {
+ return {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ with: vi.fn(() => createMockLogger()),
+ } as unknown as Logger;
+}
+
+describe("createFolderPath", () => {
+ let mockLogger: Logger;
+
+ beforeEach(() => {
+ mockLogger = createMockLogger();
+ });
+
+ it("should create a single folder at root", async () => {
+ const provider = createMockProvider();
+
+ const result = await createFolderPath(provider, "Receipts", mockLogger);
+
+ expect(result.name).toBe("Receipts");
+ expect(provider.createFolder).toHaveBeenCalledWith("Receipts", undefined);
+ });
+
+ it("should create nested folders", async () => {
+ const provider = createMockProvider();
+
+ const result = await createFolderPath(
+ provider,
+ "Receipts/2024/December",
+ mockLogger,
+ );
+
+ expect(result.name).toBe("December");
+ expect(provider.createFolder).toHaveBeenCalledTimes(3);
+ expect(provider.createFolder).toHaveBeenNthCalledWith(
+ 1,
+ "Receipts",
+ undefined,
+ );
+ });
+
+ it("should use existing folder if it exists", async () => {
+ const existingFolders = new Map([
+ [undefined, [createMockFolder("existing-1", "Receipts")]],
+ ]);
+ const provider = createMockProvider(existingFolders);
+
+ const result = await createFolderPath(
+ provider,
+ "Receipts/2024",
+ mockLogger,
+ );
+
+ expect(result.name).toBe("2024");
+ expect(provider.createFolder).toHaveBeenCalledTimes(1);
+ expect(provider.createFolder).toHaveBeenCalledWith("2024", "existing-1");
+ });
+
+ it("should match folder names case-insensitively", async () => {
+ const existingFolders = new Map([
+ [undefined, [createMockFolder("existing-1", "RECEIPTS")]],
+ ]);
+ const provider = createMockProvider(existingFolders);
+
+ const result = await createFolderPath(
+ provider,
+ "receipts/2024",
+ mockLogger,
+ );
+
+ expect(result.name).toBe("2024");
+ expect(provider.createFolder).toHaveBeenCalledTimes(1);
+ expect(provider.createFolder).toHaveBeenCalledWith("2024", "existing-1");
+ });
+
+ it("should handle path with leading slash", async () => {
+ const provider = createMockProvider();
+
+ const result = await createFolderPath(provider, "/Receipts", mockLogger);
+
+ expect(result.name).toBe("Receipts");
+ expect(provider.createFolder).toHaveBeenCalledTimes(1);
+ });
+
+ it("should handle path with trailing slash", async () => {
+ const provider = createMockProvider();
+
+ const result = await createFolderPath(provider, "Receipts/", mockLogger);
+
+ expect(result.name).toBe("Receipts");
+ expect(provider.createFolder).toHaveBeenCalledTimes(1);
+ });
+
+ it("should throw error for empty path", async () => {
+ const provider = createMockProvider();
+
+ await expect(createFolderPath(provider, "", mockLogger)).rejects.toThrow(
+ "Failed to create folder path",
+ );
+ });
+
+ it("should log when creating folders", async () => {
+ const provider = createMockProvider();
+
+ await createFolderPath(provider, "Receipts/2024", mockLogger);
+
+ expect(mockLogger.info).toHaveBeenCalledWith("Creating folder", {
+ name: "Receipts",
+ parentId: undefined,
+ });
+ });
+});
diff --git a/apps/web/utils/drive/folder-utils.ts b/apps/web/utils/drive/folder-utils.ts
new file mode 100644
index 0000000000..1b350effe6
--- /dev/null
+++ b/apps/web/utils/drive/folder-utils.ts
@@ -0,0 +1,76 @@
+import type { DriveProvider, DriveFolder } from "@/utils/drive/types";
+import type { Logger } from "@/utils/logger";
+import prisma from "@/utils/prisma";
+
+/**
+ * Create a folder path in the drive, creating intermediate folders as needed.
+ * Returns the final folder.
+ */
+export async function createFolderPath(
+ provider: DriveProvider,
+ path: string,
+ logger: Logger,
+): Promise {
+ const parts = path.split("/").filter(Boolean);
+ let parentId: string | undefined;
+ let currentFolder: DriveFolder | null = null;
+
+ for (const part of parts) {
+ const existingFolders = await provider.listFolders(parentId);
+ const existing = existingFolders.find(
+ (f) => f.name.toLowerCase() === part.toLowerCase(),
+ );
+
+ if (existing) {
+ currentFolder = existing;
+ parentId = existing.id;
+ } else {
+ logger.info("Creating folder", { name: part, parentId });
+ currentFolder = await provider.createFolder(part, parentId);
+ parentId = currentFolder.id;
+ }
+ }
+
+ if (!currentFolder) {
+ throw new Error("Failed to create folder path");
+ }
+
+ return currentFolder;
+}
+
+export async function createAndSaveFilingFolder({
+ driveProvider,
+ folderPath,
+ emailAccountId,
+ driveConnectionId,
+ logger,
+}: {
+ driveProvider: DriveProvider;
+ folderPath: string;
+ emailAccountId: string;
+ driveConnectionId: string;
+ logger: Logger;
+}): Promise {
+ const folder = await createFolderPath(driveProvider, folderPath, logger);
+
+ await prisma.filingFolder.upsert({
+ where: {
+ emailAccountId_folderId: { emailAccountId, folderId: folder.id },
+ },
+ update: {},
+ create: {
+ folderId: folder.id,
+ folderName: folder.name,
+ folderPath,
+ driveConnectionId,
+ emailAccountId,
+ },
+ });
+
+ logger.info("Saved folder as filing folder", {
+ folderId: folder.id,
+ folderPath,
+ });
+
+ return folder;
+}
diff --git a/apps/web/utils/drive/handle-drive-callback.ts b/apps/web/utils/drive/handle-drive-callback.ts
new file mode 100644
index 0000000000..89a7b116cd
--- /dev/null
+++ b/apps/web/utils/drive/handle-drive-callback.ts
@@ -0,0 +1,286 @@
+import { z } from "zod";
+import { type NextRequest, NextResponse } from "next/server";
+import { env } from "@/env";
+import type { Logger } from "@/utils/logger";
+import type { DriveTokens } from "./types";
+import {
+ RedirectError,
+ redirectWithMessage,
+ redirectWithError,
+} from "@/utils/oauth/redirect";
+import { verifyEmailAccountAccess } from "@/utils/oauth/verify";
+import {
+ acquireOAuthCodeLock,
+ getOAuthCodeResult,
+ setOAuthCodeResult,
+ clearOAuthCode,
+} from "@/utils/redis/oauth-code";
+import { DRIVE_STATE_COOKIE_NAME } from "./constants";
+import prisma from "@/utils/prisma";
+import { parseOAuthState } from "@/utils/oauth/state";
+import { prefixPath } from "@/utils/path";
+
+const driveOAuthStateSchema = z.object({
+ emailAccountId: z.string().min(1).max(64),
+ type: z.literal("drive"),
+ nonce: z.string().min(8).max(128),
+});
+
+/**
+ * Unified handler for drive OAuth callbacks
+ */
+export async function handleDriveCallback(
+ request: NextRequest,
+ provider: {
+ name: "google" | "microsoft";
+ exchangeCodeForTokens(code: string): Promise;
+ },
+ logger: Logger,
+): Promise {
+ let redirectHeaders = new Headers();
+
+ try {
+ // Step 1: Validate OAuth callback parameters
+ const { code, redirectUrl, response } = await validateOAuthCallback(
+ request,
+ logger,
+ );
+ redirectHeaders = response.headers;
+
+ // Step 1.5: Check for duplicate OAuth code processing
+ const cachedResult = await getOAuthCodeResult(code);
+ if (cachedResult) {
+ logger.info("OAuth code already processed, returning cached result");
+ const cachedRedirectUrl = new URL("/drive", env.NEXT_PUBLIC_BASE_URL);
+ for (const [key, value] of Object.entries(cachedResult.params)) {
+ cachedRedirectUrl.searchParams.set(key, value);
+ }
+ response.cookies.delete(DRIVE_STATE_COOKIE_NAME);
+ return redirectWithMessage(
+ cachedRedirectUrl,
+ cachedResult.params.message || "drive_connected",
+ redirectHeaders,
+ );
+ }
+
+ const acquiredLock = await acquireOAuthCodeLock(code);
+ if (!acquiredLock) {
+ logger.info("OAuth code is being processed by another request");
+ const lockRedirectUrl = new URL("/drive", env.NEXT_PUBLIC_BASE_URL);
+ response.cookies.delete(DRIVE_STATE_COOKIE_NAME);
+ return redirectWithMessage(
+ lockRedirectUrl,
+ "processing",
+ redirectHeaders,
+ );
+ }
+
+ // The validated state is in the request query params
+ const receivedState = request.nextUrl.searchParams.get("state");
+ if (!receivedState) {
+ throw new Error("Missing validated state");
+ }
+
+ // Step 2: Parse and validate the OAuth state
+ const decodedState = parseAndValidateDriveState(
+ receivedState,
+ logger,
+ redirectUrl,
+ response.headers,
+ );
+
+ const { emailAccountId } = decodedState;
+
+ // Step 3: Update redirect URL to include emailAccountId
+ const finalRedirectUrl = buildDriveRedirectUrl(emailAccountId);
+
+ // Step 4: Verify user owns this email account
+ await verifyEmailAccountAccess(
+ emailAccountId,
+ logger,
+ finalRedirectUrl,
+ response.headers,
+ );
+
+ // Step 5: Exchange code for tokens and get email
+ const { accessToken, refreshToken, expiresAt, email } =
+ await provider.exchangeCodeForTokens(code);
+
+ // Step 6: Create or update drive connection
+ const connection = await upsertDriveConnection({
+ provider: provider.name,
+ email,
+ emailAccountId,
+ accessToken,
+ refreshToken,
+ expiresAt,
+ });
+
+ logger.info("Drive connected successfully", {
+ emailAccountId,
+ email,
+ provider: provider.name,
+ connectionId: connection.id,
+ });
+
+ // Cache the successful result (best-effort, don't fail if cache write fails)
+ try {
+ await setOAuthCodeResult(code, { message: "drive_connected" });
+ } catch (cacheError) {
+ logger.warn("Failed to cache OAuth code result; continuing", {
+ error: cacheError,
+ });
+ }
+
+ return redirectWithMessage(
+ finalRedirectUrl,
+ "drive_connected",
+ redirectHeaders,
+ );
+ } catch (error) {
+ // Clear the OAuth code lock on error (best-effort, don't mask original error)
+ const searchParams = request.nextUrl.searchParams;
+ const code = searchParams.get("code");
+ if (code) {
+ await clearOAuthCode(code).catch((clearError) => {
+ logger.warn("Failed to clear OAuth code on error; continuing", {
+ error: clearError,
+ });
+ });
+ }
+
+ // Handle redirect errors
+ if (error instanceof RedirectError) {
+ return redirectWithError(
+ error.redirectUrl,
+ "connection_failed",
+ error.responseHeaders,
+ );
+ }
+
+ // Handle all other errors
+ logger.error("Error in drive callback", { error });
+
+ // Try to build a redirect URL, fallback to /drive
+ const errorRedirectUrl = new URL("/drive", env.NEXT_PUBLIC_BASE_URL);
+ return redirectWithError(
+ errorRedirectUrl,
+ "connection_failed",
+ redirectHeaders,
+ );
+ }
+}
+
+/**
+ * Validate OAuth callback parameters and setup redirect
+ */
+async function validateOAuthCallback(
+ request: NextRequest,
+ logger: Logger,
+): Promise<{
+ code: string;
+ redirectUrl: URL;
+ response: NextResponse;
+}> {
+ const searchParams = request.nextUrl.searchParams;
+ const code = searchParams.get("code");
+ const receivedState = searchParams.get("state");
+ const storedState = request.cookies.get(DRIVE_STATE_COOKIE_NAME)?.value;
+
+ const redirectUrl = new URL("/drive", env.NEXT_PUBLIC_BASE_URL);
+ const response = NextResponse.redirect(redirectUrl);
+
+ response.cookies.delete(DRIVE_STATE_COOKIE_NAME);
+
+ if (!code || code.length < 10) {
+ logger.warn("Missing or invalid code in drive callback");
+ redirectUrl.searchParams.set("error", "missing_code");
+ throw new RedirectError(redirectUrl, response.headers);
+ }
+
+ if (!storedState || !receivedState || storedState !== receivedState) {
+ logger.warn("Invalid state during drive callback", {
+ receivedState,
+ hasStoredState: !!storedState,
+ });
+ redirectUrl.searchParams.set("error", "invalid_state");
+ throw new RedirectError(redirectUrl, response.headers);
+ }
+
+ return { code, redirectUrl, response };
+}
+
+function parseAndValidateDriveState(
+ storedState: string,
+ logger: Logger,
+ redirectUrl: URL,
+ responseHeaders: Headers,
+): {
+ emailAccountId: string;
+ type: "drive";
+ nonce: string;
+} {
+ let rawState: unknown;
+ try {
+ rawState = parseOAuthState<{
+ emailAccountId: string;
+ type: "drive";
+ }>(storedState);
+ } catch (error) {
+ logger.error("Failed to decode state", { error });
+ redirectUrl.searchParams.set("error", "invalid_state_format");
+ throw new RedirectError(redirectUrl, responseHeaders);
+ }
+
+ const validationResult = driveOAuthStateSchema.safeParse(rawState);
+ if (!validationResult.success) {
+ logger.error("State validation failed", {
+ errors: validationResult.error.errors,
+ });
+ redirectUrl.searchParams.set("error", "invalid_state_format");
+ throw new RedirectError(redirectUrl, responseHeaders);
+ }
+
+ return validationResult.data;
+}
+
+function buildDriveRedirectUrl(emailAccountId: string): URL {
+ return new URL(
+ prefixPath(emailAccountId, "/drive"),
+ env.NEXT_PUBLIC_BASE_URL,
+ );
+}
+
+async function upsertDriveConnection(params: {
+ provider: "google" | "microsoft";
+ email: string;
+ emailAccountId: string;
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: Date | null;
+}) {
+ return await prisma.driveConnection.upsert({
+ where: {
+ emailAccountId_provider: {
+ emailAccountId: params.emailAccountId,
+ provider: params.provider,
+ },
+ },
+ update: {
+ email: params.email,
+ accessToken: params.accessToken,
+ refreshToken: params.refreshToken,
+ expiresAt: params.expiresAt,
+ isConnected: true,
+ },
+ create: {
+ provider: params.provider,
+ email: params.email,
+ emailAccountId: params.emailAccountId,
+ accessToken: params.accessToken,
+ refreshToken: params.refreshToken,
+ expiresAt: params.expiresAt,
+ isConnected: true,
+ },
+ });
+}
diff --git a/apps/web/utils/drive/handle-filing-reply.test.ts b/apps/web/utils/drive/handle-filing-reply.test.ts
new file mode 100644
index 0000000000..d8f7d2580b
--- /dev/null
+++ b/apps/web/utils/drive/handle-filing-reply.test.ts
@@ -0,0 +1,134 @@
+import { describe, it, expect } from "vitest";
+
+// These are private functions, so we need to export them for testing
+// For now, we'll test via the module's behavior
+// But ideally these would be exported or extracted
+
+describe("parseFolderPath", () => {
+ // Since parseFolderPath is not exported, we test the logic inline
+ const parseFolderPath = (content: string): string | null => {
+ const lines = content.split("\n");
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed && !trimmed.startsWith(">") && !trimmed.startsWith("On ")) {
+ const cleaned = trimmed
+ .replace(/^(put it in|move to|file to|folder:?)\s*/i, "")
+ .replace(/^["']|["']$/g, "")
+ .trim();
+
+ if (cleaned) {
+ return cleaned;
+ }
+ }
+ }
+
+ return null;
+ };
+
+ it("should parse a simple folder path", () => {
+ expect(parseFolderPath("Receipts/2024")).toBe("Receipts/2024");
+ });
+
+ it("should parse folder path with 'put it in' prefix", () => {
+ expect(parseFolderPath("Put it in Receipts/2024")).toBe("Receipts/2024");
+ });
+
+ it("should parse folder path with 'move to' prefix", () => {
+ expect(parseFolderPath("Move to Projects/ClientA")).toBe(
+ "Projects/ClientA",
+ );
+ });
+
+ it("should parse folder path with 'file to' prefix", () => {
+ expect(parseFolderPath("File to Documents")).toBe("Documents");
+ });
+
+ it("should parse folder path with 'folder:' prefix", () => {
+ expect(parseFolderPath("Folder: Invoices/2024")).toBe("Invoices/2024");
+ });
+
+ it("should remove quotes around path", () => {
+ expect(parseFolderPath('"Receipts/December"')).toBe("Receipts/December");
+ expect(parseFolderPath("'Projects/Acme'")).toBe("Projects/Acme");
+ });
+
+ it("should skip quoted reply lines", () => {
+ const content = `> Original message here
+Receipts/2024`;
+ expect(parseFolderPath(content)).toBe("Receipts/2024");
+ });
+
+ it("should skip 'On ... wrote:' lines", () => {
+ const content = `On Mon, Jan 1, 2024 at 10:00 AM User wrote:
+Receipts/2024`;
+ expect(parseFolderPath(content)).toBe("Receipts/2024");
+ });
+
+ it("should handle multiline with first valid line", () => {
+ const content = `
+Receipts/2024
+This is where I want it
+`;
+ expect(parseFolderPath(content)).toBe("Receipts/2024");
+ });
+
+ it("should return null for empty content", () => {
+ expect(parseFolderPath("")).toBeNull();
+ });
+
+ it("should return null for only quoted content", () => {
+ expect(parseFolderPath("> quoted text only")).toBeNull();
+ });
+});
+
+describe("isSkipCommand", () => {
+ const isSkipCommand = (content: string): boolean => {
+ const normalized = content.toLowerCase().trim();
+ return (
+ normalized === "skip" ||
+ normalized === "ignore" ||
+ normalized === "no" ||
+ normalized === "don't file" ||
+ normalized === "dont file"
+ );
+ };
+
+ it("should recognize 'skip'", () => {
+ expect(isSkipCommand("skip")).toBe(true);
+ expect(isSkipCommand("Skip")).toBe(true);
+ expect(isSkipCommand("SKIP")).toBe(true);
+ expect(isSkipCommand(" skip ")).toBe(true);
+ });
+
+ it("should recognize 'ignore'", () => {
+ expect(isSkipCommand("ignore")).toBe(true);
+ expect(isSkipCommand("Ignore")).toBe(true);
+ });
+
+ it("should recognize 'no'", () => {
+ expect(isSkipCommand("no")).toBe(true);
+ expect(isSkipCommand("No")).toBe(true);
+ });
+
+ it("should recognize 'don't file'", () => {
+ expect(isSkipCommand("don't file")).toBe(true);
+ expect(isSkipCommand("Don't file")).toBe(true);
+ });
+
+ it("should recognize 'dont file'", () => {
+ expect(isSkipCommand("dont file")).toBe(true);
+ expect(isSkipCommand("Dont file")).toBe(true);
+ });
+
+ it("should not match partial commands", () => {
+ expect(isSkipCommand("skip this")).toBe(false);
+ expect(isSkipCommand("please skip")).toBe(false);
+ expect(isSkipCommand("nope")).toBe(false);
+ });
+
+ it("should not match folder paths", () => {
+ expect(isSkipCommand("Receipts/2024")).toBe(false);
+ expect(isSkipCommand("Documents")).toBe(false);
+ });
+});
diff --git a/apps/web/utils/drive/handle-filing-reply.ts b/apps/web/utils/drive/handle-filing-reply.ts
new file mode 100644
index 0000000000..20a1503486
--- /dev/null
+++ b/apps/web/utils/drive/handle-filing-reply.ts
@@ -0,0 +1,230 @@
+import prisma from "@/utils/prisma";
+import type { ParsedMessage } from "@/utils/types";
+import type { EmailProvider } from "@/utils/email/types";
+import type { Logger } from "@/utils/logger";
+import { extractEmailAddress } from "@/utils/email";
+import { emailToContent } from "@/utils/mail";
+import { createDriveProviderWithRefresh } from "@/utils/drive/provider";
+import { createAndSaveFilingFolder } from "@/utils/drive/folder-utils";
+import { sendCorrectionConfirmation } from "@/utils/drive/filing-notifications";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface ProcessFilingReplyArgs {
+ emailAccountId: string;
+ userEmail: string;
+ message: ParsedMessage;
+ emailProvider: EmailProvider;
+ logger: Logger;
+}
+
+// ============================================================================
+// Main Handler
+// ============================================================================
+
+/**
+ * Process a reply to a filebot notification email.
+ * Uses the In-Reply-To header to find which notification was replied to,
+ * then looks up the filing by notificationMessageId.
+ */
+export async function processFilingReply({
+ emailAccountId,
+ userEmail,
+ message,
+ emailProvider,
+ logger,
+}: ProcessFilingReplyArgs): Promise {
+ logger = logger.with({
+ action: "processFilingReply",
+ messageId: message.id,
+ });
+
+ // Verify the message is from the user
+ if (!verifyUserSentEmail({ message, userEmail })) {
+ logger.error("Unauthorized filing reply attempt", {
+ from: message.headers.from,
+ });
+ return;
+ }
+
+ // Get the In-Reply-To header to find which notification this is replying to
+ const inReplyTo = message.headers["in-reply-to"];
+
+ if (!inReplyTo) {
+ logger.error("No In-Reply-To header found");
+ return;
+ }
+
+ // The In-Reply-To header contains the Message-ID of the notification email
+ // Look up the filing by notificationMessageId
+ const filing = await prisma.documentFiling.findUnique({
+ where: { notificationMessageId: inReplyTo },
+ include: {
+ driveConnection: true,
+ emailAccount: true,
+ },
+ });
+
+ if (!filing) {
+ logger.error("Filing not found for In-Reply-To message", { inReplyTo });
+ return;
+ }
+
+ if (filing.emailAccountId !== emailAccountId) {
+ logger.error("Filing does not belong to this email account");
+ return;
+ }
+
+ logger = logger.with({ filingId: filing.id });
+ logger.info("Processing filing reply");
+
+ // Parse the reply content
+ const replyContent = emailToContent(message, { extractReply: true }).trim();
+
+ if (!replyContent) {
+ logger.info("Empty reply, ignoring");
+ return;
+ }
+
+ // Check for skip/reject
+ if (isSkipCommand(replyContent)) {
+ await handleSkip(filing.id, logger);
+ return;
+ }
+
+ // Parse as folder path
+ const folderPath = parseFolderPath(replyContent);
+
+ if (!folderPath) {
+ logger.info("Could not parse folder path from reply", { replyContent });
+ // Could send a clarification email here
+ return;
+ }
+
+ // Get or create the folder and move/upload the file
+ try {
+ const driveProvider = await createDriveProviderWithRefresh(
+ filing.driveConnection,
+ logger,
+ );
+
+ const targetFolder = await createAndSaveFilingFolder({
+ driveProvider,
+ folderPath,
+ emailAccountId: filing.emailAccountId,
+ driveConnectionId: filing.driveConnectionId,
+ logger,
+ });
+
+ // If the file was already uploaded, we need to move it
+ // For now, we'll just update the record (moving files requires additional API calls)
+ // TODO: Implement file moving for corrections
+
+ // Update the filing record
+ await prisma.documentFiling.update({
+ where: { id: filing.id },
+ data: {
+ folderId: targetFolder.id,
+ folderPath: folderPath,
+ status: "FILED",
+ wasCorrected: filing.status === "FILED",
+ originalPath: filing.wasCorrected
+ ? filing.originalPath
+ : filing.folderPath,
+ correctedAt: new Date(),
+ },
+ });
+
+ // If the file wasn't uploaded yet (was PENDING), upload it now
+ if (filing.status === "PENDING" && !filing.fileId) {
+ logger.info("Filing was pending, file upload would happen here");
+ // TODO: Fetch attachment and upload to the specified folder
+ // This requires storing the attachment content or re-fetching it
+ }
+
+ // Build source message info for threading the confirmation
+ const sourceMessage = {
+ threadId: message.threadId,
+ headerMessageId: message.headers["message-id"] || "",
+ references: message.headers.references,
+ };
+
+ // Send confirmation
+ await sendCorrectionConfirmation({
+ emailProvider,
+ userEmail,
+ filingId: filing.id,
+ sourceMessage,
+ newFolderPath: folderPath,
+ logger,
+ });
+
+ logger.info("Filing reply processed successfully", { newPath: folderPath });
+ } catch (error) {
+ logger.error("Error processing filing reply", { error });
+
+ await prisma.documentFiling.update({
+ where: { id: filing.id },
+ data: { status: "ERROR" },
+ });
+ }
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+function verifyUserSentEmail({
+ message,
+ userEmail,
+}: {
+ message: ParsedMessage;
+ userEmail: string;
+}): boolean {
+ const fromEmail = extractEmailAddress(message.headers.from)?.toLowerCase();
+ return fromEmail === userEmail.toLowerCase();
+}
+
+function isSkipCommand(content: string): boolean {
+ const normalized = content.toLowerCase().trim();
+ return (
+ normalized === "skip" ||
+ normalized === "ignore" ||
+ normalized === "no" ||
+ normalized === "don't file" ||
+ normalized === "dont file"
+ );
+}
+
+function parseFolderPath(content: string): string | null {
+ // Clean up the reply content
+ const lines = content.split("\n");
+
+ // Take the first non-empty line as the folder path
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (trimmed && !trimmed.startsWith(">") && !trimmed.startsWith("On ")) {
+ // Remove common prefixes
+ const cleaned = trimmed
+ .replace(/^(put it in|move to|file to|folder:?)\s*/i, "")
+ .replace(/^["']|["']$/g, "")
+ .trim();
+
+ if (cleaned) {
+ return cleaned;
+ }
+ }
+ }
+
+ return null;
+}
+
+async function handleSkip(filingId: string, logger: Logger): Promise {
+ await prisma.documentFiling.update({
+ where: { id: filingId },
+ data: { status: "REJECTED" },
+ });
+ logger.info("Filing skipped by user");
+}
diff --git a/apps/web/utils/drive/provider.ts b/apps/web/utils/drive/provider.ts
new file mode 100644
index 0000000000..17510eb2a8
--- /dev/null
+++ b/apps/web/utils/drive/provider.ts
@@ -0,0 +1,300 @@
+import type { DriveConnection } from "@/generated/prisma/client";
+import {
+ isGoogleProvider,
+ isMicrosoftProvider,
+} from "@/utils/email/provider-types";
+import type { DriveProvider } from "@/utils/drive/types";
+import type { Logger } from "@/utils/logger";
+import { OneDriveProvider } from "@/utils/drive/providers/microsoft";
+import { GoogleDriveProvider } from "@/utils/drive/providers/google";
+import { MICROSOFT_DRIVE_SCOPES } from "@/utils/drive/scopes";
+import { SafeError } from "@/utils/error";
+import { env } from "@/env";
+import prisma from "@/utils/prisma";
+
+type OAuthTokenResponse = {
+ access_token?: string;
+ refresh_token?: string;
+ expires_in?: number;
+ error?: string;
+ error_description?: string;
+};
+
+/**
+ * Internal factory function to create the appropriate DriveProvider based on connection type.
+ * External code should use createDriveProviderWithRefresh to handle token expiration.
+ */
+function createDriveProvider(
+ connection: Pick,
+ logger: Logger,
+): DriveProvider {
+ const { provider, accessToken } = connection;
+
+ if (!accessToken) {
+ throw new Error("No access token available for drive connection");
+ }
+
+ if (isMicrosoftProvider(provider)) {
+ return new OneDriveProvider(accessToken, logger);
+ }
+
+ if (isGoogleProvider(provider)) {
+ return new GoogleDriveProvider(accessToken, logger);
+ }
+
+ throw new Error(`Unsupported drive provider: ${provider}`);
+}
+
+/**
+ * Factory function that handles token refresh for drive connections.
+ * Similar to getCalendarClientWithRefresh.
+ */
+export async function createDriveProviderWithRefresh(
+ connection: Pick<
+ DriveConnection,
+ "id" | "provider" | "accessToken" | "refreshToken" | "expiresAt"
+ >,
+ logger: Logger,
+): Promise {
+ const { provider, accessToken, refreshToken, expiresAt } = connection;
+
+ if (!refreshToken) {
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ // Check if token is still valid (with 5 min buffer)
+ const bufferMs = 5 * 60 * 1000;
+ const expiresAtMs = expiresAt ? expiresAt.getTime() : 0;
+ if (accessToken && expiresAtMs > Date.now() + bufferMs) {
+ return createDriveProvider({ provider, accessToken }, logger);
+ }
+
+ // Token is expired or missing, need to refresh
+ if (isMicrosoftProvider(provider)) {
+ const newAccessToken = await refreshMicrosoftDriveToken(connection, logger);
+ return new OneDriveProvider(newAccessToken, logger);
+ }
+
+ if (isGoogleProvider(provider)) {
+ const newAccessToken = await refreshGoogleDriveToken(connection, logger);
+ return new GoogleDriveProvider(newAccessToken, logger);
+ }
+
+ throw new Error(`Unsupported drive provider: ${provider}`);
+}
+
+async function refreshMicrosoftDriveToken(
+ connection: Pick,
+ logger: Logger,
+): Promise {
+ const { id: connectionId, refreshToken } = connection;
+
+ if (!refreshToken) {
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ if (!env.MICROSOFT_CLIENT_ID || !env.MICROSOFT_CLIENT_SECRET) {
+ throw new Error("Microsoft login not enabled - missing credentials");
+ }
+
+ const response = await fetch(
+ `https://login.microsoftonline.com/${env.MICROSOFT_TENANT_ID}/oauth2/v2.0/token`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ client_id: env.MICROSOFT_CLIENT_ID,
+ client_secret: env.MICROSOFT_CLIENT_SECRET,
+ refresh_token: refreshToken,
+ grant_type: "refresh_token",
+ scope: MICROSOFT_DRIVE_SCOPES.join(" "),
+ }),
+ },
+ );
+
+ let tokens: OAuthTokenResponse;
+ try {
+ tokens = await response.json();
+ } catch {
+ logger.warn("Microsoft drive token refresh returned non-JSON response", {
+ connectionId,
+ status: response.status,
+ });
+ await markDriveConnectionAsDisconnected(connectionId);
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ if (!response.ok) {
+ const errorMessage = tokens.error_description || "Failed to refresh token";
+ logger.warn("Microsoft drive token refresh failed", {
+ connectionId,
+ error: errorMessage,
+ });
+ await markDriveConnectionAsDisconnected(connectionId);
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ if (!tokens.access_token) {
+ logger.warn("Microsoft token refresh did not return access_token", {
+ connectionId,
+ });
+ await markDriveConnectionAsDisconnected(connectionId);
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ // Save new tokens
+ const expiresIn = Number(tokens.expires_in);
+ await saveDriveTokens({
+ tokens: {
+ access_token: tokens.access_token,
+ refresh_token: tokens.refresh_token,
+ expires_at: Number.isFinite(expiresIn)
+ ? Math.floor(Date.now() / 1000 + expiresIn)
+ : undefined,
+ },
+ connectionId,
+ logger,
+ });
+
+ return tokens.access_token;
+}
+
+async function refreshGoogleDriveToken(
+ connection: Pick,
+ logger: Logger,
+): Promise {
+ const { id: connectionId, refreshToken } = connection;
+
+ if (!refreshToken) {
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) {
+ throw new Error("Google login not enabled - missing credentials");
+ }
+
+ const response = await fetch("https://oauth2.googleapis.com/token", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ client_id: env.GOOGLE_CLIENT_ID,
+ client_secret: env.GOOGLE_CLIENT_SECRET,
+ refresh_token: refreshToken,
+ grant_type: "refresh_token",
+ }),
+ });
+
+ let tokens: OAuthTokenResponse;
+ try {
+ tokens = await response.json();
+ } catch {
+ logger.warn("Google drive token refresh returned non-JSON response", {
+ connectionId,
+ status: response.status,
+ });
+ await markDriveConnectionAsDisconnected(connectionId);
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ if (!response.ok) {
+ const errorMessage = tokens.error_description || "Failed to refresh token";
+ logger.warn("Google drive token refresh failed", {
+ connectionId,
+ error: errorMessage,
+ });
+ await markDriveConnectionAsDisconnected(connectionId);
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ if (!tokens.access_token) {
+ logger.warn("Google token refresh did not return access_token", {
+ connectionId,
+ });
+ await markDriveConnectionAsDisconnected(connectionId);
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ // Save new tokens (Google doesn't return a new refresh_token)
+ const expiresIn = Number(tokens.expires_in);
+ await saveDriveTokens({
+ tokens: {
+ access_token: tokens.access_token,
+ expires_at: Number.isFinite(expiresIn)
+ ? Math.floor(Date.now() / 1000 + expiresIn)
+ : undefined,
+ },
+ connectionId,
+ logger,
+ });
+
+ return tokens.access_token;
+}
+
+async function saveDriveTokens({
+ tokens,
+ connectionId,
+ logger,
+}: {
+ tokens: {
+ access_token?: string;
+ refresh_token?: string;
+ expires_at?: number; // seconds
+ };
+ connectionId: string;
+ logger: Logger;
+}) {
+ if (!tokens.access_token) {
+ logger.warn("No access token to save for drive connection", {
+ connectionId,
+ });
+ return;
+ }
+
+ try {
+ await prisma.driveConnection.update({
+ where: { id: connectionId },
+ data: {
+ accessToken: tokens.access_token,
+ ...(tokens.refresh_token && { refreshToken: tokens.refresh_token }),
+ expiresAt: tokens.expires_at
+ ? new Date(tokens.expires_at * 1000)
+ : null,
+ isConnected: true,
+ },
+ });
+
+ logger.info("Drive tokens saved successfully", { connectionId });
+ } catch (error) {
+ logger.error("Failed to save drive tokens", { error, connectionId });
+ throw error;
+ }
+}
+
+async function markDriveConnectionAsDisconnected(connectionId: string) {
+ await prisma.driveConnection.update({
+ where: { id: connectionId },
+ data: { isConnected: false },
+ });
+}
diff --git a/apps/web/utils/drive/providers/google-token.ts b/apps/web/utils/drive/providers/google-token.ts
new file mode 100644
index 0000000000..fe5a9005a1
--- /dev/null
+++ b/apps/web/utils/drive/providers/google-token.ts
@@ -0,0 +1,64 @@
+import type { DriveConnection } from "@/generated/prisma/client";
+import { env } from "@/env";
+import type { Logger } from "@/utils/logger";
+import { SafeError } from "@/utils/error";
+import {
+ saveDriveTokens,
+ markDriveConnectionAsDisconnected,
+} from "@/utils/drive/providers/token-helpers";
+
+export async function refreshGoogleDriveToken(
+ connection: Pick,
+ logger: Logger,
+): Promise {
+ const { id: connectionId, refreshToken } = connection;
+
+ if (!refreshToken) {
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) {
+ throw new Error("Google login not enabled - missing credentials");
+ }
+
+ const response = await fetch("https://oauth2.googleapis.com/token", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ client_id: env.GOOGLE_CLIENT_ID,
+ client_secret: env.GOOGLE_CLIENT_SECRET,
+ refresh_token: refreshToken,
+ grant_type: "refresh_token",
+ }),
+ });
+
+ const tokens = await response.json();
+
+ if (!response.ok) {
+ const errorMessage = tokens.error_description || "Failed to refresh token";
+ logger.warn("Google drive token refresh failed", {
+ connectionId,
+ error: errorMessage,
+ });
+ await markDriveConnectionAsDisconnected(connectionId);
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ // Save new tokens (Google doesn't return a new refresh_token)
+ await saveDriveTokens({
+ tokens: {
+ access_token: tokens.access_token,
+ expires_at: Math.floor(Date.now() / 1000 + Number(tokens.expires_in)),
+ },
+ connectionId,
+ logger,
+ });
+
+ return tokens.access_token;
+}
diff --git a/apps/web/utils/drive/providers/google.ts b/apps/web/utils/drive/providers/google.ts
new file mode 100644
index 0000000000..fce27a9f4c
--- /dev/null
+++ b/apps/web/utils/drive/providers/google.ts
@@ -0,0 +1,274 @@
+import { auth, drive, type drive_v3 } from "@googleapis/drive";
+import { Readable } from "node:stream";
+import { env } from "@/env";
+import type { Logger } from "@/utils/logger";
+import { createScopedLogger } from "@/utils/logger";
+import type {
+ DriveProvider,
+ DriveFolder,
+ DriveFile,
+ UploadFileParams,
+} from "@/utils/drive/types";
+
+export class GoogleDriveProvider implements DriveProvider {
+ readonly name = "google" as const;
+ private readonly client: drive_v3.Drive;
+ private readonly accessToken: string;
+ private readonly logger: Logger;
+
+ constructor(accessToken: string, logger?: Logger) {
+ this.accessToken = accessToken;
+ this.logger = (logger || createScopedLogger("google-drive-provider")).with({
+ provider: "google",
+ });
+
+ const googleAuth = new auth.OAuth2({
+ clientId: env.GOOGLE_CLIENT_ID,
+ clientSecret: env.GOOGLE_CLIENT_SECRET,
+ });
+ googleAuth.setCredentials({
+ access_token: accessToken,
+ });
+
+ this.client = drive({ version: "v3", auth: googleAuth });
+ }
+
+ toJSON() {
+ return { name: this.name, type: "GoogleDriveProvider" };
+ }
+
+ getAccessToken(): string {
+ return this.accessToken;
+ }
+
+ // -------------------------------------------------------------------------
+ // Folder Operations
+ // -------------------------------------------------------------------------
+
+ async listFolders(parentId?: string): Promise {
+ this.logger.trace("Listing folders", { parentId });
+
+ try {
+ // If no parentId, fetch ALL folders for better initial experience
+ const parent = parentId || null;
+ const escapedParent = parent ? this.escapeDriveQueryValue(parent) : null;
+ const query = escapedParent
+ ? `'${escapedParent}' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false`
+ : "mimeType = 'application/vnd.google-apps.folder' and trashed = false";
+
+ const allFiles: drive_v3.Schema$File[] = [];
+ let pageToken: string | undefined;
+
+ do {
+ const response = await this.client.files.list({
+ q: query,
+ fields: "nextPageToken, files(id, name, parents, webViewLink)",
+ pageSize: parent ? 100 : 1000,
+ orderBy: "name",
+ pageToken,
+ });
+
+ const files = response.data.files || [];
+ allFiles.push(...files);
+ pageToken = response.data.nextPageToken ?? undefined;
+ } while (pageToken);
+
+ return allFiles.map((file) => this.convertToFolder(file));
+ } catch (error) {
+ this.logger.error("Error listing folders", { error, parentId });
+ throw error;
+ }
+ }
+
+ async getFolder(folderId: string): Promise {
+ this.logger.trace("Getting folder", { folderId });
+
+ try {
+ const response = await this.client.files.get({
+ fileId: folderId,
+ fields: "id, name, parents, webViewLink, mimeType",
+ });
+
+ const file = response.data;
+
+ // Check if it's actually a folder
+ if (file.mimeType !== "application/vnd.google-apps.folder") {
+ this.logger.warn("Item is not a folder", { folderId });
+ return null;
+ }
+
+ return this.convertToFolder(file);
+ } catch (error) {
+ if (this.isNotFoundError(error)) {
+ this.logger.trace("Folder not found", { folderId });
+ return null;
+ }
+ this.logger.error("Error getting folder", { error, folderId });
+ throw error;
+ }
+ }
+
+ async createFolder(name: string, parentId?: string): Promise {
+ this.logger.info("Creating folder", { name, parentId });
+
+ try {
+ const response = await this.client.files.create({
+ requestBody: {
+ name,
+ mimeType: "application/vnd.google-apps.folder",
+ parents: parentId ? [parentId] : undefined,
+ },
+ fields: "id, name, parents, webViewLink",
+ });
+
+ return this.convertToFolder(response.data);
+ } catch (error) {
+ this.logger.error("Error creating folder", { error, name, parentId });
+ throw error;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // File Operations
+ // -------------------------------------------------------------------------
+
+ async uploadFile(params: UploadFileParams): Promise {
+ const { filename, mimeType, content, folderId } = params;
+ this.logger.info("Uploading file", {
+ filename,
+ mimeType,
+ folderId,
+ size: content.length,
+ });
+
+ try {
+ // Convert Buffer to Readable stream for the API
+ const stream = Readable.from(content);
+
+ const response = await this.client.files.create({
+ requestBody: {
+ name: filename,
+ parents: [folderId],
+ },
+ media: {
+ mimeType,
+ body: stream,
+ },
+ fields: "id, name, mimeType, size, parents, webViewLink, createdTime",
+ });
+
+ return this.convertToFile(response.data);
+ } catch (error) {
+ this.logger.error("Error uploading file", { error, filename, folderId });
+ throw error;
+ }
+ }
+
+ async getFile(fileId: string): Promise {
+ this.logger.trace("Getting file", { fileId });
+
+ try {
+ const response = await this.client.files.get({
+ fileId,
+ fields: "id, name, mimeType, size, parents, webViewLink, createdTime",
+ });
+
+ const file = response.data;
+
+ // Check it's not a folder
+ if (file.mimeType === "application/vnd.google-apps.folder") {
+ this.logger.warn("Item is a folder, not a file", { fileId });
+ return null;
+ }
+
+ return this.convertToFile(file);
+ } catch (error) {
+ if (this.isNotFoundError(error)) {
+ this.logger.trace("File not found", { fileId });
+ return null;
+ }
+ this.logger.error("Error getting file", { error, fileId });
+ throw error;
+ }
+ }
+
+ async moveFile(fileId: string, targetFolderId: string): Promise {
+ this.logger.info("Moving file", { fileId, targetFolderId });
+
+ try {
+ // First get current parents
+ const file = await this.client.files.get({
+ fileId,
+ fields: "parents",
+ });
+
+ const previousParents = file.data.parents?.join(",") || "";
+
+ // Move by updating parents
+ const response = await this.client.files.update({
+ fileId,
+ addParents: targetFolderId,
+ removeParents: previousParents,
+ fields: "id, name, mimeType, size, parents, webViewLink, createdTime",
+ });
+
+ this.logger.info("File moved", { fileId, targetFolderId });
+ return this.convertToFile(response.data);
+ } catch (error) {
+ this.logger.error("Error moving file", { error, fileId, targetFolderId });
+ throw error;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private convertToFolder(file: drive_v3.Schema$File): DriveFolder {
+ if (!file.id) {
+ throw new Error("Drive folder is missing id");
+ }
+ return {
+ id: file.id,
+ name: file.name || "Untitled",
+ parentId: file.parents?.[0] ?? undefined,
+ // Google Drive doesn't provide full path directly, would need recursive calls
+ path: undefined,
+ webUrl: file.webViewLink ?? undefined,
+ };
+ }
+
+ private convertToFile(file: drive_v3.Schema$File): DriveFile {
+ if (!file.id) {
+ throw new Error("Drive file is missing id");
+ }
+ return {
+ id: file.id,
+ name: file.name || "Untitled",
+ mimeType: file.mimeType || "application/octet-stream",
+ size: file.size ? Number.parseInt(file.size) : undefined,
+ folderId: file.parents?.[0] ?? undefined,
+ webUrl: file.webViewLink ?? undefined,
+ createdAt: file.createdTime ? new Date(file.createdTime) : undefined,
+ };
+ }
+
+ private isNotFoundError(error: unknown): boolean {
+ // Check status first as @googleapis/drive sets code to strings like "ENOTFOUND"
+ if (error && typeof error === "object" && "status" in error) {
+ return (error as { status: number }).status === 404;
+ }
+ if (error && typeof error === "object" && "code" in error) {
+ return (error as { code: number }).code === 404;
+ }
+ return false;
+ }
+
+ /**
+ * Escapes a value for use in Google Drive query syntax.
+ * Must escape backslashes first, then single quotes.
+ */
+ private escapeDriveQueryValue(value: string): string {
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
+ }
+}
diff --git a/apps/web/utils/drive/providers/microsoft-token.ts b/apps/web/utils/drive/providers/microsoft-token.ts
new file mode 100644
index 0000000000..9147f0e8ef
--- /dev/null
+++ b/apps/web/utils/drive/providers/microsoft-token.ts
@@ -0,0 +1,70 @@
+import type { DriveConnection } from "@/generated/prisma/client";
+import { env } from "@/env";
+import type { Logger } from "@/utils/logger";
+import { SafeError } from "@/utils/error";
+import { MICROSOFT_DRIVE_SCOPES } from "@/utils/drive/scopes";
+import {
+ saveDriveTokens,
+ markDriveConnectionAsDisconnected,
+} from "@/utils/drive/providers/token-helpers";
+
+export async function refreshMicrosoftDriveToken(
+ connection: Pick,
+ logger: Logger,
+): Promise {
+ const { id: connectionId, refreshToken } = connection;
+
+ if (!refreshToken) {
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ if (!env.MICROSOFT_CLIENT_ID || !env.MICROSOFT_CLIENT_SECRET) {
+ throw new Error("Microsoft login not enabled - missing credentials");
+ }
+
+ const response = await fetch(
+ `https://login.microsoftonline.com/${env.MICROSOFT_TENANT_ID}/oauth2/v2.0/token`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ client_id: env.MICROSOFT_CLIENT_ID,
+ client_secret: env.MICROSOFT_CLIENT_SECRET,
+ refresh_token: refreshToken,
+ grant_type: "refresh_token",
+ scope: MICROSOFT_DRIVE_SCOPES.join(" "),
+ }),
+ },
+ );
+
+ const tokens = await response.json();
+
+ if (!response.ok) {
+ const errorMessage = tokens.error_description || "Failed to refresh token";
+ logger.warn("Microsoft drive token refresh failed", {
+ connectionId,
+ error: errorMessage,
+ });
+ await markDriveConnectionAsDisconnected(connectionId);
+ throw new SafeError(
+ "Unable to access your drive. Please reconnect your drive and try again.",
+ );
+ }
+
+ // Save new tokens
+ await saveDriveTokens({
+ tokens: {
+ access_token: tokens.access_token,
+ refresh_token: tokens.refresh_token,
+ expires_at: Math.floor(Date.now() / 1000 + Number(tokens.expires_in)),
+ },
+ connectionId,
+ logger,
+ });
+
+ return tokens.access_token;
+}
diff --git a/apps/web/utils/drive/providers/microsoft.ts b/apps/web/utils/drive/providers/microsoft.ts
new file mode 100644
index 0000000000..0c7bdde801
--- /dev/null
+++ b/apps/web/utils/drive/providers/microsoft.ts
@@ -0,0 +1,239 @@
+import { Client } from "@microsoft/microsoft-graph-client";
+import type { DriveItem } from "@microsoft/microsoft-graph-types";
+import type { Logger } from "@/utils/logger";
+import { createScopedLogger } from "@/utils/logger";
+import { isNotFoundError } from "@/utils/outlook/errors";
+import type {
+ DriveProvider,
+ DriveFolder,
+ DriveFile,
+ UploadFileParams,
+} from "@/utils/drive/types";
+
+export class OneDriveProvider implements DriveProvider {
+ readonly name = "microsoft" as const;
+ private readonly client: Client;
+ private readonly accessToken: string;
+ private readonly logger: Logger;
+
+ constructor(accessToken: string, logger?: Logger) {
+ this.accessToken = accessToken;
+ this.logger = (logger || createScopedLogger("onedrive-provider")).with({
+ provider: "microsoft",
+ });
+
+ this.client = Client.init({
+ authProvider: (done) => {
+ done(null, this.accessToken);
+ },
+ defaultVersion: "v1.0",
+ });
+ }
+
+ toJSON() {
+ return { name: this.name, type: "OneDriveProvider" };
+ }
+
+ getAccessToken(): string {
+ return this.accessToken;
+ }
+
+ // -------------------------------------------------------------------------
+ // Folder Operations
+ // -------------------------------------------------------------------------
+
+ async listFolders(parentId?: string): Promise {
+ this.logger.trace("Listing folders", { parentId });
+
+ try {
+ const endpoint = parentId
+ ? `/me/drive/items/${parentId}/children`
+ : "/me/drive/root/children";
+
+ const response = await this.client
+ .api(endpoint)
+ .filter("folder ne null") // Only get folders, not files
+ .select("id,name,parentReference,webUrl")
+ .top(200) // Increase limit for better visibility
+ .get();
+
+ const items: DriveItem[] = response.value || [];
+
+ return items.map((item) => this.convertToFolder(item));
+ } catch (error) {
+ this.logger.error("Error listing folders", { error, parentId });
+ throw error;
+ }
+ }
+
+ async getFolder(folderId: string): Promise {
+ this.logger.trace("Getting folder", { folderId });
+
+ try {
+ const item: DriveItem = await this.client
+ .api(`/me/drive/items/${folderId}`)
+ .select("id,name,parentReference,webUrl")
+ .get();
+
+ if (!item.folder) {
+ this.logger.warn("Item is not a folder", { folderId });
+ return null;
+ }
+
+ return this.convertToFolder(item);
+ } catch (error) {
+ // Handle not found
+ if (isNotFoundError(error)) {
+ this.logger.trace("Folder not found", { folderId });
+ return null;
+ }
+ this.logger.error("Error getting folder", { error, folderId });
+ throw error;
+ }
+ }
+
+ async createFolder(name: string, parentId?: string): Promise {
+ this.logger.info("Creating folder", { name, parentId });
+
+ try {
+ const endpoint = parentId
+ ? `/me/drive/items/${parentId}/children`
+ : "/me/drive/root/children";
+
+ const item: DriveItem = await this.client.api(endpoint).post({
+ name,
+ folder: {},
+ "@microsoft.graph.conflictBehavior": "rename", // Rename if exists
+ });
+
+ return this.convertToFolder(item);
+ } catch (error) {
+ this.logger.error("Error creating folder", { error, name, parentId });
+ throw error;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // File Operations
+ // -------------------------------------------------------------------------
+
+ async uploadFile(params: UploadFileParams): Promise {
+ const { filename, mimeType, content, folderId } = params;
+ this.logger.info("Uploading file", {
+ filename,
+ mimeType,
+ folderId,
+ size: content.length,
+ });
+
+ try {
+ // For files up to 4MB, use simple upload
+ // For larger files, would need to use upload session (not implemented yet)
+ const MAX_SIMPLE_UPLOAD_SIZE = 4 * 1024 * 1024; // 4MB
+
+ if (content.length > MAX_SIMPLE_UPLOAD_SIZE) {
+ // TODO: Implement resumable upload for large files
+ this.logger.warn("File exceeds simple upload limit", {
+ filename,
+ size: content.length,
+ limit: MAX_SIMPLE_UPLOAD_SIZE,
+ });
+ throw new Error(
+ `File size ${content.length} exceeds 4MB limit. Large file upload not yet implemented.`,
+ );
+ }
+
+ // Use the PUT endpoint for simple upload
+ // Path: /me/drive/items/{parent-id}:/{filename}:/content
+ const item: DriveItem = await this.client
+ .api(
+ `/me/drive/items/${folderId}:/${encodeURIComponent(filename)}:/content`,
+ )
+ .header("Content-Type", mimeType)
+ .put(content);
+
+ return this.convertToFile(item);
+ } catch (error) {
+ this.logger.error("Error uploading file", { error, filename, folderId });
+ throw error;
+ }
+ }
+
+ async getFile(fileId: string): Promise {
+ this.logger.trace("Getting file", { fileId });
+
+ try {
+ const item: DriveItem = await this.client
+ .api(`/me/drive/items/${fileId}`)
+ .select("id,name,file,size,parentReference,webUrl,createdDateTime")
+ .get();
+
+ if (!item.file) {
+ this.logger.warn("Item is not a file", { fileId });
+ return null;
+ }
+
+ return this.convertToFile(item);
+ } catch (error) {
+ if (isNotFoundError(error)) {
+ this.logger.trace("File not found", { fileId });
+ return null;
+ }
+ this.logger.error("Error getting file", { error, fileId });
+ throw error;
+ }
+ }
+
+ async moveFile(fileId: string, targetFolderId: string): Promise {
+ this.logger.info("Moving file", { fileId, targetFolderId });
+
+ try {
+ const item: DriveItem = await this.client
+ .api(`/me/drive/items/${fileId}`)
+ .patch({ parentReference: { id: targetFolderId } });
+
+ this.logger.info("File moved", { fileId, targetFolderId });
+ return this.convertToFile(item);
+ } catch (error) {
+ this.logger.error("Error moving file", { error, fileId, targetFolderId });
+ throw error;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private convertToFolder(item: DriveItem): DriveFolder {
+ if (!item.id) {
+ throw new Error("Drive item is missing `id`");
+ }
+ const name = item.name || "Untitled";
+ return {
+ id: item.id ?? "",
+ name,
+ parentId: item.parentReference?.id ?? undefined,
+ path: item.parentReference?.path
+ ? `${item.parentReference.path}/${name}`
+ : undefined,
+ webUrl: item.webUrl ?? undefined,
+ };
+ }
+
+ private convertToFile(item: DriveItem): DriveFile {
+ if (!item.id) {
+ throw new Error("Drive item is missing `id`");
+ }
+ return {
+ id: item.id,
+ name: item.name || "Untitled",
+ mimeType: item.file?.mimeType ?? "application/octet-stream",
+ size: item.size ?? undefined,
+ folderId: item.parentReference?.id ?? undefined,
+ webUrl: item.webUrl ?? undefined,
+ createdAt: item.createdDateTime
+ ? new Date(item.createdDateTime)
+ : undefined,
+ };
+ }
+}
diff --git a/apps/web/utils/drive/providers/token-helpers.ts b/apps/web/utils/drive/providers/token-helpers.ts
new file mode 100644
index 0000000000..8200c3abf5
--- /dev/null
+++ b/apps/web/utils/drive/providers/token-helpers.ts
@@ -0,0 +1,49 @@
+import prisma from "@/utils/prisma";
+import type { Logger } from "@/utils/logger";
+
+export async function saveDriveTokens({
+ tokens,
+ connectionId,
+ logger,
+}: {
+ tokens: {
+ access_token?: string;
+ refresh_token?: string;
+ expires_at?: number; // seconds
+ };
+ connectionId: string;
+ logger: Logger;
+}) {
+ if (!tokens.access_token) {
+ logger.warn("No access token to save for drive connection", {
+ connectionId,
+ });
+ return;
+ }
+
+ try {
+ await prisma.driveConnection.update({
+ where: { id: connectionId },
+ data: {
+ accessToken: tokens.access_token,
+ ...(tokens.refresh_token && { refreshToken: tokens.refresh_token }),
+ expiresAt: tokens.expires_at
+ ? new Date(tokens.expires_at * 1000)
+ : null,
+ isConnected: true,
+ },
+ });
+
+ logger.info("Drive tokens saved successfully", { connectionId });
+ } catch (error) {
+ logger.error("Failed to save drive tokens", { error, connectionId });
+ throw error;
+ }
+}
+
+export async function markDriveConnectionAsDisconnected(connectionId: string) {
+ await prisma.driveConnection.update({
+ where: { id: connectionId },
+ data: { isConnected: false },
+ });
+}
diff --git a/apps/web/utils/drive/scopes.ts b/apps/web/utils/drive/scopes.ts
new file mode 100644
index 0000000000..effe291441
--- /dev/null
+++ b/apps/web/utils/drive/scopes.ts
@@ -0,0 +1,24 @@
+// Microsoft Graph Drive scopes
+// https://learn.microsoft.com/en-us/graph/permissions-reference#files-permissions
+
+export const MICROSOFT_DRIVE_SCOPES = [
+ "openid",
+ "profile",
+ "email",
+ "User.Read",
+ "offline_access", // Required for refresh tokens
+ "Files.ReadWrite", // Read and write files in user's OneDrive
+ // Note: We intentionally don't request Files.ReadWrite.All (all files user can access)
+ // to minimize permissions. Files.ReadWrite covers OneDrive + shared files.
+] as const;
+
+// Google Drive scopes
+// https://developers.google.com/drive/api/guides/api-specific-auth
+
+export const GOOGLE_DRIVE_SCOPES = [
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/drive.file", // Access files created by or opened with the app
+ // Note: We use drive.file instead of drive (full access) to minimize permissions
+ // This allows us to create files and access files the user explicitly opens with our app
+] as const;
diff --git a/apps/web/utils/drive/types.ts b/apps/web/utils/drive/types.ts
new file mode 100644
index 0000000000..12ee5b5157
--- /dev/null
+++ b/apps/web/utils/drive/types.ts
@@ -0,0 +1,122 @@
+// ============================================================================
+// Core Drive Types
+// ============================================================================
+
+export type DriveProviderType = "google" | "microsoft";
+
+export interface DriveFolder {
+ id: string;
+ name: string;
+ path?: string; // Full path for display (e.g., "/Projects/Acme Corp")
+ parentId?: string;
+ webUrl?: string; // Link to open in browser
+}
+
+export interface DriveFile {
+ id: string;
+ name: string;
+ mimeType: string;
+ size?: number;
+ folderId?: string;
+ webUrl?: string; // Link to open in browser
+ createdAt?: Date;
+}
+
+export interface UploadFileParams {
+ filename: string;
+ mimeType: string;
+ content: Buffer;
+ folderId: string;
+}
+
+// ============================================================================
+// Drive Provider Interface
+// ============================================================================
+
+/**
+ * Abstraction for cloud drive operations (Google Drive / OneDrive)
+ * Follows the same pattern as EmailProvider.
+ *
+ * Note: We intentionally don't include delete operations to minimize
+ * permissions requested from users. "Undo" is handled by marking
+ * the filing as rejected in our database - the file stays in their drive.
+ */
+export interface DriveProvider {
+ readonly name: DriveProviderType;
+
+ /**
+ * For serialization/debugging
+ */
+ toJSON(): { name: string; type: string };
+
+ // -------------------------------------------------------------------------
+ // Folder Operations
+ // -------------------------------------------------------------------------
+
+ /**
+ * List folders in a parent folder (or root if no parentId)
+ */
+ listFolders(parentId?: string): Promise;
+
+ /**
+ * Get a specific folder by ID
+ */
+ getFolder(folderId: string): Promise;
+
+ /**
+ * Create a new folder
+ */
+ createFolder(name: string, parentId?: string): Promise;
+
+ // -------------------------------------------------------------------------
+ // File Operations
+ // -------------------------------------------------------------------------
+
+ /**
+ * Upload a file to a folder
+ */
+ uploadFile(params: UploadFileParams): Promise;
+
+ /**
+ * Get file metadata by ID
+ */
+ getFile(fileId: string): Promise;
+
+ /**
+ * Move a file to a different folder
+ */
+ moveFile(fileId: string, targetFolderId: string): Promise;
+
+ // -------------------------------------------------------------------------
+ // Token Management
+ // -------------------------------------------------------------------------
+
+ /**
+ * Get the current access token (may trigger refresh if expired)
+ */
+ getAccessToken(): string;
+}
+
+// ============================================================================
+// OAuth Types
+// ============================================================================
+
+/**
+ * Tokens returned from OAuth code exchange.
+ * Used in callback routes when setting up a new DriveConnection.
+ */
+export interface DriveTokens {
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: Date | null;
+ email: string;
+}
+
+/**
+ * State passed through OAuth flow to identify the user/account.
+ */
+export interface DriveOAuthState {
+ emailAccountId: string;
+ type: "drive";
+ nonce: string;
+}
diff --git a/apps/web/utils/drive/url.test.ts b/apps/web/utils/drive/url.test.ts
new file mode 100644
index 0000000000..cc7503d25f
--- /dev/null
+++ b/apps/web/utils/drive/url.test.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect } from "vitest";
+import { getDriveFileUrl } from "./url";
+
+describe("getDriveFileUrl", () => {
+ it("should return Google Drive URL for google provider", () => {
+ expect(getDriveFileUrl("file123", "google")).toBe(
+ "https://drive.google.com/file/d/file123/view",
+ );
+ });
+
+ it("should return OneDrive URL for microsoft provider", () => {
+ expect(getDriveFileUrl("file456", "microsoft")).toBe(
+ "https://onedrive.live.com/?id=file456",
+ );
+ });
+
+ it("should return empty string for unknown provider", () => {
+ expect(getDriveFileUrl("file789", "unknown")).toBe("");
+ });
+
+ it("should handle file IDs with special characters", () => {
+ const fileId = "1a2b3c-4d5e6f_7g8h9i";
+ expect(getDriveFileUrl(fileId, "google")).toBe(
+ `https://drive.google.com/file/d/${fileId}/view`,
+ );
+ });
+});
diff --git a/apps/web/utils/drive/url.ts b/apps/web/utils/drive/url.ts
new file mode 100644
index 0000000000..0b6e9f942a
--- /dev/null
+++ b/apps/web/utils/drive/url.ts
@@ -0,0 +1,19 @@
+import { captureException } from "@/utils/error";
+import type { DriveProviderType } from "./types";
+
+export function getDriveFileUrl(
+ fileId: string,
+ provider: DriveProviderType,
+): string {
+ switch (provider) {
+ case "google":
+ return `https://drive.google.com/file/d/${fileId}/view`;
+ case "microsoft":
+ return `https://onedrive.live.com/?id=${fileId}`;
+ default: {
+ captureException(new Error("Invalid provider"), { extra: { provider } });
+ const exhaustiveCheck: never = provider;
+ return exhaustiveCheck;
+ }
+ }
+}
diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts
index 1d96b7efd3..f0b1339d62 100644
--- a/apps/web/utils/email/google.ts
+++ b/apps/web/utils/email/google.ts
@@ -858,7 +858,11 @@ export class GmailProvider implements EmailProvider {
addLabelIds?: string[];
removeLabelIds?: string[];
}) {
- return createFilter({ gmail: this.client, ...options });
+ return createFilter({
+ gmail: this.client,
+ ...options,
+ logger: this.logger,
+ });
}
async createAutoArchiveFilter(options: {
@@ -869,6 +873,7 @@ export class GmailProvider implements EmailProvider {
gmail: this.client,
from: options.from,
gmailLabelId: options.gmailLabelId,
+ logger: this.logger,
});
}
@@ -916,6 +921,17 @@ export class GmailProvider implements EmailProvider {
};
}
+ async getMessagesWithAttachments(options: {
+ maxResults?: number;
+ pageToken?: string;
+ }): Promise<{ messages: ParsedMessage[]; nextPageToken?: string }> {
+ return this.getMessagesWithPagination({
+ query: "has:attachment",
+ maxResults: options.maxResults,
+ pageToken: options.pageToken,
+ });
+ }
+
async getMessagesFromSender(options: {
senderEmail: string;
maxResults?: number;
diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts
index 355e00f29c..3e4ce5ff69 100644
--- a/apps/web/utils/email/microsoft.ts
+++ b/apps/web/utils/email/microsoft.ts
@@ -5,6 +5,7 @@ import {
getMessage,
getMessages,
queryBatchMessages,
+ queryMessagesWithAttachments,
getFolderIds,
convertMessage,
MESSAGE_SELECT_FIELDS,
@@ -934,6 +935,20 @@ export class OutlookProvider implements EmailProvider {
};
}
+ async getMessagesWithAttachments(options: {
+ maxResults?: number;
+ pageToken?: string;
+ }): Promise<{ messages: ParsedMessage[]; nextPageToken?: string }> {
+ return queryMessagesWithAttachments(
+ this.client,
+ {
+ maxResults: options.maxResults,
+ pageToken: options.pageToken,
+ },
+ this.logger,
+ );
+ }
+
async getMessagesFromSender(options: {
senderEmail: string;
maxResults?: number;
diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts
index 95b5234457..e634adef77 100644
--- a/apps/web/utils/email/types.ts
+++ b/apps/web/utils/email/types.ts
@@ -181,6 +181,13 @@ export interface EmailProvider {
messages: ParsedMessage[];
nextPageToken?: string;
}>;
+ getMessagesWithAttachments(options: {
+ maxResults?: number;
+ pageToken?: string;
+ }): Promise<{
+ messages: ParsedMessage[];
+ nextPageToken?: string;
+ }>;
getMessagesFromSender(options: {
senderEmail: string;
maxResults?: number;
diff --git a/apps/web/utils/filebot/is-filebot-email.test.ts b/apps/web/utils/filebot/is-filebot-email.test.ts
new file mode 100644
index 0000000000..351b867458
--- /dev/null
+++ b/apps/web/utils/filebot/is-filebot-email.test.ts
@@ -0,0 +1,132 @@
+import { describe, it, expect } from "vitest";
+import { isFilebotEmail, getFilebotEmail } from "./is-filebot-email";
+
+describe("isFilebotEmail", () => {
+ it("should return true for valid filebot email", () => {
+ const result = isFilebotEmail({
+ userEmail: "john@example.com",
+ emailToCheck: "john+ai@example.com",
+ });
+ expect(result).toBe(true);
+ });
+
+ it("should return false when recipient is different user", () => {
+ const result = isFilebotEmail({
+ userEmail: "john@example.com",
+ emailToCheck: "jane+ai@example.com",
+ });
+ expect(result).toBe(false);
+ });
+
+ it("should return false for plain email without filebot suffix", () => {
+ const result = isFilebotEmail({
+ userEmail: "john@example.com",
+ emailToCheck: "john@example.com",
+ });
+ expect(result).toBe(false);
+ });
+
+ it("should return false for email with token suffix (old format)", () => {
+ const result = isFilebotEmail({
+ userEmail: "john@example.com",
+ emailToCheck: "john+ai-abc123@example.com",
+ });
+ expect(result).toBe(false);
+ });
+
+ it("should handle email addresses with dots", () => {
+ const result = isFilebotEmail({
+ userEmail: "john.doe@sub.example.com",
+ emailToCheck: "john.doe+ai@sub.example.com",
+ });
+ expect(result).toBe(true);
+ });
+
+ it("should handle display name with angle brackets", () => {
+ const result = isFilebotEmail({
+ userEmail: "john@example.com",
+ emailToCheck: "John Doe ",
+ });
+ expect(result).toBe(true);
+ });
+
+ it("should reject malicious domain injection", () => {
+ const result = isFilebotEmail({
+ userEmail: "john@example.com",
+ emailToCheck: "john+ai@evil.com+ai@example.com",
+ });
+ expect(result).toBe(false);
+ });
+
+ it("should reject case manipulation", () => {
+ const result = isFilebotEmail({
+ userEmail: "john@example.com",
+ emailToCheck: "john+AI@example.com",
+ });
+ expect(result).toBe(false);
+ });
+
+ it("should handle invalid userEmail format gracefully", () => {
+ const result = isFilebotEmail({
+ userEmail: "notanemail",
+ emailToCheck: "john+ai@example.com",
+ });
+ expect(result).toBe(false);
+ });
+
+ it("should handle domain case insensitivity", () => {
+ const result = isFilebotEmail({
+ userEmail: "john@example.com",
+ emailToCheck: "john+ai@EXAMPLE.COM",
+ });
+ expect(result).toBe(true);
+ });
+
+ it("should detect filebot email when not first in multiple recipients", () => {
+ const result = isFilebotEmail({
+ userEmail: "john@example.com",
+ emailToCheck: "alice@example.com, john+ai@example.com",
+ });
+ expect(result).toBe(true);
+ });
+
+ it("should detect filebot email in middle of multiple recipients", () => {
+ const result = isFilebotEmail({
+ userEmail: "john@example.com",
+ emailToCheck: "alice@example.com, john+ai@example.com, bob@example.com",
+ });
+ expect(result).toBe(true);
+ });
+
+ it("should detect filebot email with display names in multiple recipients", () => {
+ const result = isFilebotEmail({
+ userEmail: "john@example.com",
+ emailToCheck: "Alice , John Doe ",
+ });
+ expect(result).toBe(true);
+ });
+});
+
+describe("getFilebotEmail", () => {
+ it("should generate correct filebot email", () => {
+ const result = getFilebotEmail({
+ userEmail: "john@example.com",
+ });
+ expect(result).toBe("john+ai@example.com");
+ });
+
+ it("should handle email with dots", () => {
+ const result = getFilebotEmail({
+ userEmail: "john.doe@sub.example.com",
+ });
+ expect(result).toBe("john.doe+ai@sub.example.com");
+ });
+
+ it("should throw for invalid userEmail format", () => {
+ expect(() =>
+ getFilebotEmail({
+ userEmail: "notanemail",
+ }),
+ ).toThrow("Invalid email format");
+ });
+});
diff --git a/apps/web/utils/filebot/is-filebot-email.ts b/apps/web/utils/filebot/is-filebot-email.ts
new file mode 100644
index 0000000000..36cbb9155f
--- /dev/null
+++ b/apps/web/utils/filebot/is-filebot-email.ts
@@ -0,0 +1,74 @@
+import { env } from "@/env";
+import { extractEmailAddress } from "@/utils/email";
+
+// In prod: hello+ai@example.com
+// In dev: hello+ai-test@example.com
+const FILEBOT_SUFFIX = `ai${env.NODE_ENV === "development" ? "-test" : ""}`;
+
+/**
+ * Check if any recipient in the email is a filebot reply address.
+ * Pattern: user+ai@domain.com (or user+ai-test@domain.com in dev)
+ * Handles multiple recipients in the To field (comma-separated).
+ */
+export function isFilebotEmail({
+ userEmail,
+ emailToCheck,
+}: {
+ userEmail: string;
+ emailToCheck: string;
+}): boolean {
+ if (!emailToCheck) return false;
+
+ const [localPart, domain] = userEmail.split("@");
+ if (!localPart || !domain) return false;
+
+ const pattern = buildFilebotPattern(localPart, domain);
+
+ // Split by comma to handle multiple recipients in To field
+ const recipients = emailToCheck.split(",");
+
+ for (const recipient of recipients) {
+ const extractedEmail = extractEmailAddress(recipient.trim());
+ if (extractedEmail && pattern.test(extractedEmail)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Generate a filebot reply-to email address.
+ * Returns: user+filebot@domain.com
+ */
+export function getFilebotEmail({ userEmail }: { userEmail: string }): string {
+ const [localPart, domain] = userEmail.split("@");
+ if (!localPart || !domain) {
+ throw new Error("Invalid email format");
+ }
+ return `${localPart}+${FILEBOT_SUFFIX}@${domain}`;
+}
+
+/**
+ * Build a regex pattern for filebot emails.
+ * Domain is case-insensitive (per email standards), but the filebot suffix is case-sensitive for security.
+ */
+function buildFilebotPattern(localPart: string, domain: string): RegExp {
+ // Make domain case-insensitive by matching either case for each letter
+ const caseInsensitiveDomain = domain
+ .split("")
+ .map((char) => {
+ if (/[a-zA-Z]/.test(char)) {
+ return `[${char.toLowerCase()}${char.toUpperCase()}]`;
+ }
+ return escapeRegex(char);
+ })
+ .join("");
+ return new RegExp(
+ `^${escapeRegex(localPart)}\\+${FILEBOT_SUFFIX}@${caseInsensitiveDomain}$`,
+ );
+}
+
+function escapeRegex(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
diff --git a/apps/web/utils/gmail/filter.ts b/apps/web/utils/gmail/filter.ts
index 12935c3423..15dc637c6f 100644
--- a/apps/web/utils/gmail/filter.ts
+++ b/apps/web/utils/gmail/filter.ts
@@ -1,14 +1,17 @@
import type { gmail_v1 } from "@googleapis/gmail";
import { GmailLabel } from "@/utils/gmail/label";
import { extractErrorInfo, withGmailRetry } from "@/utils/gmail/retry";
+import { SafeError } from "@/utils/error";
+import type { Logger } from "@/utils/logger";
export async function createFilter(options: {
gmail: gmail_v1.Gmail;
from: string;
addLabelIds?: string[];
removeLabelIds?: string[];
+ logger: Logger;
}) {
- const { gmail, from, addLabelIds, removeLabelIds } = options;
+ const { gmail, from, addLabelIds, removeLabelIds, logger } = options;
try {
return await withGmailRetry(() =>
@@ -25,6 +28,33 @@ export async function createFilter(options: {
);
} catch (error) {
if (isFilterExistsError(error)) return { status: 200 };
+
+ const errorInfo = extractErrorInfo(error);
+
+ logger.error("Failed to create Gmail filter", {
+ from,
+ addLabelIds,
+ removeLabelIds,
+ error,
+ });
+
+ // Check if it might be a filter limit issue
+ if (errorInfo.status === 500) {
+ try {
+ const filters = await getFiltersList({ gmail });
+ const filterCount = filters.data?.filter?.length ?? 0;
+ if (filterCount >= 990) {
+ throw new SafeError(
+ `Gmail filter limit reached (${filterCount}/1000 filters). Please delete some existing filters in Gmail settings.`,
+ );
+ }
+ } catch (limitCheckError) {
+ if (limitCheckError instanceof SafeError) throw limitCheckError;
+ // If limit check fails, just log and continue with original error
+ logger.warn("Failed to check filter count", { error: limitCheckError });
+ }
+ }
+
throw error;
}
}
@@ -33,10 +63,12 @@ export async function createAutoArchiveFilter({
gmail,
from,
gmailLabelId,
+ logger,
}: {
gmail: gmail_v1.Gmail;
from: string;
gmailLabelId?: string;
+ logger: Logger;
}) {
try {
return await createFilter({
@@ -44,6 +76,7 @@ export async function createAutoArchiveFilter({
from,
removeLabelIds: [GmailLabel.INBOX],
addLabelIds: gmailLabelId ? [gmailLabelId] : undefined,
+ logger,
});
} catch (error) {
if (isFilterExistsError(error)) return { status: 200 };
diff --git a/apps/web/utils/oauth/redirect.ts b/apps/web/utils/oauth/redirect.ts
new file mode 100644
index 0000000000..009793f584
--- /dev/null
+++ b/apps/web/utils/oauth/redirect.ts
@@ -0,0 +1,41 @@
+import { NextResponse } from "next/server";
+
+/**
+ * Custom error class for OAuth redirect responses.
+ * Thrown when we need to redirect with an error during OAuth flow.
+ */
+export class RedirectError extends Error {
+ redirectUrl: URL;
+ responseHeaders: Headers;
+
+ constructor(redirectUrl: URL, responseHeaders: Headers) {
+ super("Redirect required");
+ this.name = "RedirectError";
+ this.redirectUrl = redirectUrl;
+ this.responseHeaders = responseHeaders;
+ }
+}
+
+/**
+ * Redirect with a success message query param
+ */
+export function redirectWithMessage(
+ redirectUrl: URL,
+ message: string,
+ responseHeaders: Headers,
+): NextResponse {
+ redirectUrl.searchParams.set("message", message);
+ return NextResponse.redirect(redirectUrl, { headers: responseHeaders });
+}
+
+/**
+ * Redirect with an error query param
+ */
+export function redirectWithError(
+ redirectUrl: URL,
+ error: string,
+ responseHeaders: Headers,
+): NextResponse {
+ redirectUrl.searchParams.set("error", error);
+ return NextResponse.redirect(redirectUrl, { headers: responseHeaders });
+}
diff --git a/apps/web/utils/oauth/verify.test.ts b/apps/web/utils/oauth/verify.test.ts
new file mode 100644
index 0000000000..d93f0ea007
--- /dev/null
+++ b/apps/web/utils/oauth/verify.test.ts
@@ -0,0 +1,135 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { verifyEmailAccountAccess } from "./verify";
+import { RedirectError } from "./redirect";
+import prisma from "@/utils/__mocks__/prisma";
+import { createScopedLogger } from "@/utils/logger";
+
+vi.mock("server-only", () => ({}));
+vi.mock("@/utils/prisma");
+vi.mock("@/utils/auth", () => ({
+ auth: vi.fn(),
+}));
+
+import { auth } from "@/utils/auth";
+
+const mockAuth = vi.mocked(auth);
+const logger = createScopedLogger("test");
+
+describe("verifyEmailAccountAccess", () => {
+ const emailAccountId = "email-account-123";
+ const userId = "user-123";
+ const redirectUrl = new URL("http://localhost:3000/callback");
+ const responseHeaders = new Headers();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should return userId when session and email account are valid", async () => {
+ mockAuth.mockResolvedValue({
+ user: { id: userId },
+ } as any);
+
+ prisma.emailAccount.findFirst.mockResolvedValue({
+ id: emailAccountId,
+ } as any);
+
+ const result = await verifyEmailAccountAccess(
+ emailAccountId,
+ logger,
+ redirectUrl,
+ responseHeaders,
+ );
+
+ expect(result).toEqual({ userId });
+ expect(mockAuth).toHaveBeenCalledOnce();
+ expect(prisma.emailAccount.findFirst).toHaveBeenCalledWith({
+ where: {
+ id: emailAccountId,
+ userId,
+ },
+ select: { id: true },
+ });
+ });
+
+ it("should throw RedirectError with unauthorized when no session", async () => {
+ mockAuth.mockResolvedValue(null);
+
+ try {
+ await verifyEmailAccountAccess(
+ emailAccountId,
+ logger,
+ redirectUrl,
+ responseHeaders,
+ );
+ expect.fail("Should have thrown RedirectError");
+ } catch (error) {
+ expect(error).toBeInstanceOf(RedirectError);
+ if (error instanceof RedirectError) {
+ expect(error.redirectUrl.searchParams.get("error")).toBe(
+ "unauthorized",
+ );
+ expect(error.responseHeaders).toBe(responseHeaders);
+ }
+ }
+
+ expect(prisma.emailAccount.findFirst).not.toHaveBeenCalled();
+ });
+
+ it("should throw RedirectError with unauthorized when session has no user", async () => {
+ mockAuth.mockResolvedValue({} as any);
+
+ try {
+ await verifyEmailAccountAccess(
+ emailAccountId,
+ logger,
+ redirectUrl,
+ responseHeaders,
+ );
+ expect.fail("Should have thrown RedirectError");
+ } catch (error) {
+ expect(error).toBeInstanceOf(RedirectError);
+ if (error instanceof RedirectError) {
+ expect(error.redirectUrl.searchParams.get("error")).toBe(
+ "unauthorized",
+ );
+ expect(error.responseHeaders).toBe(responseHeaders);
+ }
+ }
+
+ expect(prisma.emailAccount.findFirst).not.toHaveBeenCalled();
+ });
+
+ it("should throw RedirectError with forbidden when email account does not exist", async () => {
+ mockAuth.mockResolvedValue({
+ user: { id: userId },
+ } as any);
+
+ prisma.emailAccount.findFirst.mockResolvedValue(null);
+
+ try {
+ await verifyEmailAccountAccess(
+ emailAccountId,
+ logger,
+ redirectUrl,
+ responseHeaders,
+ );
+ expect.fail("Should have thrown RedirectError");
+ } catch (error) {
+ expect(error).toBeInstanceOf(RedirectError);
+ if (error instanceof RedirectError) {
+ expect(error.redirectUrl.searchParams.get("error")).toBe("forbidden");
+ expect(error.responseHeaders).toBe(responseHeaders);
+ }
+ }
+
+ expect(mockAuth).toHaveBeenCalled();
+ expect(prisma.emailAccount.findFirst).toHaveBeenCalledWith({
+ where: {
+ id: emailAccountId,
+ userId,
+ },
+ select: { id: true },
+ });
+ });
+});
diff --git a/apps/web/utils/oauth/verify.ts b/apps/web/utils/oauth/verify.ts
new file mode 100644
index 0000000000..f39061ebb1
--- /dev/null
+++ b/apps/web/utils/oauth/verify.ts
@@ -0,0 +1,41 @@
+import prisma from "@/utils/prisma";
+import { auth } from "@/utils/auth";
+import type { Logger } from "@/utils/logger";
+import { RedirectError } from "./redirect";
+
+/**
+ * Verify the current user owns the specified email account.
+ * Throws RedirectError if unauthorized.
+ */
+export async function verifyEmailAccountAccess(
+ emailAccountId: string,
+ logger: Logger,
+ redirectUrl: URL,
+ responseHeaders: Headers,
+): Promise<{ userId: string }> {
+ const session = await auth();
+ if (!session?.user?.id) {
+ logger.warn("Unauthorized OAuth callback - no session");
+ redirectUrl.searchParams.set("error", "unauthorized");
+ throw new RedirectError(redirectUrl, responseHeaders);
+ }
+
+ const emailAccount = await prisma.emailAccount.findFirst({
+ where: {
+ id: emailAccountId,
+ userId: session.user.id,
+ },
+ select: { id: true },
+ });
+
+ if (!emailAccount) {
+ logger.warn("Unauthorized OAuth callback - invalid email account", {
+ emailAccountId,
+ userId: session.user.id,
+ });
+ redirectUrl.searchParams.set("error", "forbidden");
+ throw new RedirectError(redirectUrl, responseHeaders);
+ }
+
+ return { userId: session.user.id };
+}
diff --git a/apps/web/utils/outlook/draft.ts b/apps/web/utils/outlook/draft.ts
index 2a3693ad8b..f70f1c8b16 100644
--- a/apps/web/utils/outlook/draft.ts
+++ b/apps/web/utils/outlook/draft.ts
@@ -1,6 +1,6 @@
-import type { Message } from "@microsoft/microsoft-graph-types";
import type { OutlookClient } from "@/utils/outlook/client";
import type { Logger } from "@/utils/logger";
+import { isNotFoundError } from "@/utils/outlook/errors";
import { convertMessage } from "@/utils/outlook/message";
import { withOutlookRetry } from "@/utils/outlook/retry";
@@ -14,7 +14,7 @@ export async function getDraft({
logger: Logger;
}) {
try {
- const response: Message = await withOutlookRetry(
+ const response = await withOutlookRetry(
() => client.getClient().api(`/me/messages/${draftId}`).get(),
logger,
);
@@ -58,30 +58,3 @@ export async function deleteDraft({
throw error;
}
}
-
-function isNotFoundError(error: unknown): boolean {
- const err = error as {
- statusCode?: number;
- code?: number | string;
- message?: string;
- };
-
- // Check error code
- if (
- err?.statusCode === 404 ||
- err?.code === 404 ||
- err?.code === "ErrorItemNotFound" ||
- err?.code === "itemNotFound"
- ) {
- return true;
- }
-
- if (
- err?.message?.includes("not found in the store") ||
- err?.message?.includes("ErrorItemNotFound")
- ) {
- return true;
- }
-
- return false;
-}
diff --git a/apps/web/utils/outlook/errors.test.ts b/apps/web/utils/outlook/errors.test.ts
new file mode 100644
index 0000000000..da5c16671d
--- /dev/null
+++ b/apps/web/utils/outlook/errors.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect } from "vitest";
+import { isNotFoundError, isAlreadyExistsError } from "./errors";
+
+describe("isNotFoundError", () => {
+ it("should return true for statusCode 404", () => {
+ const error = { statusCode: 404, message: "Not found" };
+ expect(isNotFoundError(error)).toBe(true);
+ });
+
+ it("should return false for other status codes", () => {
+ expect(isNotFoundError({ statusCode: 400 })).toBe(false);
+ expect(isNotFoundError({ statusCode: 401 })).toBe(false);
+ expect(isNotFoundError({ statusCode: 403 })).toBe(false);
+ expect(isNotFoundError({ statusCode: 500 })).toBe(false);
+ });
+
+ it("should return false for null", () => {
+ expect(isNotFoundError(null)).toBe(false);
+ });
+
+ it("should return false for undefined", () => {
+ expect(isNotFoundError(undefined)).toBe(false);
+ });
+
+ it("should return false for non-object", () => {
+ expect(isNotFoundError("error")).toBe(false);
+ expect(isNotFoundError(404)).toBe(false);
+ });
+
+ it("should return false for object without statusCode", () => {
+ expect(isNotFoundError({ message: "Not found" })).toBe(false);
+ expect(isNotFoundError({ code: "itemNotFound" })).toBe(false);
+ });
+
+ it("should return false for empty object", () => {
+ expect(isNotFoundError({})).toBe(false);
+ });
+
+ it("should handle Error with statusCode property", () => {
+ const error = Object.assign(new Error("Not found"), { statusCode: 404 });
+ expect(isNotFoundError(error)).toBe(true);
+ });
+});
+
+describe("isAlreadyExistsError", () => {
+ it("should return true for 'already exists' message", () => {
+ expect(isAlreadyExistsError({ message: "Resource already exists" })).toBe(
+ true,
+ );
+ });
+
+ it("should return true for 'duplicate' message", () => {
+ expect(isAlreadyExistsError({ message: "duplicate entry" })).toBe(true);
+ });
+
+ it("should return true for 'conflict' message", () => {
+ expect(isAlreadyExistsError({ message: "conflict detected" })).toBe(true);
+ });
+
+ it("should return false for unrelated message", () => {
+ expect(isAlreadyExistsError({ message: "Not found" })).toBe(false);
+ });
+
+ it("should return false for null", () => {
+ expect(isAlreadyExistsError(null)).toBe(false);
+ });
+
+ it("should return false for undefined", () => {
+ expect(isAlreadyExistsError(undefined)).toBe(false);
+ });
+
+ it("should return false for object without message", () => {
+ expect(isAlreadyExistsError({ code: 409 })).toBe(false);
+ });
+});
diff --git a/apps/web/utils/outlook/errors.ts b/apps/web/utils/outlook/errors.ts
index c5655969ab..c22f3d1dda 100644
--- a/apps/web/utils/outlook/errors.ts
+++ b/apps/web/utils/outlook/errors.ts
@@ -1,4 +1,4 @@
-// Helper functions for checking Outlook API errors
+// Helper functions for checking Microsoft Graph API errors
/**
* Check if an error indicates that a resource already exists
@@ -13,3 +13,26 @@ export function isAlreadyExistsError(error: unknown): boolean {
errorMessage.includes("conflict")
);
}
+
+/**
+ * Check if a Microsoft Graph API error indicates a resource was not found.
+ * GraphError from the SDK has `statusCode: number` as the canonical HTTP status.
+ * Microsoft Graph also nests errors under `error` property with code like "itemNotFound".
+ */
+export function isNotFoundError(error: unknown): boolean {
+ if (error && typeof error === "object") {
+ // Check top-level statusCode (GraphError)
+ if ("statusCode" in error) {
+ return (error as { statusCode: number }).statusCode === 404;
+ }
+ // Check nested error.code (Microsoft Graph API response format)
+ if (
+ "error" in error &&
+ typeof (error as { error: unknown }).error === "object"
+ ) {
+ const nestedError = (error as { error: { code?: string } }).error;
+ return nestedError?.code === "itemNotFound";
+ }
+ }
+ return false;
+}
diff --git a/apps/web/utils/outlook/message.ts b/apps/web/utils/outlook/message.ts
index 45f921de8f..1f614116be 100644
--- a/apps/web/utils/outlook/message.ts
+++ b/apps/web/utils/outlook/message.ts
@@ -9,7 +9,7 @@ import type { Logger } from "@/utils/logger";
// Standard fields to select when fetching messages from Microsoft Graph API
export const MESSAGE_SELECT_FIELDS =
- "id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,ccRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId";
+ "id,conversationId,conversationIndex,subject,bodyPreview,from,sender,toRecipients,ccRecipients,receivedDateTime,isDraft,isRead,body,categories,parentFolderId,hasAttachments";
// Well-known folder names in Outlook that are consistent across all languages
export const WELL_KNOWN_FOLDERS = {
@@ -415,6 +415,55 @@ async function convertMessages(
.map((message: Message) => convertMessage(message, folderIds));
}
+export async function queryMessagesWithAttachments(
+ client: OutlookClient,
+ options: {
+ maxResults?: number;
+ pageToken?: string;
+ },
+ logger: Logger,
+): Promise<{
+ messages: ParsedMessage[];
+ nextPageToken?: string;
+}> {
+ const MAX_RESULTS = 20;
+ const maxResults = Math.min(options.maxResults || MAX_RESULTS, MAX_RESULTS);
+
+ // If pageToken is a URL, fetch directly (per MS docs, don't extract $skiptoken)
+ if (options.pageToken?.startsWith("http")) {
+ const response: { value: Message[]; "@odata.nextLink"?: string } =
+ await withOutlookRetry(
+ () => client.getClient().api(options.pageToken!).get(),
+ logger,
+ );
+
+ const messages = await convertMessages(response.value, {});
+ return { messages, nextPageToken: response["@odata.nextLink"] };
+ }
+
+ // Build request with hasAttachments filter
+ const request = createMessagesRequest(client)
+ .top(maxResults)
+ .filter("hasAttachments eq true")
+ .expand("attachments($select=id,name,contentType,size)")
+ .orderby("receivedDateTime DESC");
+
+ const response: { value: Message[]; "@odata.nextLink"?: string } =
+ await withOutlookRetry(() => request.get(), logger);
+
+ const messages = await convertMessages(response.value, {});
+
+ logger.info("Messages with attachments fetched", {
+ messageCount: messages.length,
+ hasNextPageToken: !!response["@odata.nextLink"],
+ });
+
+ return {
+ messages,
+ nextPageToken: response["@odata.nextLink"],
+ };
+}
+
export async function getMessage(
messageId: string,
client: OutlookClient,
@@ -516,6 +565,24 @@ export function convertMessage(
| "html"
| undefined;
+ const attachments = ((message.attachments || []) as OutlookAttachment[])
+ .filter(
+ (att): att is OutlookAttachment & { id: string } =>
+ att["@odata.type"] === "#microsoft.graph.fileAttachment" && !!att.id,
+ )
+ .map((att) => ({
+ filename: att.name || "unknown",
+ mimeType: att.contentType || "application/octet-stream",
+ size: att.size || 0,
+ attachmentId: att.id,
+ headers: {
+ "content-type": att.contentType || "",
+ "content-description": att.name || "",
+ "content-transfer-encoding": "",
+ "content-id": "",
+ },
+ }));
+
return {
id: message.id || "",
threadId: message.conversationId || "",
@@ -540,6 +607,7 @@ export function convertMessage(
internalDate: message.receivedDateTime || new Date().toISOString(),
historyId: "",
inline: [],
+ attachments: attachments.length > 0 ? attachments : undefined,
conversationIndex: message.conversationIndex,
rawRecipients: {
from: message.from,
@@ -548,3 +616,11 @@ export function convertMessage(
},
};
}
+
+type OutlookAttachment = {
+ "@odata.type"?: string;
+ id?: string;
+ name?: string;
+ contentType?: string;
+ size?: number;
+};
diff --git a/apps/web/utils/prisma-extensions.ts b/apps/web/utils/prisma-extensions.ts
index 9f5dbbdcb3..4f6b284d90 100644
--- a/apps/web/utils/prisma-extensions.ts
+++ b/apps/web/utils/prisma-extensions.ts
@@ -52,6 +52,20 @@ export const encryptedTokens = Prisma.defineExtension((client) => {
},
},
},
+ driveConnection: {
+ accessToken: {
+ needs: { accessToken: true },
+ compute(connection) {
+ return decryptToken(connection.accessToken);
+ },
+ },
+ refreshToken: {
+ needs: { refreshToken: true },
+ compute(connection) {
+ return decryptToken(connection.refreshToken);
+ },
+ },
+ },
},
query: {
account: {
@@ -323,6 +337,86 @@ export const encryptedTokens = Prisma.defineExtension((client) => {
return query(args);
},
},
+ driveConnection: {
+ async create({ args, query }) {
+ if (args.data.accessToken) {
+ args.data.accessToken = encryptToken(args.data.accessToken);
+ }
+ if (args.data.refreshToken) {
+ args.data.refreshToken = encryptToken(args.data.refreshToken);
+ }
+ return query(args);
+ },
+ async update({ args, query }) {
+ if (args.data.accessToken) {
+ if (typeof args.data.accessToken === "string") {
+ args.data.accessToken = encryptToken(args.data.accessToken);
+ } else if (args.data.accessToken.set) {
+ args.data.accessToken.set = encryptToken(
+ args.data.accessToken.set,
+ );
+ }
+ }
+ if (args.data.refreshToken) {
+ if (typeof args.data.refreshToken === "string") {
+ args.data.refreshToken = encryptToken(args.data.refreshToken);
+ } else if (args.data.refreshToken.set) {
+ args.data.refreshToken.set = encryptToken(
+ args.data.refreshToken.set,
+ );
+ }
+ }
+ return query(args);
+ },
+ async updateMany({ args, query }) {
+ if (args.data.accessToken) {
+ if (typeof args.data.accessToken === "string") {
+ args.data.accessToken = encryptToken(args.data.accessToken);
+ } else if (args.data.accessToken.set) {
+ args.data.accessToken.set = encryptToken(
+ args.data.accessToken.set,
+ );
+ }
+ }
+ if (args.data.refreshToken) {
+ if (typeof args.data.refreshToken === "string") {
+ args.data.refreshToken = encryptToken(args.data.refreshToken);
+ } else if (args.data.refreshToken.set) {
+ args.data.refreshToken.set = encryptToken(
+ args.data.refreshToken.set,
+ );
+ }
+ }
+ return query(args);
+ },
+ async upsert({ args, query }) {
+ if (args.create.accessToken) {
+ args.create.accessToken = encryptToken(args.create.accessToken);
+ }
+ if (args.create.refreshToken) {
+ args.create.refreshToken = encryptToken(args.create.refreshToken);
+ }
+ if (args.update.accessToken) {
+ if (typeof args.update.accessToken === "string") {
+ args.update.accessToken = encryptToken(args.update.accessToken);
+ } else if (args.update.accessToken.set) {
+ args.update.accessToken.set = encryptToken(
+ args.update.accessToken.set,
+ );
+ }
+ }
+ if (args.update.refreshToken) {
+ if (typeof args.update.refreshToken === "string") {
+ args.update.refreshToken = encryptToken(args.update.refreshToken);
+ } else if (args.update.refreshToken.set) {
+ args.update.refreshToken.set = encryptToken(
+ args.update.refreshToken.set,
+ );
+ }
+ }
+ return query(args);
+ },
+ },
},
});
});
diff --git a/apps/web/utils/redis/oauth-code.ts b/apps/web/utils/redis/oauth-code.ts
index 3f68286972..9fd81d113d 100644
--- a/apps/web/utils/redis/oauth-code.ts
+++ b/apps/web/utils/redis/oauth-code.ts
@@ -52,6 +52,15 @@ export async function setOAuthCodeResult(
await redis.set(getCodeKey(code), result, { ex: 60 });
}
+/**
+ * Clear the OAuth code from Redis.
+ * Fails silently - cleanup errors should never mask the original error in catch blocks.
+ */
export async function clearOAuthCode(code: string): Promise {
- await redis.del(getCodeKey(code));
+ try {
+ await redis.del(getCodeKey(code));
+ } catch {
+ // Silently ignore - this is called in error handlers where we don't want
+ // cleanup failures to mask the original error
+ }
}
diff --git a/apps/web/utils/webhook/process-history-item.ts b/apps/web/utils/webhook/process-history-item.ts
index eb9533c1b3..f250c51ae6 100644
--- a/apps/web/utils/webhook/process-history-item.ts
+++ b/apps/web/utils/webhook/process-history-item.ts
@@ -1,9 +1,16 @@
+import { after } from "next/server";
import prisma from "@/utils/prisma";
import { runRules } from "@/utils/ai/choose-rule/run-rules";
import { categorizeSender } from "@/utils/categorize/senders/categorize";
import { markMessageAsProcessing } from "@/utils/redis/message-processing";
import { isAssistantEmail } from "@/utils/assistant/is-assistant-email";
import { processAssistantEmail } from "@/utils/assistant/process-assistant-email";
+import { isFilebotEmail } from "@/utils/filebot/is-filebot-email";
+import { processFilingReply } from "@/utils/drive/handle-filing-reply";
+import {
+ processAttachment,
+ getExtractableAttachments,
+} from "@/utils/drive/filing-engine";
import { handleOutboundMessage } from "@/utils/reply-tracker/handle-outbound";
import { NewsletterStatus } from "@/generated/prisma/enums";
import type { EmailAccount } from "@/generated/prisma/client";
@@ -20,7 +27,10 @@ export type SharedProcessHistoryOptions = {
hasAutomationRules: boolean;
hasAiAccess: boolean;
emailAccount: EmailAccountWithAI &
- Pick;
+ Pick<
+ EmailAccount,
+ "autoCategorizeSenders" | "filingEnabled" | "filingPrompt" | "email"
+ >;
logger: Logger;
};
@@ -137,6 +147,22 @@ export async function processHistoryItem(
return;
}
+ const isForFilebot = isFilebotEmail({
+ userEmail,
+ emailToCheck: parsedMessage.headers.to,
+ });
+
+ if (isForFilebot) {
+ logger.info("Processing filebot reply.");
+ return processFilingReply({
+ message: parsedMessage,
+ emailAccountId,
+ userEmail,
+ emailProvider: provider,
+ logger,
+ });
+ }
+
const isOutbound = provider.isSentMessage(parsedMessage);
if (isOutbound) {
@@ -198,6 +224,44 @@ export async function processHistoryItem(
logger,
});
}
+
+ // Process attachments for document filing (runs in parallel with rules if both enabled)
+ if (
+ emailAccount.filingEnabled &&
+ emailAccount.filingPrompt &&
+ hasAiAccess
+ ) {
+ after(async () => {
+ const extractableAttachments = getExtractableAttachments(parsedMessage);
+
+ if (extractableAttachments.length > 0) {
+ logger.info("Processing attachments for filing", {
+ count: extractableAttachments.length,
+ });
+
+ // Process each attachment (don't await all - let them run in background)
+ for (const attachment of extractableAttachments) {
+ await processAttachment({
+ emailAccount: {
+ ...emailAccount,
+ filingEnabled: emailAccount.filingEnabled,
+ filingPrompt: emailAccount.filingPrompt,
+ email: emailAccount.email,
+ },
+ message: parsedMessage,
+ attachment,
+ emailProvider: provider,
+ logger,
+ }).catch((error) => {
+ logger.error("Failed to process attachment", {
+ filename: attachment.filename,
+ error,
+ });
+ });
+ }
+ }
+ });
+ }
} catch (error: unknown) {
// Handle provider-specific "not found" errors
if (error instanceof Error) {
diff --git a/apps/web/utils/webhook/validate-webhook-account.ts b/apps/web/utils/webhook/validate-webhook-account.ts
index 7c2fe24223..4fe71d9a87 100644
--- a/apps/web/utils/webhook/validate-webhook-account.ts
+++ b/apps/web/utils/webhook/validate-webhook-account.ts
@@ -21,6 +21,8 @@ export async function getWebhookEmailAccount(
calendarBookingLink: true,
lastSyncedHistoryId: true,
autoCategorizeSenders: true,
+ filingEnabled: true,
+ filingPrompt: true,
watchEmailsSubscriptionId: true,
watchEmailsSubscriptionHistory: true,
account: {
@@ -176,8 +178,11 @@ export async function validateWebhookAccount(
}
const hasAutomationRules = emailAccount.rules.length > 0;
- if (!hasAutomationRules) {
- logger.info("Has no rules enabled");
+ const hasFilingEnabled =
+ emailAccount.filingEnabled && !!emailAccount.filingPrompt;
+
+ if (!hasAutomationRules && !hasFilingEnabled) {
+ logger.info("Has no rules enabled and filing not configured");
return { success: false, response: NextResponse.json({ ok: true }) };
}
diff --git a/docs/hosting/environment-variables.md b/docs/hosting/environment-variables.md
index 2c427ca506..b6104cb786 100644
--- a/docs/hosting/environment-variables.md
+++ b/docs/hosting/environment-variables.md
@@ -89,6 +89,7 @@ cp apps/web/.env.example apps/web/.env
| `NEXT_PUBLIC_DIGEST_ENABLED` | No | Enable email digest feature, which sends periodic summaries of emails. Requires QStash to be configured. | `false` |
| `NEXT_PUBLIC_MEETING_BRIEFS_ENABLED` | No | Enable meeting briefs, which automatically sends pre-meeting briefings to users. Requires the meeting briefs cron job to be running. | `false` |
| `NEXT_PUBLIC_INTEGRATIONS_ENABLED` | No | Enable the integrations feature, allowing users to connect external services. | `false` |
+| `NEXT_PUBLIC_SMART_FILING_ENABLED` | No | Enable the Smart Filing feature for automatic document organization from email attachments. | `false` |
| **Debugging** ||||
| `LOG_ZOD_ERRORS` | No | Log Zod validation errors | — |
| `ENABLE_DEBUG_LOGS` | No | Enable debug logging | `false` |
diff --git a/package.json b/package.json
index cd8de21ffc..dfb054d42a 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
"prettier": "3.6.2",
"tsconfig-paths": "^4.2.0",
"tsx": "4.21.0",
- "turbo": "2.6.3",
+ "turbo": "2.7.1",
"ultracite": "5.3.3"
},
"packageManager": "pnpm@10.27.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 92144def5b..d0b5eae6ab 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -40,8 +40,8 @@ importers:
specifier: 4.21.0
version: 4.21.0
turbo:
- specifier: 2.6.3
- version: 2.6.3
+ specifier: 2.7.1
+ version: 2.7.1
ultracite:
specifier: 5.3.3
version: 5.3.3(@inquirer/prompts@7.10.1(@types/node@24.10.1))(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1)
@@ -145,6 +145,9 @@ importers:
'@googleapis/calendar':
specifier: ^14.0.0
version: 14.0.0
+ '@googleapis/drive':
+ specifier: 20.0.0
+ version: 20.0.0
'@googleapis/gmail':
specifier: 16.1.0
version: 16.1.0
@@ -427,6 +430,9 @@ importers:
lucide-react:
specifier: 0.555.0
version: 0.555.0(react@19.2.3)
+ mammoth:
+ specifier: 1.11.0
+ version: 1.11.0
motion:
specifier: 12.23.25
version: 12.23.25(@emotion/is-prop-valid@1.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -559,6 +565,9 @@ importers:
typescript:
specifier: 5.9.3
version: 5.9.3
+ unpdf:
+ specifier: 1.4.0
+ version: 1.4.0
use-stick-to-bottom:
specifier: 1.1.1
version: 1.1.1(react@19.2.3)
@@ -2788,6 +2797,10 @@ packages:
resolution: {integrity: sha512-/5DtvL4jInnJzEOcfloHbDV8s6TwVziwbsf4NTKl55EluDKwRFTm2ymKfMie5dyXsuUf0VKrrTYWufcEjD2xaQ==}
engines: {node: '>=12.0.0'}
+ '@googleapis/drive@20.0.0':
+ resolution: {integrity: sha512-qLi5ypZn0zYY2FcGjdlHQsv1DAFNRwCWFiE5kq23J0yTdUSZynh/mDph9NBaiQ9ybajrmttySR/rSaNfm8S/bA==}
+ engines: {node: '>=12.0.0'}
+
'@googleapis/gmail@16.1.0':
resolution: {integrity: sha512-BMyqpAjC1Nj2Fv9DvLtn/tnXy0fHw7QabBNE7aU6SIXPfvSEKdUJ1fV1+iKDcCgEV+uLb5zY2J5sQSW1903lkQ==}
engines: {node: '>=12.0.0'}
@@ -6851,6 +6864,9 @@ packages:
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+ bluebird@3.4.7:
+ resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
+
body-parser@1.20.3:
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -7741,6 +7757,9 @@ packages:
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
engines: {node: '>=0.3.1'}
+ dingbat-to-unicode@1.0.1:
+ resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
+
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
@@ -7802,6 +7821,9 @@ packages:
dub@0.67.0:
resolution: {integrity: sha512-QVqbCOQrr3BU+w9hF0VXr7gJZ2z/B9v3QCi2CuLEZl6FlwFgEEUnMNUsSbZv08COWZ2Xixv3tLfgc4EIiQO21Q==}
+ duck@0.1.12:
+ resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==}
+
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -8763,6 +8785,9 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
+ immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+
immer@10.1.3:
resolution: {integrity: sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==}
@@ -9185,6 +9210,9 @@ packages:
resolution: {integrity: sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==}
hasBin: true
+ jszip@3.10.1:
+ resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
@@ -9238,6 +9266,9 @@ packages:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
+ lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+
light-my-request@6.6.0:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
@@ -9367,6 +9398,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
+ lop@0.4.2:
+ resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==}
+
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -9439,6 +9473,11 @@ packages:
make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
+ mammoth@1.11.0:
+ resolution: {integrity: sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==}
+ engines: {node: '>=12.0.0'}
+ hasBin: true
+
map-obj@1.0.1:
resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==}
engines: {node: '>=0.10.0'}
@@ -10175,6 +10214,9 @@ packages:
openapi3-ts@4.5.0:
resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==}
+ option@0.2.4:
+ resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==}
+
ora@4.1.1:
resolution: {integrity: sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==}
engines: {node: '>=8'}
@@ -11488,6 +11530,9 @@ packages:
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+ setimmediate@1.0.5:
+ resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -12191,38 +12236,38 @@ packages:
resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
- turbo-darwin-64@2.6.3:
- resolution: {integrity: sha512-BlJJDc1CQ7SK5Y5qnl7AzpkvKSnpkfPmnA+HeU/sgny3oHZckPV2776ebO2M33CYDSor7+8HQwaodY++IINhYg==}
+ turbo-darwin-64@2.7.1:
+ resolution: {integrity: sha512-EaA7UfYujbY9/Ku0WqPpvfctxm91h9LF7zo8vjielz+omfAPB54Si+ADmUoBczBDC6RoLgbURC3GmUW2alnjJg==}
cpu: [x64]
os: [darwin]
- turbo-darwin-arm64@2.6.3:
- resolution: {integrity: sha512-MwVt7rBKiOK7zdYerenfCRTypefw4kZCue35IJga9CH1+S50+KTiCkT6LBqo0hHeoH2iKuI0ldTF2a0aB72z3w==}
+ turbo-darwin-arm64@2.7.1:
+ resolution: {integrity: sha512-/pWGSygtBugd7sKQOeMm+jKY3qN1vyB0RiHBM6bN/6qUOo2VHo8IQwBTIaSgINN4Ue6fzEU+WfePNvonSU9yXw==}
cpu: [arm64]
os: [darwin]
- turbo-linux-64@2.6.3:
- resolution: {integrity: sha512-cqpcw+dXxbnPtNnzeeSyWprjmuFVpHJqKcs7Jym5oXlu/ZcovEASUIUZVN3OGEM6Y/OTyyw0z09tOHNt5yBAVg==}
+ turbo-linux-64@2.7.1:
+ resolution: {integrity: sha512-Y5H11mdhASw/dJuRFyGtTCDFX5/MPT73EKsVEiHbw5MkFc77lx3nMc5L/Q7bKEhef/vYJAsAb61QuHsB6qdP8Q==}
cpu: [x64]
os: [linux]
- turbo-linux-arm64@2.6.3:
- resolution: {integrity: sha512-MterpZQmjXyr4uM7zOgFSFL3oRdNKeflY7nsjxJb2TklsYqiu3Z9pQ4zRVFFH8n0mLGna7MbQMZuKoWqqHb45w==}
+ turbo-linux-arm64@2.7.1:
+ resolution: {integrity: sha512-L/r77jD7cqIEXoyu2LGBUrTY5GJSi/XcGLsQ2nZ/fefk6x3MpljTvwsXUVG1BUkiBPc4zaKRj6yGyWMo5MbLxQ==}
cpu: [arm64]
os: [linux]
- turbo-windows-64@2.6.3:
- resolution: {integrity: sha512-biDU70v9dLwnBdLf+daoDlNJVvqOOP8YEjqNipBHzgclbQlXbsi6Gqqelp5er81Qo3BiRgmTNx79oaZQTPb07Q==}
+ turbo-windows-64@2.7.1:
+ resolution: {integrity: sha512-rkeuviXZ/1F7lCare7TNKvYtT/SH9dZR55FAMrxrFRh88b+ZKwlXEBfq5/1OctEzRUo/VLIm+s5LJMOEy+QshA==}
cpu: [x64]
os: [win32]
- turbo-windows-arm64@2.6.3:
- resolution: {integrity: sha512-dDHVKpSeukah3VsI/xMEKeTnV9V9cjlpFSUs4bmsUiLu3Yv2ENlgVEZv65wxbeE0bh0jjpmElDT+P1KaCxArQQ==}
+ turbo-windows-arm64@2.7.1:
+ resolution: {integrity: sha512-1rZk9htm3+iP/rWCf/h4/DFQey9sMs2TJPC4T5QQfwqAdMWsphgrxBuFqHdxczlbBCgbWNhVw0CH2bTxe1/GFg==}
cpu: [arm64]
os: [win32]
- turbo@2.6.3:
- resolution: {integrity: sha512-bf6YKUv11l5Xfcmg76PyWoy/e2vbkkxFNBGJSnfdSXQC33ZiUfutYh6IXidc5MhsnrFkWfdNNLyaRk+kHMLlwA==}
+ turbo@2.7.1:
+ resolution: {integrity: sha512-zAj9jGc7VDvuAo/5Jbos4QTtWz9uUpkMhMKGyTjDJkx//hdL2bM31qQoJSAbU+7JyK5vb0LPzpwf6DUt3zayqg==}
hasBin: true
type-fest@0.18.1:
@@ -12292,6 +12337,9 @@ packages:
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
+ underscore@1.13.7:
+ resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==}
+
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -12367,6 +12415,14 @@ packages:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
+ unpdf@1.4.0:
+ resolution: {integrity: sha512-TahIk0xdH/4jh/MxfclzU79g40OyxtP00VnEUZdEkJoYtXAHWLiir6t3FC6z3vDqQTzc2ZHcla6uEiVTNjejuA==}
+ peerDependencies:
+ '@napi-rs/canvas': ^0.1.69
+ peerDependenciesMeta:
+ '@napi-rs/canvas':
+ optional: true
+
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
@@ -12925,6 +12981,10 @@ packages:
xml@1.0.1:
resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
+ xmlbuilder@10.1.1:
+ resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==}
+ engines: {node: '>=4.0'}
+
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
@@ -15288,6 +15348,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@googleapis/drive@20.0.0':
+ dependencies:
+ googleapis-common: 8.0.0
+ transitivePeerDependencies:
+ - supports-color
+
'@googleapis/gmail@16.1.0':
dependencies:
googleapis-common: 8.0.0
@@ -19868,7 +19934,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
- vitest: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/ui@4.0.15)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
+ vitest: 4.0.15(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/ui@4.0.15)(jiti@2.6.1)(jsdom@27.2.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
'@vitest/utils@3.2.4':
dependencies:
@@ -20362,6 +20428,8 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
+ bluebird@3.4.7: {}
+
body-parser@1.20.3:
dependencies:
bytes: 3.1.2
@@ -21296,6 +21364,8 @@ snapshots:
diff@8.0.2: {}
+ dingbat-to-unicode@1.0.1: {}
+
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
@@ -21357,6 +21427,10 @@ snapshots:
dependencies:
zod: 3.25.46
+ duck@0.1.12:
+ dependencies:
+ underscore: 1.13.7
+
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -22715,6 +22789,8 @@ snapshots:
ignore@5.3.2: {}
+ immediate@3.0.6: {}
+
immer@10.1.3: {}
immer@11.0.1: {}
@@ -23133,6 +23209,13 @@ snapshots:
jsonrepair@3.13.1: {}
+ jszip@3.10.1:
+ dependencies:
+ lie: 3.3.0
+ pako: 1.0.11
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
+
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
@@ -23180,6 +23263,10 @@ snapshots:
leven@3.1.0: {}
+ lie@3.3.0:
+ dependencies:
+ immediate: 3.0.6
+
light-my-request@6.6.0:
dependencies:
cookie: 1.0.2
@@ -23314,6 +23401,12 @@ snapshots:
dependencies:
js-tokens: 4.0.0
+ lop@0.4.2:
+ dependencies:
+ duck: 0.1.12
+ option: 0.2.4
+ underscore: 1.13.7
+
loupe@3.2.1: {}
lower-case-first@1.0.2:
@@ -23387,6 +23480,19 @@ snapshots:
make-error@1.3.6: {}
+ mammoth@1.11.0:
+ dependencies:
+ '@xmldom/xmldom': 0.8.11
+ argparse: 1.0.10
+ base64-js: 1.5.1
+ bluebird: 3.4.7
+ dingbat-to-unicode: 1.0.1
+ jszip: 3.10.1
+ lop: 0.4.2
+ path-is-absolute: 1.0.1
+ underscore: 1.13.7
+ xmlbuilder: 10.1.1
+
map-obj@1.0.1: {}
map-obj@4.3.0: {}
@@ -24399,6 +24505,8 @@ snapshots:
dependencies:
yaml: 2.8.1
+ option@0.2.4: {}
+
ora@4.1.1:
dependencies:
chalk: 3.0.0
@@ -26161,6 +26269,8 @@ snapshots:
set-cookie-parser@2.7.2: {}
+ setimmediate@1.0.5: {}
+
setprototypeof@1.2.0: {}
sha256-uint8array@0.10.7: {}
@@ -27036,32 +27146,32 @@ snapshots:
tunnel@0.0.6: {}
- turbo-darwin-64@2.6.3:
+ turbo-darwin-64@2.7.1:
optional: true
- turbo-darwin-arm64@2.6.3:
+ turbo-darwin-arm64@2.7.1:
optional: true
- turbo-linux-64@2.6.3:
+ turbo-linux-64@2.7.1:
optional: true
- turbo-linux-arm64@2.6.3:
+ turbo-linux-arm64@2.7.1:
optional: true
- turbo-windows-64@2.6.3:
+ turbo-windows-64@2.7.1:
optional: true
- turbo-windows-arm64@2.6.3:
+ turbo-windows-arm64@2.7.1:
optional: true
- turbo@2.6.3:
+ turbo@2.7.1:
optionalDependencies:
- turbo-darwin-64: 2.6.3
- turbo-darwin-arm64: 2.6.3
- turbo-linux-64: 2.6.3
- turbo-linux-arm64: 2.6.3
- turbo-windows-64: 2.6.3
- turbo-windows-arm64: 2.6.3
+ turbo-darwin-64: 2.7.1
+ turbo-darwin-arm64: 2.7.1
+ turbo-linux-64: 2.7.1
+ turbo-linux-arm64: 2.7.1
+ turbo-windows-64: 2.7.1
+ turbo-windows-arm64: 2.7.1
type-fest@0.18.1: {}
@@ -27142,6 +27252,8 @@ snapshots:
uncrypto@0.1.3: {}
+ underscore@1.13.7: {}
+
undici-types@6.21.0: {}
undici-types@7.16.0: {}
@@ -27226,6 +27338,8 @@ snapshots:
universalify@2.0.1: {}
+ unpdf@1.4.0: {}
+
unpipe@1.0.0: {}
unplugin@1.0.1:
@@ -27881,6 +27995,8 @@ snapshots:
xml@1.0.1: {}
+ xmlbuilder@10.1.1: {}
+
xmlchars@2.2.0: {}
xmlhttprequest-ssl@2.1.2: {}