diff --git a/README.md b/README.md index 9d80512c37..002eeb4bf1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ To help you spend less time in your inbox, so you can focus on what matters. - **Bulk Unsubscriber:** One-click unsubscribe and archive emails you never read. - **Cold Email Blocker:** Auto‑block cold emails. - **Email Analytics:** Track your activity and trends over time. +- **Meeting Briefs (Beta):** Get personalized briefings before every meeting, pulling context from your email and calendar. +- **Smart Filing (Early Access):** Automatically save email attachments to Google Drive or OneDrive. + Learn more in our [docs](https://docs.getinboxzero.com). @@ -156,6 +159,7 @@ Create [new credentials](https://console.cloud.google.com/apis/credentials): - `http://localhost:3000/api/auth/callback/google` - `http://localhost:3000/api/google/linking/callback` - `http://localhost:3000/api/google/calendar/callback` (only required for calendar integration) + - `http://localhost:3000/api/google/drive/callback` (only required for Google Drive integration) 6. Click `Create`. 7. A popup will show up with the new credentials, including the Client ID and secret. 3. Update .env file: @@ -174,6 +178,7 @@ Create [new credentials](https://console.cloud.google.com/apis/credentials): https://www.googleapis.com/auth/gmail.settings.basic https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar (only required for calendar integration) + https://www.googleapis.com/auth/drive.file (only required for Google Drive integration) ``` 4. Click `Update` @@ -187,6 +192,7 @@ Create [new credentials](https://console.cloud.google.com/apis/credentials): 6. Enable required APIs in [Google Cloud Console](https://console.cloud.google.com/apis/library): - [Google People API](https://console.cloud.google.com/marketplace/product/google/people.googleapis.com) (required) - [Google Calendar API](https://console.cloud.google.com/marketplace/product/google/calendar-json.googleapis.com) (only required for calendar integration) + - [Google Drive API](https://console.cloud.google.com/marketplace/product/google/drive.googleapis.com) (only required for Google Drive integration) ### Google PubSub Setup @@ -233,6 +239,7 @@ Go to [Microsoft Azure Portal](https://portal.azure.com/) and create a new Azure 6. Add the following Redirect URIs (replace `localhost:3000` with your domain in production): - `http://localhost:3000/api/outlook/linking/callback` - `http://localhost:3000/api/outlook/calendar/callback` (only required for calendar integration) + - `http://localhost:3000/api/outlook/drive/callback` (only required for OneDrive integration) 4. Get your credentials from the `Overview` tab: @@ -262,6 +269,7 @@ Go to [Microsoft Azure Portal](https://portal.azure.com/) and create a new Azure - MailboxSettings.ReadWrite - Calendars.Read (only required for calendar integration) - Calendars.ReadWrite (only required for calendar integration) + - Files.ReadWrite (only required for OneDrive integration) 6. Click "Add permissions" 7. Click "Grant admin consent" if you're an admin diff --git a/apps/web/app/(app)/(redirects)/drive/page.tsx b/apps/web/app/(app)/(redirects)/drive/page.tsx new file mode 100644 index 0000000000..642d9e1e81 --- /dev/null +++ b/apps/web/app/(app)/(redirects)/drive/page.tsx @@ -0,0 +1,5 @@ +import { redirectToEmailAccountPath } from "@/utils/account"; + +export default async function DrivePage() { + await redirectToEmailAccountPath("/drive"); +} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSteps.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSteps.tsx index a5666f2ef7..a2a9ca720b 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ActionSteps.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ActionSteps.tsx @@ -42,6 +42,7 @@ import { WebhookDocumentationLink } from "@/components/WebhookDocumentation"; import { LabelCombobox } from "@/components/LabelCombobox"; import { RuleStep } from "@/app/(app)/[emailAccountId]/assistant/RuleStep"; import { Card } from "@/components/ui/card"; +import { MutedText } from "@/components/Typography"; export function ActionSteps({ actionFields, @@ -538,15 +539,15 @@ function ActionCard({ const rightContent = ( <> {isNotifySender ? ( -
+ Sends an automated notification from Inbox Zero informing the sender their email was filtered as cold outreach. -
+ ) : isDraftEmailWithoutManualContent ? ( -
+ Our AI generates a draft reply from your email history and knowledge base. -
+ ) : isEmailAction || actionType === ActionType.CALL_WEBHOOK ? ( {fieldsContent} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx index ea8d100743..a2b54c20ad 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessRules.tsx @@ -34,6 +34,7 @@ import { ResultsDisplay } from "@/app/(app)/[emailAccountId]/assistant/ResultDis import { useAccount } from "@/providers/EmailAccountProvider"; import { FixWithChat } from "@/app/(app)/[emailAccountId]/assistant/FixWithChat"; import { useChat } from "@/providers/ChatProvider"; +import { MutedText } from "@/components/Typography"; type Message = MessagesResponse["messages"][number]; @@ -287,9 +288,7 @@ export function ProcessRulesContent({ testMode }: { testMode: boolean }) { {messages.length === 0 ? ( -
- No emails found -
+ No emails found ) : ( diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ResultDisplay.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ResultDisplay.tsx index 198793228a..bbc2763b85 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ResultDisplay.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ResultDisplay.tsx @@ -8,7 +8,7 @@ import { ExecutedRuleStatus, LogicalOperator } from "@/generated/prisma/enums"; import type { ActionType } from "@/generated/prisma/enums"; import type { Rule } from "@/generated/prisma/client"; import { Button } from "@/components/ui/button"; -import { MessageText } from "@/components/Typography"; +import { MessageText, MutedText } from "@/components/Typography"; import { EyeIcon } from "lucide-react"; import { useRuleDialog } from "@/app/(app)/[emailAccountId]/assistant/RuleDialog"; import type { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; @@ -211,7 +211,7 @@ function Actions({ {getActionDisplay(action, provider, labels)} {fields.length > 0 && ( -
+ {fields.map((field) => (
))} -
+
)}
); @@ -258,7 +258,7 @@ function PrettyConditions({
{conditions.map((condition, index) => (
- {condition} + {condition} {index < conditions.length - 1 && ( {operator} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx index 681da2c541..ba610aa660 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx @@ -68,6 +68,7 @@ import { STEP_KEYS, getStepNumber, } from "@/app/(app)/[emailAccountId]/onboarding/steps"; +import { MutedText } from "@/components/Typography"; export function Rules({ showAddRuleButton = true, @@ -246,9 +247,9 @@ export function Rules({ if (isConversationStatus) { return (
- + {systemRuleDesc?.condition || ""} - + diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/RulesTabNew.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/RulesTabNew.tsx index 23b9052a31..2c769e16a8 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/RulesTabNew.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/RulesTabNew.tsx @@ -1,14 +1,15 @@ import { Rules } from "@/app/(app)/[emailAccountId]/assistant/Rules"; import { AddRuleDialog } from "@/app/(app)/[emailAccountId]/assistant/AddRuleDialog"; +import { MutedText } from "@/components/Typography"; export function RulesTab() { return (
-

+ Your assistant automatically organizes incoming emails using these rules. -

+
diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/group/ViewLearnedPatterns.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/group/ViewLearnedPatterns.tsx index bcd41bf243..8d14710d3d 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/group/ViewLearnedPatterns.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/group/ViewLearnedPatterns.tsx @@ -26,7 +26,7 @@ import { TableHeader, TableHead, } from "@/components/ui/table"; -import { MessageText } from "@/components/Typography"; +import { MessageText, MutedText } from "@/components/Typography"; import { addGroupItemAction, deleteGroupItemAction, @@ -309,9 +309,9 @@ function GroupItemList({ - + {formatShortDate(new Date(item.createdAt))} - +
-

- {job._count.threads} emails processed -

+ {job._count.threads} emails processed
))}
) : ( -
- No history yet -
+ No history yet )} ); diff --git a/apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx b/apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx index 117ec70204..20a2d9101b 100644 --- a/apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx +++ b/apps/web/app/(app)/[emailAccountId]/clean/ConfirmationStep.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import Image from "next/image"; -import { TypographyH3 } from "@/components/Typography"; +import { MutedText, TypographyH3 } from "@/components/Typography"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/Badge"; import { cleanInboxAction } from "@/utils/actions/clean"; @@ -122,7 +122,7 @@ export function ConfirmationStep({
{showFooter && ( -
+ -
+ )} ); diff --git a/apps/web/app/(app)/[emailAccountId]/debug/rule-history/[ruleId]/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/rule-history/[ruleId]/page.tsx index e0505937ba..f4ccdf56b5 100644 --- a/apps/web/app/(app)/[emailAccountId]/debug/rule-history/[ruleId]/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/debug/rule-history/[ruleId]/page.tsx @@ -1,5 +1,5 @@ import prisma from "@/utils/prisma"; -import { PageHeading } from "@/components/Typography"; +import { MutedText, PageHeading } from "@/components/Typography"; import { Card, CardContent, @@ -80,11 +80,11 @@ export default async function RuleHistoryPage(props: { {triggerTypeLabels[history.triggerType] || history.triggerType} - + {formatDistanceToNow(history.createdAt, { addSuffix: true, })} - + {history.promptText && ( diff --git a/apps/web/app/(app)/[emailAccountId]/drive/AllowedFolders.tsx b/apps/web/app/(app)/[emailAccountId]/drive/AllowedFolders.tsx new file mode 100644 index 0000000000..8aff6f6eb9 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/AllowedFolders.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { useForm, type SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { FolderIcon, Loader2Icon } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { toastError, toastSuccess } from "@/components/Toast"; +import { + TreeProvider, + TreeView, + TreeNode, + TreeNodeTrigger, + TreeNodeContent, + TreeExpander, + TreeIcon, + TreeLabel, + useTree, +} from "@/components/kibo-ui/tree"; +import { + addFilingFolderAction, + removeFilingFolderAction, + createDriveFolderAction, +} from "@/utils/actions/drive"; +import { + createDriveFolderBody, + type CreateDriveFolderBody, +} from "@/utils/actions/drive.validation"; +import { useDriveFolders } from "@/hooks/useDriveFolders"; +import { LoadingContent } from "@/components/LoadingContent"; +import { useDriveSubfolders } from "@/hooks/useDriveSubfolders"; +import type { + FolderItem, + SavedFolder, +} from "@/app/api/user/drive/folders/route"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/Input"; +import { useDialogState } from "@/hooks/useDialogState"; +import { useDriveConnections } from "@/hooks/useDriveConnections"; + +export function AllowedFolders({ emailAccountId }: { emailAccountId: string }) { + const { data, isLoading, error, mutate } = useDriveFolders(); + const { data: connectionsData } = useDriveConnections(); + const driveConnectionId = connectionsData?.connections[0]?.id; + + return ( + + {data && ( + + )} + + ); +} + +function AllowedFoldersContent({ + emailAccountId, + driveConnectionId, + availableFolders, + savedFolders, + mutateFolders, +}: { + emailAccountId: string; + driveConnectionId: string | null; + availableFolders: FolderItem[]; + savedFolders: SavedFolder[]; + mutateFolders: () => void; +}) { + const [isFolderBusy, setIsFolderBusy] = useState(false); + + const handleFolderToggle = useCallback( + async (folder: FolderItem, isChecked: boolean) => { + const folderPath = folder.path || folder.name; + setIsFolderBusy(true); + + try { + if (isChecked) { + const result = await addFilingFolderAction(emailAccountId, { + folderId: folder.id, + folderName: folder.name, + folderPath, + driveConnectionId: folder.driveConnectionId, + }); + + if (result?.serverError) { + toastError({ + title: "Error adding folder", + description: result.serverError, + }); + } else { + mutateFolders(); + } + } else { + const result = await removeFilingFolderAction(emailAccountId, { + folderId: folder.id, + }); + + if (result?.serverError) { + toastError({ + title: "Error removing folder", + description: result.serverError, + }); + } else { + mutateFolders(); + } + } + } finally { + setIsFolderBusy(false); + } + }, + [emailAccountId, mutateFolders], + ); + + const rootFolders = useMemo(() => { + const folderMap = new Map(); + const roots: FolderItem[] = []; + + for (const folder of availableFolders) { + folderMap.set(folder.id, folder); + } + + for (const folder of availableFolders) { + if (!folder.parentId || !folderMap.has(folder.parentId)) { + roots.push(folder); + } + } + + return roots; + }, [availableFolders]); + + const folderChildrenMap = useMemo(() => { + const map = new Map(); + for (const folder of availableFolders) { + if (folder.parentId) { + if (!map.has(folder.parentId)) map.set(folder.parentId, []); + map.get(folder.parentId)!.push(folder); + } + } + return map; + }, [availableFolders]); + + const savedFolderIds = useMemo( + () => new Set(savedFolders.map((f) => f.folderId)), + [savedFolders], + ); + + return ( + + + Allowed folders + AI can only file to these folders + + + {rootFolders.length > 0 ? ( + + + {rootFolders.map((folder, index) => ( + + ))} + + + ) : ( + + )} + + + ); +} + +export function FolderNode({ + folder, + isLast, + selectedFolderIds, + onToggle, + isDisabled, + level, + parentPath, + knownChildren, +}: { + folder: FolderItem; + isLast: boolean; + selectedFolderIds: Set; + onToggle: (folder: FolderItem, isChecked: boolean) => void; + isDisabled: boolean; + level: number; + parentPath: string; + knownChildren?: FolderItem[]; +}) { + const { expandedIds } = useTree(); + const isExpanded = expandedIds.has(folder.id); + const isSelected = selectedFolderIds.has(folder.id); + const currentPath = parentPath ? `${parentPath}/${folder.name}` : folder.name; + + const { data: subfoldersData, isLoading: isLoadingSubfolders } = + useDriveSubfolders( + isExpanded && !knownChildren + ? { + folderId: folder.id, + driveConnectionId: folder.driveConnectionId, + } + : null, + ); + + const subfolders = subfoldersData?.folders ?? knownChildren ?? []; + const hasLoadedChildren = subfolders.length > 0; + + return ( + + + {isLoadingSubfolders ? ( +
+ +
+ ) : ( + + )} + +
+ + onToggle({ ...folder, path: currentPath }, checked === true) + } + disabled={isDisabled} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + } + }} + /> + {folder.name} +
+
+ + {hasLoadedChildren ? ( + subfolders.map((subfolder, index) => ( + + )) + ) : isExpanded && !isLoadingSubfolders ? ( +
+ No subfolders +
+ ) : null} +
+
+ ); +} + +export function NoFoldersFound({ + emailAccountId, + driveConnectionId, + onFolderCreated, +}: { + emailAccountId: string; + driveConnectionId: string | null; + onFolderCreated?: () => void; +}) { + const { isOpen, onClose, onToggle } = useDialogState(); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + reset, + } = useForm({ + resolver: zodResolver(createDriveFolderBody), + defaultValues: { driveConnectionId: "" }, + }); + + const onSubmit: SubmitHandler = useCallback( + async (data) => { + if (!driveConnectionId) { + toastError({ + title: "Error creating folder", + description: "No drive connection found", + }); + return; + } + + const result = await createDriveFolderAction(emailAccountId, { + ...data, + driveConnectionId, + }); + + if (result?.serverError) { + toastError({ + title: "Error creating folder", + description: result.serverError, + }); + } else { + toastSuccess({ description: "Folder created!" }); + reset(); + onClose(); + onFolderCreated?.(); + } + }, + [emailAccountId, reset, onClose, onFolderCreated, driveConnectionId], + ); + + return ( + + + + + + No folders found + + Create a folder in your drive to get started. + + + + + + + + + + Create folder + + Create a new folder in your drive to organize your files. + + +
+ + + +
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx b/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx new file mode 100644 index 0000000000..eefb588342 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { Button } from "@/components/ui/button"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { toastError } from "@/components/Toast"; +import { captureException } from "@/utils/error"; +import type { GetDriveAuthUrlResponse } from "@/app/api/google/drive/auth-url/route"; +import { fetchWithAccount } from "@/utils/fetch"; + +export function ConnectDrive() { + const { emailAccountId } = useAccount(); + const [isConnectingGoogle, setIsConnectingGoogle] = useState(false); + const [isConnectingMicrosoft, setIsConnectingMicrosoft] = useState(false); + + const handleConnectGoogle = async () => { + setIsConnectingGoogle(true); + try { + const response = await fetchWithAccount({ + url: "/api/google/drive/auth-url", + emailAccountId, + init: { headers: { "Content-Type": "application/json" } }, + }); + + if (!response.ok) { + throw new Error("Failed to initiate Google Drive connection"); + } + + const data: GetDriveAuthUrlResponse = await response.json(); + + if (!data?.url) throw new Error("Invalid auth URL"); + + window.location.href = data.url; + } catch (error) { + captureException(error, { + extra: { context: "Google Drive OAuth initiation" }, + }); + toastError({ + title: "Error initiating Google Drive connection", + description: "Please try again or contact support", + }); + setIsConnectingGoogle(false); + } + }; + + const handleConnectMicrosoft = async () => { + setIsConnectingMicrosoft(true); + try { + const response = await fetchWithAccount({ + url: "/api/outlook/drive/auth-url", + emailAccountId, + init: { headers: { "Content-Type": "application/json" } }, + }); + + if (!response.ok) { + throw new Error("Failed to initiate OneDrive connection"); + } + + const data: GetDriveAuthUrlResponse = await response.json(); + + if (!data?.url) throw new Error("Invalid auth URL"); + + window.location.href = data.url; + } catch (error) { + captureException(error, { + extra: { context: "OneDrive OAuth initiation" }, + }); + toastError({ + title: "Error initiating OneDrive connection", + description: "Please try again or contact support", + }); + setIsConnectingMicrosoft(false); + } + }; + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx b/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx new file mode 100644 index 0000000000..9f652a9d40 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { MoreVertical, Trash2, XCircle } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import type { GetDriveConnectionsResponse } from "@/app/api/user/drive/connections/route"; +import { disconnectDriveAction } from "@/utils/actions/drive"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { useDriveConnections } from "@/hooks/useDriveConnections"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import Image from "next/image"; + +type DriveConnection = GetDriveConnectionsResponse["connections"][0]; + +export function getProviderInfo(provider: string) { + const providers = { + microsoft: { + name: "OneDrive", + icon: "/images/microsoft.svg", + alt: "OneDrive", + }, + google: { + name: "Google Drive", + icon: "/images/google.svg", + alt: "Google Drive", + }, + }; + + return providers[provider as keyof typeof providers] || providers.google; +} + +export function DriveConnectionCard({ + connection, +}: { + connection: DriveConnection; +}) { + const { emailAccountId } = useAccount(); + const { mutate } = useDriveConnections(); + const providerInfo = getProviderInfo(connection.provider); + + const { executeAsync: executeDisconnect, isExecuting: isDisconnecting } = + useAction(disconnectDriveAction.bind(null, emailAccountId)); + + const handleDisconnect = async () => { + if (confirm("Are you sure you want to disconnect this drive?")) { + await executeDisconnect({ connectionId: connection.id }); + mutate(); + } + }; + + return ( +
+ + {providerInfo.name} + · + {connection.email} + {!connection.isConnected && ( +
+ + Disconnected +
+ )} + + + + + + + + Disconnect + + + +
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/drive/DriveConnections.tsx b/apps/web/app/(app)/[emailAccountId]/drive/DriveConnections.tsx new file mode 100644 index 0000000000..84ce9f8f9c --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/DriveConnections.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { LoadingContent } from "@/components/LoadingContent"; +import { useDriveConnections } from "@/hooks/useDriveConnections"; +import { DriveConnectionCard } from "./DriveConnectionCard"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "@/components/ui/empty"; + +export function DriveConnections() { + const { data, isLoading, error } = useDriveConnections(); + const connections = data?.connections || []; + + return ( + + {connections.length > 0 ? ( +
+ {connections.map((connection) => ( + + ))} +
+ ) : ( + + + No drive connections found + + Connect your drive to start organizing your documents. + + + + )} +
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/drive/DriveOnboarding.tsx b/apps/web/app/(app)/[emailAccountId]/drive/DriveOnboarding.tsx new file mode 100644 index 0000000000..1dce769337 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/DriveOnboarding.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { Card } from "@/components/ui/card"; +import { + PageSubHeading, + TypographyH3, + TypographyH4, +} from "@/components/Typography"; +import { ConnectDrive } from "./ConnectDrive"; + +const steps = [ + { + number: 1, + title: "Tell us how you organize", + description: '"Receipts go to Expenses by month. Contracts go to Legal."', + }, + { + number: 2, + title: "Attachments get filed", + description: "AI reads each document and files it to the right folder", + }, + { + number: 3, + title: "You stay in control", + description: "Get an email when files are sorted—reply to correct", + }, +]; + +export function DriveOnboarding() { + return ( +
+ + Attachments filed automatically while you work + + +
+ {steps.map((step) => ( +
+
+ {step.number} +
+
+ {step.title} + + {step.description} + +
+
+ ))} +
+ + + + Where should we file your attachments? + +
+ +
+
+
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/drive/DriveSetup.tsx b/apps/web/app/(app)/[emailAccountId]/drive/DriveSetup.tsx new file mode 100644 index 0000000000..41d20334cf --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/DriveSetup.tsx @@ -0,0 +1,942 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; +import { useForm, type SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import Link from "next/link"; +import { ExternalLinkIcon } from "lucide-react"; +import { + TypographyH3, + SectionDescription, + TypographyP, + TypographyH4, + MutedText, +} from "@/components/Typography"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Input } from "@/components/Input"; +import { toastSuccess, toastError } from "@/components/Toast"; +import { FilingStatusCell } from "@/components/drive/FilingStatusCell"; +import { YesNoIndicator } from "@/components/drive/YesNoIndicator"; +import { + TreeProvider, + TreeView, + TreeNode, + TreeNodeTrigger, + TreeNodeContent, + TreeExpander, + TreeIcon, +} from "@/components/kibo-ui/tree"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; +import { useDriveConnections } from "@/hooks/useDriveConnections"; +import { useDriveFolders } from "@/hooks/useDriveFolders"; +import { useFilingPreviewAttachments } from "@/hooks/useFilingPreviewAttachments"; +import { + addFilingFolderAction, + removeFilingFolderAction, + updateFilingPromptAction, + updateFilingEnabledAction, + moveFilingAction, + fileAttachmentAction, + submitPreviewFeedbackAction, + type FileAttachmentFiled, +} from "@/utils/actions/drive"; +import { + updateFilingPromptBody, + type UpdateFilingPromptBody, +} from "@/utils/actions/drive.validation"; +import { FolderNode, NoFoldersFound } from "./AllowedFolders"; +import type { + FolderItem, + SavedFolder, +} from "@/app/api/user/drive/folders/route"; +import { DriveConnectionCard, getProviderInfo } from "./DriveConnectionCard"; +import type { AttachmentPreviewItem } from "@/app/api/user/drive/preview/attachments/route"; +import { LoadingContent } from "@/components/LoadingContent"; +import { getEmailUrlForMessage } from "@/utils/url"; + +type SetupPhase = "setup" | "loading-attachments" | "preview" | "starting"; + +type FilingState = { + status: "pending" | "filing" | "filed" | "skipped" | "error"; + result?: FileAttachmentFiled; + error?: string; + skipReason?: string; +}; + +export function DriveSetup() { + const { emailAccountId } = useAccount(); + const { data: connectionsData } = useDriveConnections(); + const { + data: foldersData, + isLoading: foldersLoading, + mutate: mutateFolders, + } = useDriveFolders(); + const { data: emailAccount, mutate: mutateEmail } = useEmailAccountFull(); + + const connections = connectionsData?.connections || []; + const connection = connections[0]; + const providerInfo = connection ? getProviderInfo(connection.provider) : null; + + const [userPhase, setUserPhase] = useState< + "setup" | "previewing" | "starting" + >("setup"); + const [filingStates, setFilingStates] = useState>( + {}, + ); + + const shouldFetchAttachments = + userPhase === "previewing" || userPhase === "starting"; + const { data: attachmentsData, isLoading: attachmentsLoading } = + useFilingPreviewAttachments(shouldFetchAttachments, { + onSuccess: (data) => { + // Initialize states and trigger filing for each attachment + const initial: Record = {}; + for (const att of data.attachments) { + const key = `${att.messageId}-${att.filename}`; + initial[key] = { status: "filing" }; + + fileAttachmentAction(emailAccountId, { + messageId: att.messageId, + filename: att.filename, + }).then((result) => { + const resultData = result?.data; + if (result?.serverError) { + setFilingStates((prev) => ({ + ...prev, + [key]: { status: "error", error: result.serverError }, + })); + } else if (resultData?.skipped) { + setFilingStates((prev) => ({ + ...prev, + [key]: { + status: "skipped", + skipReason: resultData.skipReason, + }, + })); + } else if (resultData) { + setFilingStates((prev) => ({ + ...prev, + [key]: { status: "filed", result: resultData }, + })); + } else { + setFilingStates((prev) => ({ + ...prev, + [key]: { status: "error", error: "Unknown error" }, + })); + } + }); + } + setFilingStates(initial); + }, + onError: (err) => { + toastError({ + title: "Error fetching preview", + description: + err instanceof Error + ? err.message + : "Failed to load recent attachments. Please try again.", + }); + setUserPhase("setup"); + }, + }); + + const displayPhase = useMemo((): SetupPhase => { + if (userPhase === "setup") return "setup"; + if (userPhase === "starting") return "starting"; + if (attachmentsLoading) return "loading-attachments"; + if (attachmentsData) return "preview"; + return "loading-attachments"; + }, [userPhase, attachmentsLoading, attachmentsData]); + + const handlePreviewClick = useCallback(() => { + setUserPhase("previewing"); + }, []); + + const handleStartFiling = useCallback(async () => { + setUserPhase("starting"); + + const result = await updateFilingEnabledAction(emailAccountId, { + filingEnabled: true, + }); + + if (result?.serverError) { + toastError({ + title: "Error starting auto-filing", + description: result.serverError, + }); + setUserPhase("previewing"); + } else { + toastSuccess({ description: "Auto-filing started!" }); + mutateEmail(); + } + }, [emailAccountId, mutateEmail]); + + return ( +
+
+ Let's set up auto-filing + + We'll file attachments from your emails into your{" "} + {providerInfo?.name || "drive"}.
+ Just tell us where and how. +
+
+ +
+ {connection ? ( + + ) : ( + + No drive connection found. Please connect your drive to continue + setup. + + )} +
+ +
+ + + 0 : false} + phase={displayPhase} + onPreviewClick={handlePreviewClick} + /> + + {(displayPhase === "preview" || displayPhase === "starting") && + attachmentsData && ( + + )} +
+
+ ); +} + +function PreviewContent({ + emailAccountId, + attachments, + noAttachmentsFound, + availableFolders, + filingStates, + onStartFiling, + isStarting, +}: { + emailAccountId: string; + attachments: AttachmentPreviewItem[]; + noAttachmentsFound: boolean; + availableFolders: FolderItem[]; + filingStates: Record; + onStartFiling: () => void; + isStarting: boolean; +}) { + if (noAttachmentsFound) { + return ( + + ); + } + + return ( + + ); +} + +function NoAttachmentsMessage({ + onSkip, + isStarting, +}: { + onSkip: () => void; + isStarting: boolean; +}) { + return ( +
+ + We couldn't find recent emails with attachments to preview. + + +
+ ); +} + +function PreviewResults({ + emailAccountId, + attachments, + availableFolders, + filingStates, + onStartFiling, + isStarting, +}: { + emailAccountId: string; + attachments: AttachmentPreviewItem[]; + availableFolders: FolderItem[]; + filingStates: Record; + onStartFiling: () => void; + isStarting: boolean; +}) { + const { userEmail, provider } = useAccount(); + const [correctingId, setCorrectingId] = useState(null); + + const allComplete = attachments.every((att) => { + const key = `${att.messageId}-${att.filename}`; + const status = filingStates[key]?.status; + return status === "filed" || status === "skipped" || status === "error"; + }); + + const anyFiling = attachments.some((att) => { + const key = `${att.messageId}-${att.filename}`; + return filingStates[key]?.status === "filing" || !filingStates[key]; + }); + + const filedCount = attachments.filter((att) => { + const key = `${att.messageId}-${att.filename}`; + return filingStates[key]?.status === "filed"; + }).length; + + const skippedCount = attachments.filter((att) => { + const key = `${att.messageId}-${att.filename}`; + return filingStates[key]?.status === "skipped"; + }).length; + + const statusMessage = allComplete + ? filedCount > 0 + ? `Filed ${filedCount} attachment${filedCount !== 1 ? "s" : ""}${skippedCount > 0 ? `, skipped ${skippedCount}` : ""}:` + : `Skipped ${skippedCount} attachment${skippedCount !== 1 ? "s" : ""} (didn't match your filing preferences):` + : `Filing your ${attachments.length} most recent attachments...`; + + return ( +
+ 3. See it in action + {statusMessage} + +
+
+ + + File + Folder + Correct? + + + + {attachments.map((attachment) => { + const key = `${attachment.messageId}-${attachment.filename}`; + return ( + setCorrectingId(key)} + onCancelCorrect={() => setCorrectingId(null)} + userEmail={userEmail} + provider={provider} + /> + ); + })} + +
+ + +

+ Your feedback helps us learn +

+ +
+ +

+ You'll get an email each time we file something. Reply to correct us. +

+
+ + ); +} + +function FilingRow({ + emailAccountId, + attachment, + filingState, + availableFolders, + isCorrectingThis, + onCorrectClick, + onCancelCorrect, + userEmail, + provider, +}: { + emailAccountId: string; + attachment: AttachmentPreviewItem; + filingState: FilingState; + availableFolders: FolderItem[]; + isCorrectingThis: boolean; + onCorrectClick: () => void; + onCancelCorrect: () => void; + userEmail: string; + provider: string; +}) { + const [correctedPath, setCorrectedPath] = useState(null); + const [isMoving, setIsMoving] = useState(false); + const [vote, setVote] = useState(null); + + const folderPath = correctedPath ?? filingState.result?.folderPath ?? null; + + const handleMoveToFolder = useCallback( + async (folder: FolderItem) => { + const filingId = filingState.result?.filingId; + if (!filingId) return; + + const newPath = folder.path || folder.name; + setIsMoving(true); + + try { + await moveFilingAction(emailAccountId, { + filingId, + targetFolderId: folder.id, + targetFolderPath: newPath, + }); + setCorrectedPath(newPath); + setVote(true); + toastSuccess({ description: `Moved to ${folder.name}` }); + } catch { + toastError({ description: "Failed to move file" }); + } finally { + setIsMoving(false); + onCancelCorrect(); + } + }, + [emailAccountId, filingState.result?.filingId, onCancelCorrect], + ); + + const handleCorrectClick = useCallback(async () => { + const filingId = filingState.result?.filingId; + if (!filingId) return; + + setVote(true); + await submitPreviewFeedbackAction(emailAccountId, { + filingId, + feedbackPositive: true, + }); + }, [emailAccountId, filingState.result?.filingId]); + + const handleWrongClick = useCallback(() => { + setVote(false); + onCorrectClick(); + }, [onCorrectClick]); + + const isFiled = filingState.status === "filed"; + const isSkipped = filingState.status === "skipped"; + + if (isCorrectingThis && isFiled) { + return ( + + +
+

{attachment.filename}

+ Select the correct folder: + + +
+
+
+ ); + } + + const emailUrl = getEmailUrlForMessage( + attachment.messageId, + attachment.threadId, + userEmail, + provider, + ); + + return ( + + +
+ + {attachment.filename} + + + + +
+
+ + + + + {isFiled ? ( +
+ { + if (value) { + handleCorrectClick(); + } else { + handleWrongClick(); + } + }} + /> +
+ ) : isSkipped ? ( + + ) : ( +
+ )} + + + ); +} + +function SetupFolderSelection({ + emailAccountId, + availableFolders, + savedFolders, + connections, + mutateFolders, + isLoading, +}: { + emailAccountId: string; + availableFolders: FolderItem[]; + savedFolders: SavedFolder[]; + connections: Array<{ id: string; provider: string }>; + mutateFolders: () => void; + isLoading: boolean; +}) { + // Optimistic state for folder selection + const [optimisticFolderIds, setOptimisticFolderIds] = useState>( + () => new Set(savedFolders.map((f) => f.folderId)), + ); + + // Sync optimistic state when server data changes + const serverFolderIds = savedFolders.map((f) => f.folderId).join(","); + const prevServerFolderIds = useRef(serverFolderIds); + if (serverFolderIds !== prevServerFolderIds.current) { + prevServerFolderIds.current = serverFolderIds; + setOptimisticFolderIds(new Set(savedFolders.map((f) => f.folderId))); + } + + const handleFolderToggle = useCallback( + async (folder: FolderItem, isChecked: boolean) => { + const folderPath = folder.path || folder.name; + + // Optimistic update + setOptimisticFolderIds((prev) => { + const next = new Set(prev); + if (isChecked) { + next.add(folder.id); + } else { + next.delete(folder.id); + } + return next; + }); + + if (isChecked) { + const result = await addFilingFolderAction(emailAccountId, { + folderId: folder.id, + folderName: folder.name, + folderPath, + driveConnectionId: folder.driveConnectionId, + }); + + if (result?.serverError) { + // Revert on error + setOptimisticFolderIds((prev) => { + const next = new Set(prev); + next.delete(folder.id); + return next; + }); + toastError({ + title: "Error adding folder", + description: result.serverError, + }); + } else { + mutateFolders(); + } + } else { + const result = await removeFilingFolderAction(emailAccountId, { + folderId: folder.id, + }); + + if (result?.serverError) { + // Revert on error + setOptimisticFolderIds((prev) => { + const next = new Set(prev); + next.add(folder.id); + return next; + }); + toastError({ + title: "Error removing folder", + description: result.serverError, + }); + } else { + mutateFolders(); + } + } + }, + [emailAccountId, mutateFolders], + ); + + const rootFolders = useMemo(() => { + const folderMap = new Map(); + const roots: FolderItem[] = []; + + for (const folder of availableFolders) { + folderMap.set(folder.id, folder); + } + + for (const folder of availableFolders) { + if (!folder.parentId || !folderMap.has(folder.parentId)) { + roots.push(folder); + } + } + + return roots; + }, [availableFolders]); + + const folderChildrenMap = useMemo(() => { + const map = new Map(); + for (const folder of availableFolders) { + if (folder.parentId) { + if (!map.has(folder.parentId)) map.set(folder.parentId, []); + map.get(folder.parentId)!.push(folder); + } + } + return map; + }, [availableFolders]); + + return ( +
+ 1. Pick your folders + Which folders can we file to? + + + {rootFolders.length > 0 ? ( + <> +
+ + + {rootFolders.map((folder, index) => ( + + ))} + + +
+

+ We'll only ever put files in folders you select +

+ + ) : ( + + )} +
+
+ ); +} + +function SetupRulesForm({ + emailAccountId, + initialPrompt, + mutateEmail, + hasFolders, + phase, + onPreviewClick, +}: { + emailAccountId: string; + initialPrompt: string; + mutateEmail: () => void; + hasFolders: boolean; + phase: SetupPhase; + onPreviewClick: () => void; +}) { + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(updateFilingPromptBody), + defaultValues: { + filingPrompt: initialPrompt, + }, + }); + + const filingPrompt = watch("filingPrompt"); + const canPreview = (filingPrompt || "").trim().length > 0 && hasFolders; + const isLoading = phase === "loading-attachments"; + const showPreviewButton = + phase === "setup" || phase === "loading-attachments"; + + const onSubmit: SubmitHandler = useCallback( + async (data) => { + if (!canPreview) { + toastError({ + title: "Setup incomplete", + description: + "Please select at least one folder and describe how you organize files.", + }); + return; + } + + const result = await updateFilingPromptAction(emailAccountId, data); + + if (result?.serverError) { + toastError({ + title: "Error saving rules", + description: result.serverError, + }); + } else { + mutateEmail(); + // Trigger preview after successful save + onPreviewClick(); + } + }, + [canPreview, emailAccountId, mutateEmail, onPreviewClick], + ); + + return ( +
+

2. Describe how you organize

+ Tell us in plain English +
+ + {errors.filingPrompt && ( +

{errors.filingPrompt.message}

+ )} + {showPreviewButton && ( +
+ +
+ )} +
+
+ ); +} + +function SelectableFolderTree({ + folders, + selectedPath, + onSelect, + disabled, +}: { + folders: FolderItem[]; + selectedPath: string | null; + onSelect: (folder: FolderItem) => void; + disabled: boolean; +}) { + const { rootFolders, folderChildrenMap } = useMemo(() => { + const folderMap = new Map(); + const roots: FolderItem[] = []; + const childrenMap = new Map(); + + for (const folder of folders) { + folderMap.set(folder.id, folder); + } + + for (const folder of folders) { + if (!folder.parentId || !folderMap.has(folder.parentId)) { + roots.push(folder); + } else { + if (!childrenMap.has(folder.parentId)) { + childrenMap.set(folder.parentId, []); + } + childrenMap.get(folder.parentId)!.push(folder); + } + } + + return { rootFolders: roots, folderChildrenMap: childrenMap }; + }, [folders]); + + return ( + + + {rootFolders.map((folder, index) => ( + + ))} + + + ); +} + +function SelectableFolderNode({ + folder, + isLast, + selectedPath, + onSelect, + disabled, + level, + parentPath, + childrenMap, +}: { + folder: FolderItem; + isLast: boolean; + selectedPath: string | null; + onSelect: (folder: FolderItem) => void; + disabled: boolean; + level: number; + parentPath: string; + childrenMap: Map; +}) { + const currentPath = parentPath ? `${parentPath}/${folder.name}` : folder.name; + const isSelected = + selectedPath === currentPath || selectedPath === folder.path; + const children = childrenMap.get(folder.id) || []; + const hasChildren = children.length > 0; + + return ( + + + + + + + {hasChildren && ( + + {children.map((child, index) => ( + + ))} + + )} + + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/drive/FilingActivity.tsx b/apps/web/app/(app)/[emailAccountId]/drive/FilingActivity.tsx new file mode 100644 index 0000000000..28556e0ecd --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/FilingActivity.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { formatDistanceToNow } from "date-fns"; +import { ExternalLinkIcon } from "lucide-react"; +import { LoadingContent } from "@/components/LoadingContent"; +import { MutedText, SectionHeader } from "@/components/Typography"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useFilingActivity } from "@/hooks/useFilingActivity"; +import { getDriveFileUrl } from "@/utils/drive/url"; +import type { GetFilingsResponse } from "@/app/api/user/drive/filings/route"; +import { useDriveConnections } from "@/hooks/useDriveConnections"; +import type { GetDriveConnectionsResponse } from "@/app/api/user/drive/connections/route"; +import { TableCellWithTooltip } from "@/components/drive/TableCellWithTooltip"; +import { YesNoIndicator } from "@/components/drive/YesNoIndicator"; +import type { DriveProviderType } from "@/utils/drive/types"; + +export function FilingActivity() { + const { data, isLoading, error } = useFilingActivity({ + limit: 10, + offset: 0, + }); + const { data: connectionsData } = useDriveConnections(); + + return ( +
+ Recent Activity + + {data?.filings.length === 0 ? ( + No recently filed documents. + ) : ( +
+ + + + File + Folder + When + + Correct? + + + + + + {data?.filings.map((filing) => ( + + ))} + +
+ {data && data.total > 10 && ( + + Showing {data.filings.length} of {data.total} filings + + )} +
+ )} +
+
+ ); +} + +function FilingRow({ + filing, + connections, +}: { + filing: GetFilingsResponse["filings"][number]; + connections: GetDriveConnectionsResponse["connections"]; +}) { + const connection = connections.find((c) => c.id === filing.driveConnectionId); + + const driveUrl = filing.fileId + ? getDriveFileUrl(filing.fileId, connection?.provider as DriveProviderType) + : null; + + return ( + + + + {filing.filename} + + + + + + + + {formatDistanceToNow(new Date(filing.createdAt), { addSuffix: true })} + + + +
+ +
+
+ + {driveUrl && ( + + + + )} + +
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/drive/FilingPreferences.tsx b/apps/web/app/(app)/[emailAccountId]/drive/FilingPreferences.tsx new file mode 100644 index 0000000000..e831b087f9 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/FilingPreferences.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useAccount } from "@/providers/EmailAccountProvider"; +import { FilingRulesForm } from "./FilingRulesForm"; +import { AllowedFolders } from "./AllowedFolders"; + +export function FilingPreferences() { + const { emailAccountId } = useAccount(); + + return ( +
+ + +
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/drive/FilingRulesForm.tsx b/apps/web/app/(app)/[emailAccountId]/drive/FilingRulesForm.tsx new file mode 100644 index 0000000000..9a8d60a52d --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/FilingRulesForm.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useCallback } from "react"; +import { useForm, type SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/Input"; +import { toastSuccess, toastError } from "@/components/Toast"; +import { updateFilingPromptAction } from "@/utils/actions/drive"; +import { + updateFilingPromptBody, + type UpdateFilingPromptBody, +} from "@/utils/actions/drive.validation"; +import { LoadingContent } from "@/components/LoadingContent"; +import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; + +export function FilingRulesForm({ + emailAccountId, +}: { + emailAccountId: string; +}) { + const { data, isLoading, error, mutate } = useEmailAccountFull(); + + return ( + + {data && ( + + )} + + ); +} + +function FilingRulesFormContent({ + emailAccountId, + initialPrompt, + mutateEmail, +}: { + emailAccountId: string; + initialPrompt: string; + mutateEmail: () => void; +}) { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(updateFilingPromptBody), + defaultValues: { + filingPrompt: initialPrompt, + }, + }); + + const onSubmit: SubmitHandler = useCallback( + async (data) => { + const result = await updateFilingPromptAction(emailAccountId, data); + + if (result?.serverError) { + toastError({ + title: "Error saving rules", + description: result.serverError, + }); + } else { + toastSuccess({ description: "Filing rules saved" }); + mutateEmail(); + } + }, + [emailAccountId, mutateEmail], + ); + + return ( + + + Filing rules + How should we organize your files? + + +
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/drive/page.tsx b/apps/web/app/(app)/[emailAccountId]/drive/page.tsx new file mode 100644 index 0000000000..a2427f9aad --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { parseAsBoolean, useQueryState } from "nuqs"; +import { PageWrapper } from "@/components/PageWrapper"; +import { PageHeader } from "@/components/PageHeader"; +import { LoadingContent } from "@/components/LoadingContent"; +import { useDriveConnections } from "@/hooks/useDriveConnections"; +import { DriveConnections } from "./DriveConnections"; +import { FilingPreferences } from "./FilingPreferences"; +import { FilingActivity } from "./FilingActivity"; +import { DriveOnboarding } from "./DriveOnboarding"; +import { DriveSetup } from "./DriveSetup"; +import { Switch } from "@/components/ui/switch"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; +import { updateFilingEnabledAction } from "@/utils/actions/drive"; +import { toastError, toastSuccess } from "@/components/Toast"; +import { cn } from "@/utils"; +import { Badge } from "@/components/ui/badge"; + +type DriveView = "onboarding" | "setup" | "settings"; + +export default function DrivePage() { + const { emailAccountId } = useAccount(); + const { data, isLoading, error } = useDriveConnections(); + const { + data: emailAccount, + isLoading: emailLoading, + error: emailError, + mutate: mutateEmail, + } = useEmailAccountFull(); + const [forceOnboarding] = useQueryState("onboarding", parseAsBoolean); + const [forceSetup] = useQueryState("setup", parseAsBoolean); + + const hasConnections = (data?.connections?.length ?? 0) > 0; + const filingEnabled = emailAccount?.filingEnabled ?? false; + const [isSaving, setIsSaving] = useState(false); + + const view = getDriveView( + hasConnections, + filingEnabled, + forceOnboarding, + forceSetup, + ); + + const handleToggle = useCallback( + async (checked: boolean) => { + setIsSaving(true); + + const result = await updateFilingEnabledAction(emailAccountId, { + filingEnabled: checked, + }); + + if (result?.serverError) { + toastError({ + title: "Error saving preferences", + description: result.serverError, + }); + } else { + toastSuccess({ description: "Preferences saved" }); + mutateEmail(); + } + + setIsSaving(false); + }, + [emailAccountId, mutateEmail], + ); + + return ( + + + {view === "onboarding" && } + {view === "setup" && } + {view === "settings" && ( + <> +
+ +
+ {!filingEnabled && Paused} + +
+
+ +
+ + + +
+ + )} +
+
+ ); +} + +function getDriveView( + hasConnections: boolean, + filingEnabled: boolean, + forceOnboarding: boolean | null, + forceSetup: boolean | null, +): DriveView { + if (forceOnboarding === true || !hasConnections) return "onboarding"; + if (forceSetup === true || (hasConnections && !filingEnabled)) return "setup"; + return "settings"; +} diff --git a/apps/web/app/(app)/[emailAccountId]/integrations/IntegrationRow.tsx b/apps/web/app/(app)/[emailAccountId]/integrations/IntegrationRow.tsx index a6b13713c8..465a63c361 100644 --- a/apps/web/app/(app)/[emailAccountId]/integrations/IntegrationRow.tsx +++ b/apps/web/app/(app)/[emailAccountId]/integrations/IntegrationRow.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import type { GetIntegrationsResponse } from "@/app/api/mcp/integrations/route"; import type { GetMcpAuthUrlResponse } from "@/app/api/mcp/[integration]/auth-url/route"; import { Toggle } from "@/components/Toggle"; -import { TypographyP } from "@/components/Typography"; +import { MutedText, TypographyP } from "@/components/Typography"; import { Button } from "@/components/ui/button"; import { TableRow, TableCell } from "@/components/ui/table"; import { @@ -312,9 +312,9 @@ function ToolsList({ tools, onToggleTool }: ToolsListProps) {
{tool.description && ( -

+ {truncate(tool.description, 100)} -

+ )}
diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding/OnboardingCategories.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding/OnboardingCategories.tsx index d6a4ef3e8d..d321a011c4 100644 --- a/apps/web/app/(app)/[emailAccountId]/onboarding/OnboardingCategories.tsx +++ b/apps/web/app/(app)/[emailAccountId]/onboarding/OnboardingCategories.tsx @@ -44,6 +44,7 @@ import { isGoogleProvider, isMicrosoftProvider, } from "@/utils/email/provider-types"; +import { MutedText } from "@/components/Typography"; // copy paste of old file export function CategoriesSetup({ @@ -230,9 +231,7 @@ function CategoryCard({ ) : ( <>
{label}
-
- {description} -
+ {description} )}
diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding/StepFeatures.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding/StepFeatures.tsx index f58016662e..16747a760e 100644 --- a/apps/web/app/(app)/[emailAccountId]/onboarding/StepFeatures.tsx +++ b/apps/web/app/(app)/[emailAccountId]/onboarding/StepFeatures.tsx @@ -10,7 +10,7 @@ import { SparklesIcon, ZapIcon, } from "lucide-react"; -import { PageHeading, TypographyP } from "@/components/Typography"; +import { MutedText, PageHeading, TypographyP } from "@/components/Typography"; import { IconCircle } from "@/app/(app)/[emailAccountId]/onboarding/IconCircle"; import { OnboardingWrapper } from "@/app/(app)/[emailAccountId]/onboarding/OnboardingWrapper"; import { cn } from "@/utils"; @@ -91,9 +91,7 @@ export function StepFeatures({ onNext }: { onNext: () => void }) {
{choice.label}
-
- {choice.description} -
+ {choice.description}
))} diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding/StepIntro.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding/StepIntro.tsx index 865a6b89b7..48d8485586 100644 --- a/apps/web/app/(app)/[emailAccountId]/onboarding/StepIntro.tsx +++ b/apps/web/app/(app)/[emailAccountId]/onboarding/StepIntro.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import { MailIcon } from "lucide-react"; import { CardBasic } from "@/components/ui/card"; -import { PageHeading, TypographyP } from "@/components/Typography"; +import { MutedText, PageHeading, TypographyP } from "@/components/Typography"; import { IconCircle } from "@/app/(app)/[emailAccountId]/onboarding/IconCircle"; import { OnboardingWrapper } from "@/app/(app)/[emailAccountId]/onboarding/OnboardingWrapper"; import { ContinueButton } from "@/app/(app)/[emailAccountId]/onboarding/ContinueButton"; @@ -74,9 +74,7 @@ function Benefit({ {index}
{title}
-
-

{description}

-
+ {description}
diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding/StepWho.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding/StepWho.tsx index a2cc137841..c52383f366 100644 --- a/apps/web/app/(app)/[emailAccountId]/onboarding/StepWho.tsx +++ b/apps/web/app/(app)/[emailAccountId]/onboarding/StepWho.tsx @@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Form } from "@/components/ui/form"; import { Input } from "@/components/Input"; import { saveOnboardingAnswersAction } from "@/utils/actions/onboarding"; -import { PageHeading, TypographyP } from "@/components/Typography"; +import { MutedText, PageHeading, TypographyP } from "@/components/Typography"; import { usersRolesInfo } from "@/app/(app)/[emailAccountId]/onboarding/config"; import { USER_ROLES } from "@/utils/constants/user-roles"; import { cn } from "@/utils"; @@ -155,9 +155,7 @@ export function StepWho({
{roleName}
-
- {description} -
+ {description}
); diff --git a/apps/web/app/(app)/[emailAccountId]/reply-zero/ReplyTrackerEmails.tsx b/apps/web/app/(app)/[emailAccountId]/reply-zero/ReplyTrackerEmails.tsx index 27e4f0dcb7..00be54946d 100644 --- a/apps/web/app/(app)/[emailAccountId]/reply-zero/ReplyTrackerEmails.tsx +++ b/apps/web/app/(app)/[emailAccountId]/reply-zero/ReplyTrackerEmails.tsx @@ -35,6 +35,7 @@ import { useTableKeyboardNavigation } from "@/hooks/useTableKeyboardNavigation"; import { useIsMobile } from "@/hooks/use-mobile"; import { useAccount } from "@/providers/EmailAccountProvider"; import { isGoogleProvider } from "@/utils/email/provider-types"; +import { MutedText } from "@/components/Typography"; export function ReplyTrackerEmails({ trackers, @@ -344,9 +345,9 @@ function Row({ )} onClick={(e) => e.stopPropagation()} > -
+ {formatShortDate(internalDateToDate(message.internalDate))} -
+ {isResolved ? ( {isAnalyzing ? ( <> -

- Analyzing your emails... -

+ Analyzing your emails...
diff --git a/apps/web/app/(app)/[emailAccountId]/setup/SetupContent.tsx b/apps/web/app/(app)/[emailAccountId]/setup/SetupContent.tsx index eec7897c85..2c46af29c5 100644 --- a/apps/web/app/(app)/[emailAccountId]/setup/SetupContent.tsx +++ b/apps/web/app/(app)/[emailAccountId]/setup/SetupContent.tsx @@ -12,7 +12,11 @@ import { CalendarIcon, } from "lucide-react"; import { useLocalStorage } from "usehooks-ts"; -import { PageHeading, SectionDescription } from "@/components/Typography"; +import { + MutedText, + PageHeading, + SectionDescription, +} from "@/components/Typography"; import { Card } from "@/components/ui/card"; import { prefixPath } from "@/utils/path"; import { useSetupProgress } from "@/hooks/useSetupProgress"; @@ -51,7 +55,7 @@ function FeatureCard({

{title}

-

{description}

+ {description} ); diff --git a/apps/web/app/(app)/accounts/AddAccount.tsx b/apps/web/app/(app)/accounts/AddAccount.tsx index 913b436691..444f4040a2 100644 --- a/apps/web/app/(app)/accounts/AddAccount.tsx +++ b/apps/web/app/(app)/accounts/AddAccount.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { toastError } from "@/components/Toast"; import Image from "next/image"; -import { TypographyP } from "@/components/Typography"; +import { MutedText } from "@/components/Typography"; import { getAccountLinkingUrl } from "@/utils/account-linking"; export function AddAccount() { @@ -66,9 +66,7 @@ export function AddAccount() { - - You will be billed for each account. - + You will be billed for each account. ); } diff --git a/apps/web/app/(app)/accounts/CopyRulesDialog.tsx b/apps/web/app/(app)/accounts/CopyRulesDialog.tsx index e1c8db4f62..8594f06ea2 100644 --- a/apps/web/app/(app)/accounts/CopyRulesDialog.tsx +++ b/apps/web/app/(app)/accounts/CopyRulesDialog.tsx @@ -36,6 +36,7 @@ import { copyRulesFromAccountAction } from "@/utils/actions/rule"; import type { RulesResponse } from "@/app/api/user/rules/route"; import { EMAIL_ACCOUNT_HEADER } from "@/utils/config"; import { prefixPath } from "@/utils/path"; +import { MutedText } from "@/components/Typography"; type SourceAccount = { id: string; @@ -245,9 +246,9 @@ export function CopyRulesDialog({ ) : ( -
+ No rules found in {selectedSource?.email} -
+ )}
)} diff --git a/apps/web/app/(app)/organization/[organizationId]/stats/OrgStats.tsx b/apps/web/app/(app)/organization/[organizationId]/stats/OrgStats.tsx index 8bb7520167..5091cd5cd7 100644 --- a/apps/web/app/(app)/organization/[organizationId]/stats/OrgStats.tsx +++ b/apps/web/app/(app)/organization/[organizationId]/stats/OrgStats.tsx @@ -11,6 +11,7 @@ import { DatePickerWithRange } from "@/components/DatePickerWithRange"; import { useOrgStatsTotals } from "@/hooks/useOrgStatsTotals"; import { useOrgStatsEmailBuckets } from "@/hooks/useOrgStatsEmailBuckets"; import { useOrgStatsRulesBuckets } from "@/hooks/useOrgStatsRulesBuckets"; +import { MutedText } from "@/components/Typography"; const selectOptions = [ { label: "Last week", value: "7" }, @@ -189,14 +190,12 @@ function BucketChart({ {title} -

{description}

+ {description}
{!hasData ? (
-

- {emptyMessage} -

+ {emptyMessage}
) : (
diff --git a/apps/web/app/(landing)/components/page.tsx b/apps/web/app/(landing)/components/page.tsx index 44a1f33632..38f5954bbf 100644 --- a/apps/web/app/(landing)/components/page.tsx +++ b/apps/web/app/(landing)/components/page.tsx @@ -1,10 +1,19 @@ "use client"; import { SparklesIcon } from "lucide-react"; -import { CardBasic } from "@/components/ui/card"; +import { + Card, + CardBasic, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Container } from "@/components/Container"; import { PageHeading, + PageSubHeading, SectionDescription, SectionHeader, MessageText, @@ -12,6 +21,7 @@ import { TypographyH3, TypographyH4, TextLink, + MutedText, } from "@/components/Typography"; import { Button } from "@/components/Button"; import { Button as ShadButton } from "@/components/ui/button"; @@ -63,15 +73,59 @@ export default function Components() { TypographyH3 TypographyH4 SectionHeader + PageSubHeading SectionDescription MessageText TypographyP + MutedText TextLink
Card
This is a basic card. + +
+ + + Default Card + + This card uses the default size. + + + +

+ The default card has larger padding and text for better + readability in standard layouts. +

+
+ + + Action + + +
+ + + + Small Card + + This card uses the small size variant. + + + +

+ The card component supports a size prop that can be set to + "sm" for a more compact appearance. +

+
+ + + Action + + +
+
@@ -207,27 +261,23 @@ export default function Components() {
Premium Alerts
-

+ Basic Plan (needs upgrade to Business): -

+
-

- Pro Plan (needs API key): -

+ Pro Plan (needs API key):
-

- Free Plan (needs upgrade): -

+ Free Plan (needs upgrade):
@@ -369,9 +419,9 @@ export default function Components() { />
-

+ Complex example with multiple batches: -

+
ActivityLog
-

- Default with mixed states: -

+ Default with mixed states: -

Paused state:

+ Paused state: -

- Long text truncation test: -

+ Long text truncation test: -

All completed:

+ All completed: Premium Expired Banners
-

- Stripe Past Due: -

+ Stripe Past Due:
-

- Stripe Canceled: -

+ Stripe Canceled:
-

- LemonSqueezy Expired: -

+ LemonSqueezy Expired:
-

+ No Banner (Active Premium): -

+
-

+ No Banner (Never Had Premium): -

+
Banner should not appear for users who never had premium diff --git a/apps/web/app/(landing)/login/page.tsx b/apps/web/app/(landing)/login/page.tsx index fb8cff0a36..92f1eb873f 100644 --- a/apps/web/app/(landing)/login/page.tsx +++ b/apps/web/app/(landing)/login/page.tsx @@ -9,6 +9,7 @@ import { env } from "@/env"; import { Button } from "@/components/ui/button"; import { WELCOME_PATH } from "@/utils/config"; import { CrispChatLoggedOutVisible } from "@/components/CrispChat"; +import { MutedText } from "@/components/Typography"; import { isInternalPath } from "@/utils/path"; export const metadata: Metadata = { @@ -47,7 +48,7 @@ export default async function AuthenticationPage(props: { {searchParams?.error && } -

+ By clicking continue, you agree to our{" "} . -

+ -

+ Inbox Zero{"'"}s use and transfer of information received from Google APIs to any other app will adhere to{" "} {" "} Policy, including the Limited Use requirements. -

+
); diff --git a/apps/web/app/api/google/drive/auth-url/route.ts b/apps/web/app/api/google/drive/auth-url/route.ts new file mode 100644 index 0000000000..c03e378497 --- /dev/null +++ b/apps/web/app/api/google/drive/auth-url/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { withEmailAccount } from "@/utils/middleware"; +import { getGoogleDriveOAuth2Url } from "@/utils/drive/client"; +import { DRIVE_STATE_COOKIE_NAME } from "@/utils/drive/constants"; +import { + generateOAuthState, + oauthStateCookieOptions, +} from "@/utils/oauth/state"; + +export type GetDriveAuthUrlResponse = { url: string }; + +export const GET = withEmailAccount( + "google/drive/auth-url", + async (request) => { + const { emailAccountId } = request.auth; + const { url, state } = getAuthUrl({ emailAccountId }); + + const res: GetDriveAuthUrlResponse = { url }; + const response = NextResponse.json(res); + + response.cookies.set( + DRIVE_STATE_COOKIE_NAME, + state, + oauthStateCookieOptions, + ); + + return response; + }, +); + +const getAuthUrl = ({ emailAccountId }: { emailAccountId: string }) => { + const state = generateOAuthState({ + emailAccountId, + type: "drive", + }); + + const url = getGoogleDriveOAuth2Url(state); + + return { url, state }; +}; diff --git a/apps/web/app/api/google/drive/callback/route.ts b/apps/web/app/api/google/drive/callback/route.ts new file mode 100644 index 0000000000..fb0cfa76c0 --- /dev/null +++ b/apps/web/app/api/google/drive/callback/route.ts @@ -0,0 +1,14 @@ +import { withError } from "@/utils/middleware"; +import { handleDriveCallback } from "@/utils/drive/handle-drive-callback"; +import { exchangeGoogleDriveCode } from "@/utils/drive/client"; + +export const GET = withError("google/drive/callback", async (request) => { + return handleDriveCallback( + request, + { + name: "google", + exchangeCodeForTokens: exchangeGoogleDriveCode, + }, + request.logger, + ); +}); diff --git a/apps/web/app/api/google/webhook/types.ts b/apps/web/app/api/google/webhook/types.ts index af963f995b..bc94b03187 100644 --- a/apps/web/app/api/google/webhook/types.ts +++ b/apps/web/app/api/google/webhook/types.ts @@ -19,6 +19,9 @@ export type ProcessHistoryOptions = { rules: RuleWithActions[]; hasAutomationRules: boolean; hasAiAccess: boolean; - emailAccount: Pick & + emailAccount: Pick< + EmailAccount, + "autoCategorizeSenders" | "filingEnabled" | "filingPrompt" + > & EmailAccountWithAI; }; diff --git a/apps/web/app/api/outlook/drive/auth-url/route.ts b/apps/web/app/api/outlook/drive/auth-url/route.ts new file mode 100644 index 0000000000..df522790e1 --- /dev/null +++ b/apps/web/app/api/outlook/drive/auth-url/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { withEmailAccount } from "@/utils/middleware"; +import { getMicrosoftDriveOAuth2Url } from "@/utils/drive/client"; +import { DRIVE_STATE_COOKIE_NAME } from "@/utils/drive/constants"; +import { + generateOAuthState, + oauthStateCookieOptions, +} from "@/utils/oauth/state"; + +export type GetDriveAuthUrlResponse = { url: string }; + +export const GET = withEmailAccount(async (request) => { + const { emailAccountId } = request.auth; + const { url, state } = getAuthUrl({ emailAccountId }); + + const res: GetDriveAuthUrlResponse = { url }; + const response = NextResponse.json(res); + + response.cookies.set(DRIVE_STATE_COOKIE_NAME, state, oauthStateCookieOptions); + + return response; +}); + +const getAuthUrl = ({ emailAccountId }: { emailAccountId: string }) => { + const state = generateOAuthState({ + emailAccountId, + type: "drive", + }); + + const url = getMicrosoftDriveOAuth2Url(state); + + return { url, state }; +}; diff --git a/apps/web/app/api/outlook/drive/callback/route.ts b/apps/web/app/api/outlook/drive/callback/route.ts new file mode 100644 index 0000000000..bb5262d57a --- /dev/null +++ b/apps/web/app/api/outlook/drive/callback/route.ts @@ -0,0 +1,14 @@ +import { withError } from "@/utils/middleware"; +import { handleDriveCallback } from "@/utils/drive/handle-drive-callback"; +import { exchangeMicrosoftDriveCode } from "@/utils/drive/client"; + +export const GET = withError("outlook/drive/callback", async (request) => { + return handleDriveCallback( + request, + { + name: "microsoft", + exchangeCodeForTokens: exchangeMicrosoftDriveCode, + }, + request.logger, + ); +}); diff --git a/apps/web/app/api/user/drive/connections/route.ts b/apps/web/app/api/user/drive/connections/route.ts new file mode 100644 index 0000000000..43f70a2fc8 --- /dev/null +++ b/apps/web/app/api/user/drive/connections/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withEmailAccount } from "@/utils/middleware"; + +export type GetDriveConnectionsResponse = Awaited>; + +export const GET = withEmailAccount( + "user/drive/connections", + async (request) => { + const { emailAccountId } = request.auth; + + const result = await getData({ emailAccountId }); + return NextResponse.json(result); + }, +); + +async function getData({ emailAccountId }: { emailAccountId: string }) { + const connections = await prisma.driveConnection.findMany({ + where: { emailAccountId }, + select: { + id: true, + email: true, + provider: true, + isConnected: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + }); + + return { connections }; +} diff --git a/apps/web/app/api/user/drive/filings/route.ts b/apps/web/app/api/user/drive/filings/route.ts new file mode 100644 index 0000000000..278fd9f6ff --- /dev/null +++ b/apps/web/app/api/user/drive/filings/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/utils/prisma"; +import { withEmailAccount } from "@/utils/middleware"; + +export const querySchema = z.object({ + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0), +}); +export type GetFilingsQuery = z.infer; + +export type GetFilingsResponse = Awaited>; + +export const GET = withEmailAccount(async (request) => { + const { emailAccountId } = request.auth; + + const { searchParams } = new URL(request.url); + const query = querySchema.parse({ + limit: searchParams.get("limit"), + offset: searchParams.get("offset"), + }); + + const result = await getFilings({ emailAccountId, ...query }); + return NextResponse.json(result); +}); + +async function getFilings({ + emailAccountId, + limit, + offset, +}: { + emailAccountId: string; + limit: number; + offset: number; +}) { + const [filings, total] = await Promise.all([ + prisma.documentFiling.findMany({ + where: { emailAccountId }, + select: { + id: true, + filename: true, + folderPath: true, + fileId: true, + status: true, + confidence: true, + reasoning: true, + wasAsked: true, + wasCorrected: true, + originalPath: true, + createdAt: true, + driveConnectionId: true, + feedbackPositive: true, + }, + orderBy: { createdAt: "desc" }, + take: limit, + skip: offset, + }), + prisma.documentFiling.count({ where: { emailAccountId } }), + ]); + + return { + filings, + total, + hasMore: offset + filings.length < total, + }; +} diff --git a/apps/web/app/api/user/drive/folders/[folderId]/route.ts b/apps/web/app/api/user/drive/folders/[folderId]/route.ts new file mode 100644 index 0000000000..b6d169917d --- /dev/null +++ b/apps/web/app/api/user/drive/folders/[folderId]/route.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withEmailAccount } from "@/utils/middleware"; +import { createDriveProviderWithRefresh } from "@/utils/drive/provider"; +import { SafeError } from "@/utils/error"; +import type { Logger } from "@/utils/logger"; + +const querySchema = z.object({ driveConnectionId: z.string() }); +export type GetSubfoldersQuery = z.infer; + +export type GetSubfoldersResponse = Awaited>; + +export const GET = withEmailAccount(async (request, context) => { + const { emailAccountId } = request.auth; + const { folderId } = await context.params; + + const { searchParams } = new URL(request.url); + + const { driveConnectionId } = querySchema.parse({ + driveConnectionId: searchParams.get("driveConnectionId"), + }); + + const result = await getData({ + driveConnectionId, + emailAccountId, + folderId, + logger: request.logger, + }); + + return NextResponse.json(result); +}); + +async function getData({ + driveConnectionId, + emailAccountId, + folderId, + logger, +}: { + driveConnectionId: string; + emailAccountId: string; + folderId: string; + logger: Logger; +}) { + const driveConnection = await prisma.driveConnection.findFirst({ + where: { + id: driveConnectionId, + emailAccountId, + isConnected: true, + }, + }); + + if (!driveConnection) throw new SafeError("Drive connection not found"); + + const provider = await createDriveProviderWithRefresh( + driveConnection, + logger, + ); + const subfolders = await provider.listFolders(folderId); + + return { + folders: subfolders.map((folder) => ({ + id: folder.id, + name: folder.name, + path: folder.path || folder.name, + driveConnectionId: driveConnection.id, + provider: driveConnection.provider, + })), + }; +} diff --git a/apps/web/app/api/user/drive/folders/route.ts b/apps/web/app/api/user/drive/folders/route.ts new file mode 100644 index 0000000000..3afcda86fc --- /dev/null +++ b/apps/web/app/api/user/drive/folders/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withEmailAccount } from "@/utils/middleware"; +import { createDriveProviderWithRefresh } from "@/utils/drive/provider"; +import { SafeError } from "@/utils/error"; +import type { Logger } from "@/utils/logger"; + +export type GetDriveFoldersResponse = Awaited>; +export type FolderItem = GetDriveFoldersResponse["availableFolders"][number] & { + parentId?: string; +}; +export type SavedFolder = GetDriveFoldersResponse["savedFolders"][number]; + +export const GET = withEmailAccount(async (request) => { + const logger = request.logger; + const { emailAccountId } = request.auth; + + const result = await getData({ emailAccountId, logger }); + return NextResponse.json(result); +}); + +async function getData({ + emailAccountId, + logger, +}: { + emailAccountId: string; + logger: Logger; +}) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + driveConnections: { + where: { isConnected: true }, + }, + filingFolders: { + select: { + id: true, + folderId: true, + folderName: true, + folderPath: true, + driveConnectionId: true, + driveConnection: { + select: { provider: true }, + }, + }, + }, + }, + }); + + // Fetch top-level folders from each drive (depth 1 only) + const availableFolders: Array<{ + id: string; + name: string; + path: string; + driveConnectionId: string; + provider: string; + }> = []; + + const connectionErrors: Array<{ provider: string; error: unknown }> = []; + + const driveConnections = emailAccount?.driveConnections ?? []; + + for (const connection of driveConnections) { + try { + const provider = await createDriveProviderWithRefresh(connection, logger); + const folders = await provider.listFolders(undefined); + + for (const folder of folders) { + availableFolders.push({ + id: folder.id, + name: folder.name, + path: folder.name, + driveConnectionId: connection.id, + provider: connection.provider, + }); + } + } catch (error) { + logger.warn("Error fetching folders from drive", { + connectionId: connection.id, + provider: connection.provider, + error, + }); + connectionErrors.push({ provider: connection.provider, error }); + } + } + + // If we have connections but all failed, throw an error + if ( + driveConnections.length > 0 && + connectionErrors.length === driveConnections.length + ) { + throw new SafeError( + "Unable to access your drive. Please reconnect your drive and try again.", + ); + } + + return { + savedFolders: emailAccount?.filingFolders ?? [], + availableFolders, + }; +} diff --git a/apps/web/app/api/user/drive/preview/attachments/route.ts b/apps/web/app/api/user/drive/preview/attachments/route.ts new file mode 100644 index 0000000000..0d46774731 --- /dev/null +++ b/apps/web/app/api/user/drive/preview/attachments/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withEmailProvider } from "@/utils/middleware"; +import { SafeError } from "@/utils/error"; +import { getExtractableAttachments } from "@/utils/drive/filing-engine"; +import { extractNameFromEmail, extractEmailAddress } from "@/utils/email"; +import type { ParsedMessage } from "@/utils/types"; +import type { EmailProvider } from "@/utils/email/types"; +import type { Logger } from "@/utils/logger"; + +export type AttachmentPreviewItem = { + messageId: string; + threadId: string; + attachmentId: string; + filename: string; + mimeType: string; + size: number; + senderEmail: string; + senderName: string; + subject: string; +}; + +export type GetAttachmentsPreviewResponse = Awaited< + ReturnType +>; + +const MAX_MESSAGES_TO_FETCH = 20; +const MAX_ATTACHMENTS = 3; + +export const GET = withEmailProvider(async (request) => { + const { emailAccountId } = request.auth; + + const result = await getAttachmentsData({ + emailAccountId, + emailProvider: request.emailProvider, + logger: request.logger, + }); + + return NextResponse.json(result); +}); + +async function getAttachmentsData({ + emailAccountId, + emailProvider, + logger, +}: { + emailAccountId: string; + emailProvider: EmailProvider; + logger: Logger; +}) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { id: emailAccountId }, + select: { + id: true, + filingPrompt: true, + filingFolders: { select: { id: true } }, + driveConnections: { + where: { isConnected: true }, + select: { id: true }, + }, + }, + }); + + if (!emailAccount) { + throw new SafeError("Email account not found"); + } + + if (!emailAccount.filingPrompt) { + throw new SafeError( + "Please describe how you organize files before previewing", + ); + } + + if (emailAccount.filingFolders.length === 0) { + throw new SafeError("Please select at least one folder before previewing"); + } + + if (emailAccount.driveConnections.length === 0) { + throw new SafeError("No connected drives found"); + } + + logger.info("Fetching recent messages for attachments preview"); + const { messages } = await emailProvider.getMessagesWithAttachments({ + maxResults: MAX_MESSAGES_TO_FETCH, + }); + + const attachments = extractAttachmentPreviews(messages, MAX_ATTACHMENTS); + + logger.info("Attachments preview ready", { count: attachments.length }); + + return { + attachments, + noAttachmentsFound: attachments.length === 0, + }; +} + +function extractAttachmentPreviews( + messages: ParsedMessage[], + limit: number, +): AttachmentPreviewItem[] { + const result: AttachmentPreviewItem[] = []; + + for (const message of messages) { + const extractable = getExtractableAttachments(message); + for (const attachment of extractable) { + result.push({ + messageId: message.id, + threadId: message.threadId, + attachmentId: attachment.attachmentId, + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + senderEmail: extractEmailAddress(message.headers.from), + senderName: extractNameFromEmail(message.headers.from), + subject: message.headers.subject || message.subject || "(No subject)", + }); + if (result.length >= limit) return result; + } + } + + return result; +} diff --git a/apps/web/app/api/user/drive/preview/route.ts b/apps/web/app/api/user/drive/preview/route.ts new file mode 100644 index 0000000000..4a756484b3 --- /dev/null +++ b/apps/web/app/api/user/drive/preview/route.ts @@ -0,0 +1,231 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withEmailProvider } from "@/utils/middleware"; +import { SafeError } from "@/utils/error"; +import { + getExtractableAttachments, + processAttachment, +} from "@/utils/drive/filing-engine"; +import type { ParsedMessage, Attachment } from "@/utils/types"; +import type { EmailProvider } from "@/utils/email/types"; +import type { Logger } from "@/utils/logger"; +import type { DriveProviderType } from "@/utils/drive/types"; + +export type FilingPreviewResult = { + filingId: string; + filename: string; + folderPath: string; + fileId: string | null; + filedAt: string; + provider: DriveProviderType; +}; + +export type GetFilingPreviewResponse = Awaited< + ReturnType +>; + +const MAX_MESSAGES_TO_FETCH = 20; +const MAX_FILINGS = 3; + +export const GET = withEmailProvider(async (request) => { + const { emailAccountId } = request.auth; + + const result = await getPreviewData({ + emailAccountId, + emailProvider: request.emailProvider, + logger: request.logger, + }); + + return NextResponse.json(result); +}); + +async function getPreviewData({ + emailAccountId, + emailProvider, + logger, +}: { + emailAccountId: string; + emailProvider: EmailProvider; + logger: Logger; +}) { + 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, + }, + }, + filingFolders: { + include: { driveConnection: true }, + }, + driveConnections: { + where: { isConnected: true }, + }, + }, + }); + + if (!emailAccount) { + throw new SafeError("Email account not found"); + } + + if (!emailAccount.filingPrompt) { + throw new SafeError( + "Please describe how you organize files before previewing", + ); + } + + if (emailAccount.filingFolders.length === 0) { + throw new SafeError("Please select at least one folder before previewing"); + } + + if (emailAccount.driveConnections.length === 0) { + throw new SafeError("No connected drives found"); + } + + logger.info("Fetching recent messages"); + const { messages } = await emailProvider.getMessagesWithAttachments({ + maxResults: MAX_MESSAGES_TO_FETCH, + }); + + logger.info("Messages fetched", { + count: messages.length, + withAttachments: messages.filter((m) => m.attachments?.length).length, + allAttachmentTypes: messages + .flatMap((m) => m.attachments || []) + .map((a) => ({ filename: a.filename, mimeType: a.mimeType })) + .slice(0, 10), + }); + + const messagesWithAttachments = findMessagesWithExtractableAttachments( + messages, + MAX_FILINGS, + ); + + logger.info("Extractable attachments found", { + count: messagesWithAttachments.length, + files: messagesWithAttachments + .flatMap((m) => m.attachments) + .map((a) => a.filename), + }); + + if (messagesWithAttachments.length === 0) { + logger.info("No extractable attachments found - returning empty"); + return { filings: [], noAttachmentsFound: true }; + } + + const filings = await fileAttachments({ + messagesWithAttachments, + emailAccount: { + ...emailAccount, + filingEnabled: true, + filingPrompt: emailAccount.filingPrompt, + }, + emailProvider, + logger, + }); + + return { + filings, + noAttachmentsFound: filings.length === 0, + }; +} + +function findMessagesWithExtractableAttachments( + messages: ParsedMessage[], + limit: number, +): Array<{ message: ParsedMessage; attachments: Attachment[] }> { + const result: Array<{ message: ParsedMessage; attachments: Attachment[] }> = + []; + + for (const message of messages) { + const extractable = getExtractableAttachments(message); + if (extractable.length > 0) { + result.push({ message, attachments: extractable }); + if (result.length >= limit) break; + } + } + + return result; +} + +async function fileAttachments({ + messagesWithAttachments, + emailAccount, + emailProvider, + logger, +}: { + messagesWithAttachments: Array<{ + message: ParsedMessage; + attachments: Attachment[]; + }>; + emailAccount: Parameters[0]["emailAccount"]; + emailProvider: EmailProvider; + logger: Logger; +}): Promise { + const filings: FilingPreviewResult[] = []; + + for (const { message, attachments } of messagesWithAttachments) { + const attachment = attachments[0]; + + try { + logger.info("Filing attachment for preview", { + messageId: message.id, + filename: attachment.filename, + }); + + const result = await processAttachment({ + emailAccount, + message, + attachment, + emailProvider, + logger, + sendNotification: false, + }); + + if (result.success && result.filing) { + filings.push({ + 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, + }); + + logger.info("Preview filing complete", { filingId: result.filing.id }); + logger.trace("Preview filing complete", { + filename: result.filing.filename, + folderPath: result.filing.folderPath, + }); + } else { + logger.warn("Failed to file attachment for preview", { + error: result.error, + }); + } + } catch (attachmentError) { + logger.error("Error filing attachment for preview", { + messageId: message.id, + error: attachmentError, + }); + } + } + + return filings; +} diff --git a/apps/web/app/api/user/email-account/route.ts b/apps/web/app/api/user/email-account/route.ts index 053b27cf85..3c75f5480a 100644 --- a/apps/web/app/api/user/email-account/route.ts +++ b/apps/web/app/api/user/email-account/route.ts @@ -24,6 +24,8 @@ async function getEmailAccount({ emailAccountId }: { emailAccountId: string }) { signature: true, includeReferralSignature: true, writingStyle: true, + filingEnabled: true, + filingPrompt: true, }, }); diff --git a/apps/web/components.json b/apps/web/components.json index db8efc02e9..bace609725 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -16,6 +16,7 @@ "ui": "@/components/ui/" }, "registries": { - "@ai-elements": "https://registry.ai-sdk.dev/{name}.json" + "@ai-elements": "https://registry.ai-sdk.dev/{name}.json", + "@kibo-ui": "https://www.kibo-ui.com/r/{name}.json" } } diff --git a/apps/web/components/EmailViewer.tsx b/apps/web/components/EmailViewer.tsx index f3a7fdd7cc..32902e7024 100644 --- a/apps/web/components/EmailViewer.tsx +++ b/apps/web/components/EmailViewer.tsx @@ -9,6 +9,7 @@ import { LoadingContent } from "@/components/LoadingContent"; import { ErrorBoundary } from "@/components/ErrorBoundary"; import { useAccount } from "@/providers/EmailAccountProvider"; import { isGoogleProvider } from "@/utils/email/provider-types"; +import { MutedText } from "@/components/Typography"; export function EmailViewer() { const { provider } = useAccount(); @@ -36,9 +37,7 @@ export function EmailViewer() { ) ) : (
-

- This feature isn't enabled for Outlook. -

+ This feature isn't enabled for Outlook.
)} diff --git a/apps/web/components/PremiumCard.tsx b/apps/web/components/PremiumCard.tsx index f3a9eadd32..7bb616c8c8 100644 --- a/apps/web/components/PremiumCard.tsx +++ b/apps/web/components/PremiumCard.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { cn } from "@/utils"; import { HoverCard } from "@/components/HoverCard"; +import { MutedText } from "@/components/Typography"; interface PremiumData { lemonSqueezyRenewsAt?: Date | string | null; @@ -132,7 +133,7 @@ export function PremiumExpiredCardContent({

{title}

-

{description}

+ {description} + ); + } + + if (value === false) { + return ( + + ); + } + + // value is null or undefined + if (!isInteractive) { + return ; + } + + return ( +
+ + +
+ ); +} 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({ )}
-

+ -

+ {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: {}