diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 7289c06..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(/Users/deprogram/_work/2026/dynobox/server/.env.example << 'EOF'\nGOOGLE_CLIENT_ID=your_client_id_here\nGOOGLE_CLIENT_SECRET=your_client_secret_here\nPORT=3001\nCLIENT_URL=http://localhost:5173\nEOF)", - "Bash(/Users/deprogram/_work/2026/dynobox/server/index.js << 'ENDOFFILE'\nimport express from 'express';\nimport cors from 'cors';\nimport dotenv from 'dotenv';\nimport authRouter from './routes/auth.js';\nimport gmailRouter from './routes/gmail.js';\n\ndotenv.config\\(\\);\n\nconst app = express\\(\\);\nconst PORT = process.env.PORT || 3001;\n\napp.use\\(cors\\({\n origin: process.env.CLIENT_URL || 'http://localhost:5173',\n credentials: true,\n}\\)\\);\napp.use\\(express.json\\({ limit: '10mb' }\\)\\);\n\napp.use\\('/auth', authRouter\\);\napp.use\\('/api/gmail', gmailRouter\\);\n\napp.get\\('/health', \\(req, res\\) => res.json\\({ status: 'ok', app: 'dynobox' }\\)\\);\n\napp.listen\\(PORT, \\(\\) => {\n console.log\\(`\\\\n ⚡ dynobox server running on http://localhost:${PORT}`\\);\n console.log\\(` → Auth: http://localhost:${PORT}/auth`\\);\n console.log\\(` → API: http://localhost:${PORT}/api/gmail/threads\\\\n`\\);\n}\\);\nENDOFFILE)", - "Bash(npm create:*)", - "Bash(npm install:*)", - "Bash(npx tailwindcss:*)", - "Bash(/Users/deprogram/_work/2026/dynobox/client/src/index.css << 'ENDOFFILE'\n@import url\\('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap'\\);\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n:root {\n --font-sans: 'IBM Plex Sans', system-ui, sans-serif;\n --font-mono: 'IBM Plex Mono', monospace;\n color-scheme: dark;\n}\n\n*, *::before, *::after {\n box-sizing: border-box;\n}\n\nhtml, body, #root {\n height: 100%;\n margin: 0;\n padding: 0;\n overflow: hidden;\n background: #080808;\n color: #f0f0f0;\n font-family: var\\(--font-sans\\);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n\n/* Scrollbars */\n::-webkit-scrollbar {\n width: 4px;\n height: 4px;\n}\n::-webkit-scrollbar-track {\n background: transparent;\n}\n::-webkit-scrollbar-thumb {\n background: rgba\\(255,255,255,0.12\\);\n border-radius: 2px;\n}\n::-webkit-scrollbar-thumb:hover {\n background: rgba\\(255,255,255,0.2\\);\n}\n\n/* Email HTML content isolation */\n.email-body {\n all: initial;\n display: block;\n font-family: var\\(--font-sans\\);\n font-size: 14px;\n line-height: 1.6;\n color: #e0e0e0;\n}\n.email-body * {\n max-width: 100%;\n word-break: break-word;\n}\n.email-body a {\n color: #e8ff5a;\n}\n.email-body img {\n max-width: 100%;\n height: auto;\n}\n.email-body blockquote {\n border-left: 2px solid rgba\\(255,255,255,0.15\\);\n margin: 8px 0;\n padding: 4px 12px;\n color: #888;\n}\n.email-body table {\n border-collapse: collapse;\n}\n.email-body pre, .email-body code {\n font-family: var\\(--font-mono\\);\n font-size: 13px;\n background: rgba\\(255,255,255,0.06\\);\n border-radius: 3px;\n padding: 2px 4px;\n}\n\n/* Compose editor */\n[contenteditable=\"true\"] {\n outline: none;\n}\n[contenteditable=\"true\"]:empty::before {\n content: attr\\(data-placeholder\\);\n color: #555;\n pointer-events: none;\n}\n\n/* Selection */\n::selection {\n background: rgba\\(232,255,90,0.25\\);\n}\n\n/* Thread list item */\n.thread-item {\n transition: background 0.08s ease;\n}\n\n/* Keyboard hint badge */\n.key-hint {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 18px;\n height: 18px;\n padding: 0 4px;\n background: rgba\\(255,255,255,0.08\\);\n border: 1px solid rgba\\(255,255,255,0.12\\);\n border-radius: 3px;\n font-family: var\\(--font-mono\\);\n font-size: 10px;\n color: #888;\n}\n\n/* Shimmer loading */\n@keyframes shimmer {\n 0% { background-position: -200% 0; }\n 100% { background-position: 200% 0; }\n}\n.shimmer {\n background: linear-gradient\\(90deg, #1a1a1a 25%, #242424 50%, #1a1a1a 75%\\);\n background-size: 200% 100%;\n animation: shimmer 1.5s infinite;\n}\nENDOFFILE)", - "Bash(/Users/deprogram/_work/2026/dynobox/client/src/context/AppContext.jsx << 'ENDOFFILE'\nimport React, { createContext, useContext, useState, useCallback, useEffect } from 'react';\nimport { api, setCurrentAccount, getCurrentAccount } from '../lib/api.js';\nimport toast from 'react-hot-toast';\n\nconst AppContext = createContext\\(null\\);\n\nexport function AppProvider\\({ children }\\) {\n const [accounts, setAccounts] = useState\\([]\\);\n const [activeAccount, setActiveAccountState] = useState\\(null\\);\n const [threads, setThreads] = useState\\([]\\);\n const [selectedThreadId, setSelectedThreadId] = useState\\(null\\);\n const [activeThread, setActiveThread] = useState\\(null\\);\n const [loadingThreads, setLoadingThreads] = useState\\(false\\);\n const [loadingThread, setLoadingThread] = useState\\(false\\);\n const [nextPageToken, setNextPageToken] = useState\\(null\\);\n const [searchQuery, setSearchQuery] = useState\\(''\\);\n const [currentLabel, setCurrentLabel] = useState\\('INBOX'\\);\n const [composing, setComposing] = useState\\(null\\); // null | { mode: 'new'|'reply'|'replyAll', thread, message }\n const [focusedIndex, setFocusedIndex] = useState\\(0\\);\n\n const setActiveAccount = useCallback\\(\\(account\\) => {\n setActiveAccountState\\(account\\);\n setCurrentAccount\\(account?.id || null\\);\n }, []\\);\n\n const loadAccounts = useCallback\\(async \\(\\) => {\n try {\n const { accounts: accts } = await api.accounts.list\\(\\);\n setAccounts\\(accts\\);\n if \\(accts.length > 0 && !getCurrentAccount\\(\\)\\) {\n setActiveAccount\\(accts[0]\\);\n }\n } catch \\(e\\) {\n // no accounts yet\n }\n }, [setActiveAccount]\\);\n\n const loadThreads = useCallback\\(async \\(opts = {}\\) => {\n if \\(!getCurrentAccount\\(\\)\\) return;\n const { append = false, query, label, token } = opts;\n if \\(!append\\) setLoadingThreads\\(true\\);\n try {\n const q = query !== undefined ? query : searchQuery;\n const lbl = label !== undefined ? label : currentLabel;\n const params = {};\n if \\(q\\) params.q = q;\n else params.label = lbl;\n if \\(token\\) params.pageToken = token;\n const data = await api.threads.list\\(params\\);\n setThreads\\(\\(prev\\) => \\(append ? [...prev, ...data.threads] : data.threads\\)\\);\n setNextPageToken\\(data.nextPageToken || null\\);\n if \\(!append\\) {\n setSelectedThreadId\\(null\\);\n setActiveThread\\(null\\);\n setFocusedIndex\\(0\\);\n }\n } catch \\(e\\) {\n toast.error\\('Failed to load threads'\\);\n } finally {\n setLoadingThreads\\(false\\);\n }\n }, [searchQuery, currentLabel]\\);\n\n const openThread = useCallback\\(async \\(threadId\\) => {\n setSelectedThreadId\\(threadId\\);\n setLoadingThread\\(true\\);\n setActiveThread\\(null\\);\n try {\n const data = await api.threads.get\\(threadId\\);\n setActiveThread\\(data\\);\n // Mark as read\n const thread = threads.find\\(t => t.id === threadId\\);\n if \\(thread?.isUnread\\) {\n await api.threads.markRead\\(threadId\\);\n setThreads\\(prev => prev.map\\(t =>\n t.id === threadId ? { ...t, isUnread: false, labelIds: t.labelIds.filter\\(l => l !== 'UNREAD'\\) } : t\n \\)\\);\n }\n } catch \\(e\\) {\n toast.error\\('Failed to load thread'\\);\n } finally {\n setLoadingThread\\(false\\);\n }\n }, [threads]\\);\n\n const archiveThread = useCallback\\(async \\(threadId\\) => {\n try {\n await api.threads.archive\\(threadId\\);\n setThreads\\(prev => prev.filter\\(t => t.id !== threadId\\)\\);\n if \\(selectedThreadId === threadId\\) {\n setSelectedThreadId\\(null\\);\n setActiveThread\\(null\\);\n }\n toast.success\\('Archived'\\);\n } catch \\(e\\) {\n toast.error\\('Archive failed'\\);\n }\n }, [selectedThreadId]\\);\n\n const trashThread = useCallback\\(async \\(threadId\\) => {\n try {\n await api.threads.trash\\(threadId\\);\n setThreads\\(prev => prev.filter\\(t => t.id !== threadId\\)\\);\n if \\(selectedThreadId === threadId\\) {\n setSelectedThreadId\\(null\\);\n setActiveThread\\(null\\);\n }\n toast.success\\('Moved to trash'\\);\n } catch \\(e\\) {\n toast.error\\('Trash failed'\\);\n }\n }, [selectedThreadId]\\);\n\n const loadMore = useCallback\\(\\(\\) => {\n if \\(nextPageToken\\) loadThreads\\({ append: true, token: nextPageToken }\\);\n }, [nextPageToken, loadThreads]\\);\n\n const search = useCallback\\(\\(q\\) => {\n setSearchQuery\\(q\\);\n loadThreads\\({ query: q }\\);\n }, [loadThreads]\\);\n\n const switchLabel = useCallback\\(\\(label\\) => {\n setCurrentLabel\\(label\\);\n setSearchQuery\\(''\\);\n loadThreads\\({ label, query: '' }\\);\n }, [loadThreads]\\);\n\n const refreshThreads = useCallback\\(\\(\\) => {\n loadThreads\\({}\\);\n }, [loadThreads]\\);\n\n // Load when account changes\n useEffect\\(\\(\\) => {\n if \\(activeAccount\\) loadThreads\\({}\\);\n }, [activeAccount]\\);\n\n useEffect\\(\\(\\) => {\n loadAccounts\\(\\);\n }, []\\);\n\n return \\(\n \n {children}\n \n \\);\n}\n\nexport function useApp\\(\\) {\n const ctx = useContext\\(AppContext\\);\n if \\(!ctx\\) throw new Error\\('useApp must be inside AppProvider'\\);\n return ctx;\n}\nENDOFFILE)", - "Bash(/Users/deprogram/_work/2026/dynobox/client/src/hooks/useKeyboard.js << 'ENDOFFILE'\nimport { useEffect } from 'react';\nimport { useApp } from '../context/AppContext.jsx';\nimport { api } from '../lib/api.js';\nimport toast from 'react-hot-toast';\n\nexport function useKeyboard\\({ onSearch }\\) {\n const {\n threads, focusedIndex, setFocusedIndex,\n selectedThreadId, openThread,\n archiveThread, trashThread,\n setComposing, activeThread,\n loadMore, nextPageToken,\n } = useApp\\(\\);\n\n useEffect\\(\\(\\) => {\n const handler = \\(e\\) => {\n // Don't fire when typing in inputs or contenteditable\n const tag = e.target?.tagName?.toLowerCase\\(\\);\n const isCE = e.target?.contentEditable === 'true';\n if \\(['input', 'textarea', 'select'].includes\\(tag\\) || isCE\\) {\n // Allow Escape to close compose\n if \\(e.key === 'Escape'\\) {\n e.preventDefault\\(\\);\n setComposing\\(null\\);\n }\n return;\n }\n\n switch \\(e.key\\) {\n case 'j':\n case 'ArrowDown': {\n e.preventDefault\\(\\);\n const next = Math.min\\(focusedIndex + 1, threads.length - 1\\);\n setFocusedIndex\\(next\\);\n if \\(next === threads.length - 1 && nextPageToken\\) loadMore\\(\\);\n break;\n }\n case 'k':\n case 'ArrowUp': {\n e.preventDefault\\(\\);\n setFocusedIndex\\(Math.max\\(focusedIndex - 1, 0\\)\\);\n break;\n }\n case 'Enter': {\n e.preventDefault\\(\\);\n const t = threads[focusedIndex];\n if \\(t\\) openThread\\(t.id\\);\n break;\n }\n case 'e': {\n e.preventDefault\\(\\);\n const threadId = selectedThreadId || threads[focusedIndex]?.id;\n if \\(threadId\\) archiveThread\\(threadId\\);\n break;\n }\n case '#': {\n e.preventDefault\\(\\);\n const threadId = selectedThreadId || threads[focusedIndex]?.id;\n if \\(threadId\\) trashThread\\(threadId\\);\n break;\n }\n case 'r': {\n e.preventDefault\\(\\);\n if \\(activeThread\\) {\n const msgs = activeThread.messages;\n const lastMsg = msgs[msgs.length - 1];\n setComposing\\({ mode: 'reply', thread: activeThread, message: lastMsg }\\);\n }\n break;\n }\n case 'a': {\n e.preventDefault\\(\\);\n if \\(activeThread\\) {\n const msgs = activeThread.messages;\n const lastMsg = msgs[msgs.length - 1];\n setComposing\\({ mode: 'replyAll', thread: activeThread, message: lastMsg }\\);\n }\n break;\n }\n case 'c': {\n e.preventDefault\\(\\);\n setComposing\\({ mode: 'new' }\\);\n break;\n }\n case '/': {\n e.preventDefault\\(\\);\n onSearch?.\\(\\);\n break;\n }\n case 'Escape': {\n setComposing\\(null\\);\n break;\n }\n case 'u': {\n e.preventDefault\\(\\);\n const threadId = selectedThreadId || threads[focusedIndex]?.id;\n if \\(threadId\\) {\n api.threads.markUnread\\(threadId\\).then\\(\\(\\) => {\n toast.success\\('Marked unread'\\);\n }\\);\n }\n break;\n }\n case 's': {\n e.preventDefault\\(\\);\n const threadId = selectedThreadId || threads[focusedIndex]?.id;\n if \\(threadId\\) {\n api.threads.star\\(threadId\\).then\\(\\(\\) => {\n toast.success\\('Starred'\\);\n }\\);\n }\n break;\n }\n default:\n break;\n }\n };\n\n window.addEventListener\\('keydown', handler\\);\n return \\(\\) => window.removeEventListener\\('keydown', handler\\);\n }, [threads, focusedIndex, selectedThreadId, activeThread, nextPageToken]\\);\n}\nENDOFFILE)", - "Bash(/Users/deprogram/_work/2026/dynobox/client/src/components/NoAccounts.jsx << 'ENDOFFILE'\nimport React from 'react';\n\nexport default function NoAccounts\\(\\) {\n const handleConnect = \\(\\) => {\n window.location.href = 'http://localhost:3001/auth';\n };\n\n return \\(\n
\n
\n dyno\n mail\n
\n

\n Connect your Gmail account to get started. Your credentials stay local — never leave your machine.\n

\n \n \n \n \n \n \n \n Connect Gmail Account\n \n \n

\n You'll be redirected to Google's OAuth screen\n

\n
\n \\);\n}\nENDOFFILE)", - "Bash(/Users/deprogram/_work/2026/dynobox/client/src/App.jsx << 'ENDOFFILE'\nimport React, { useRef, useEffect, useCallback } from 'react';\nimport { Toaster } from 'react-hot-toast';\nimport { AppProvider, useApp } from './context/AppContext.jsx';\nimport Sidebar from './components/Sidebar.jsx';\nimport ThreadList from './components/ThreadList.jsx';\nimport ThreadView from './components/ThreadView.jsx';\nimport Compose from './components/Compose.jsx';\nimport SearchBar from './components/SearchBar.jsx';\nimport NoAccounts from './components/NoAccounts.jsx';\nimport { useKeyboard } from './hooks/useKeyboard.js';\n\nfunction AppShell\\(\\) {\n const { accounts, loadAccounts, refreshThreads } = useApp\\(\\);\n const searchRef = useRef\\(null\\);\n\n const focusSearch = useCallback\\(\\(\\) => {\n searchRef.current?.focus\\(\\);\n searchRef.current?.select\\(\\);\n }, []\\);\n\n useKeyboard\\({ onSearch: focusSearch }\\);\n\n // Handle auth success redirect\n useEffect\\(\\(\\) => {\n const params = new URLSearchParams\\(window.location.search\\);\n if \\(params.get\\('auth_success'\\)\\) {\n window.history.replaceState\\({}, '', '/'\\);\n loadAccounts\\(\\);\n }\n if \\(params.get\\('auth_error'\\)\\) {\n console.error\\('Auth error:', params.get\\('auth_error'\\)\\);\n window.history.replaceState\\({}, '', '/'\\);\n }\n }, [loadAccounts]\\);\n\n if \\(accounts.length === 0\\) {\n return ;\n }\n\n return \\(\n
\n {/* Sidebar */}\n \n\n {/* Main content */}\n
\n {/* Top bar */}\n
\n
\n \n
\n
\n \n ↺\n \n \n
\n
\n\n {/* Two-panel */}\n
\n {/* Thread list */}\n
\n \n
\n\n {/* Thread view */}\n
\n \n
\n
\n
\n\n {/* Compose overlay */}\n \n
\n \\);\n}\n\nfunction HintPill\\({ keys, label }\\) {\n return \\(\n
\n {keys.map\\(\\(k\\) => \\(\n {k}\n \\)\\)}\n {label}\n
\n \\);\n}\n\nexport default function App\\(\\) {\n return \\(\n \n \n \n \n \\);\n}\nENDOFFILE)", - "Bash(/Users/deprogram/_work/2026/dynobox/client/src/App.css << 'EOF'\n/* All styles live in index.css */\nEOF)", - "Bash(/Users/deprogram/_work/2026/dynobox/package.json << 'ENDOFFILE'\n{\n \"name\": \"dynobox\",\n \"version\": \"1.0.0\",\n \"description\": \"Personal Gmail client — keyboard-first, Superhuman-style\",\n \"scripts\": {\n \"dev\": \"concurrently --kill-others-on-fail -n server,client -c cyan,yellow \\\\\"npm run dev --prefix server\\\\\" \\\\\"npm run dev --prefix client\\\\\"\",\n \"server\": \"npm run dev --prefix server\",\n \"client\": \"npm run dev --prefix client\"\n },\n \"devDependencies\": {\n \"concurrently\": \"^8.2.2\"\n }\n}\nENDOFFILE)", - "Bash(/Users/deprogram/_work/2026/dynobox/.gitignore << 'ENDOFFILE'\nnode_modules/\nserver/node_modules/\nclient/node_modules/\nserver/.tokens.json\nserver/.env\n.env\ndist/\n.DS_Store\nENDOFFILE)", - "Bash(__NEW_LINE_8f2000a9feb6d691__ cat)", - "Bash(/Users/deprogram/_work/2026/dynobox/server/.env << 'ENDOFFILE'\nGOOGLE_CLIENT_ID=\nGOOGLE_CLIENT_SECRET=\nPORT=3001\nCLIENT_URL=http://localhost:5173\nENDOFFILE)", - "Bash(npm run build:*)", - "Bash(timeout 3 node:*)", - "Bash(node --check:*)", - "Bash(/Users/deprogram/_work/2026/dynobox/README.md << 'ENDOFFILE'\n# dynobox\n\nA personal Gmail client. Keyboard-first, Superhuman-style. Dark theme. Runs entirely on your machine.\n\n## Setup\n\n### 1. Google OAuth credentials\n\n1. Go to [Google Cloud Console]\\(https://console.cloud.google.com/\\)\n2. Create a project → **APIs & Services → Credentials**\n3. Create **OAuth 2.0 Client ID** → Application type: **Web application**\n4. Add authorized redirect URI: `http://localhost:3001/auth/callback`\n5. Enable **Gmail API** under APIs & Services → Library\n\n### 2. Configure environment\n\n```bash\ncp server/.env.example server/.env\n```\n\nEdit `server/.env`:\n```\nGOOGLE_CLIENT_ID=your_client_id\nGOOGLE_CLIENT_SECRET=your_client_secret\nPORT=3001\nCLIENT_URL=http://localhost:5173\n```\n\n### 3. Install & run\n\n```bash\nnpm install\nnpm run dev\n```\n\nThis starts both the Express backend \\(port 3001\\) and Vite frontend \\(port 5173\\).\n\n### 4. Connect your Gmail account\n\nOpen `http://localhost:5173` → click **Connect Gmail Account** → complete Google OAuth flow.\n\nTokens are stored in `server/.tokens.json` \\(gitignored\\). Repeat to add more accounts.\n\n## Keyboard shortcuts\n\n| Key | Action |\n|-----|--------|\n| `j` / `↓` | Next thread |\n| `k` / `↑` | Previous thread |\n| `↵` | Open thread |\n| `e` | Archive |\n| `#` | Trash |\n| `r` | Reply |\n| `a` | Reply all |\n| `c` | Compose |\n| `/` | Search |\n| `s` | Star |\n| `u` | Mark unread |\n| `⌘↵` | Send \\(in compose\\) |\n| `Esc` | Close compose |\n\n## Stack\n\n- **Frontend**: React + Vite + TailwindCSS + DOMPurify\n- **Backend**: Node.js + Express + googleapis\n- **Auth**: OAuth 2.0, refresh tokens stored in `.tokens.json`\nENDOFFILE)" - ] - } -} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3e94c7a --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Root Electron/runtime env. +# Electron loads this file in desktop mode before falling back to server/.env. + +# Optional Electron/desktop runtime: +# ELECTRON_START_URL=http://localhost:5173 +# DYNOBOX_SERVER_BASE_URL=http://localhost:3001 +# DYNOBOX_API_KEY=shared_secret_for_local_api_requests +# DYNOBOX_SUPPRESS_CHROMIUM_WARNINGS=true +# DYNOBOX_DISABLE_HARDWARE_ACCELERATION=false + +# Optional REX/BRAIN BOX AI config: +# REX_PLATFORM_API_KEY=optional_platform_provider_key +# VERCEL_AI_GATEWAY_API_KEY=optional_platform_provider_key +# AI_GATEWAY_API_KEY=optional_platform_provider_key +# REX_GATEWAY_PROVIDER=openai +# REX_MODEL=google/gemini-2.5-flash-lite +# REX_MAX_OUTPUT_TOKENS=500 +# REX_GATEWAY_BASE_URL=https://ai-gateway.vercel.sh/v1 +# REX_ANTHROPIC_BASE_URL=https://api.anthropic.com +# REX_OLLAMA_BASE_URL=http://127.0.0.1:11434 +# REX_OLLAMA_MODEL=llama3.2:latest +# REX_OLLAMA_KEEP_ALIVE=30m +# REX_OLLAMA_CHAT_TIMEOUT_MS=35000 +# REX_OLLAMA_CONTEXT_TIMEOUT_MS=5000 +# REX_EMBEDDING_MODEL=text-embedding-3-small +# REX_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe +# REX_SETTINGS_ENCRYPTION_KEY=long_random_secret_for_rex_settings diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..94210a1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Report a reproducible Dynobox problem +title: "[Bug]: " +labels: bug +assignees: "" +--- + +## What Happened? + +Describe the bug and what you expected to happen. + +## Reproduction + +1. +2. +3. + +## Environment + +- OS: +- Node: +- npm: +- Mode: browser / desktop +- Account type: Gmail / Outlook preset / IMAP / POP3 + +## Logs or Screenshots + +Paste only redacted logs. Do not include tokens, API keys, email bodies, private recipients, or mailbox screenshots with sensitive content. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8737bdf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an improvement for Dynobox +title: "[Feature]: " +labels: enhancement +assignees: "" +--- + +## Problem + +What user problem should this solve? + +## Proposed Solution + +Describe the behavior you would like to see. + +## Data, Security, or Privacy Impact + +Does this touch credentials, mailbox content, AI providers, MCP tools, local databases, or remote access? + +## Alternatives + +Describe any alternatives you considered. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7e774ae --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## Summary + +- + +## Test Plan + +- [ ] `npm run lint` +- [ ] `npm run build --prefix client` +- [ ] `npm run build:runtime` + +## Security and Privacy + +- [ ] This change does not add new handling of credentials, mailbox content, AI provider calls, MCP tools, or remote access. +- [ ] If it does, the relevant docs were updated (`SECURITY.md`, `docs/rex-ai-policy.md`, or `docs/MCP_README.md`). + +## Screenshots + +Add screenshots or screen recordings for UI changes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3bb2f73 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + lint-build: + name: Lint and Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: | + package-lock.json + client/package-lock.json + server/package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install client dependencies + run: npm ci --prefix client + + - name: Install server dependencies + run: npm ci --prefix server + + - name: Lint client + run: npm run lint --prefix client + + - name: Build client + run: npm run build --prefix client + + - name: Build runtime + run: npm run build:runtime diff --git a/.gitignore b/.gitignore index c84d3d3..ac2a433 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,47 @@ node_modules/ server/node_modules/ client/node_modules/ -server/.tokens.json -server/.env + +# Environment and local secrets .env +.env.* +!.env.example +!*.env.example +server/.env +server/.env.* +!server/.env.example +client/.env +client/.env.* +!client/.env.example +server/.tokens.json +server/.mcp-settings.json +server/.rex-settings.json +*.pem +*.key + +# Local app data and generated runtime output dist/ -.DS_Store +client/dist/ electron-build/ server/.build/ +out/ +release/ +dist-electron/ + +# Logs, caches, and coverage +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +coverage/ +.nyc_output/ +.eslintcache +.vite/ + +# OS and editor state +.DS_Store +Thumbs.db +.idea/ +.vscode/ +.claude/settings.local.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..899394c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,70 @@ +# Contributing + +Thanks for helping improve Dynobox. The project is a local-first email client, so changes should be conservative around credentials, mailbox data, network exposure, and AI data flows. + +## Setup + +Install dependencies for the root, client, and server packages: + +```bash +npm install +npm install --prefix client +npm install --prefix server +``` + +Copy the server env example and add your own local OAuth credentials: + +```bash +cp server/.env.example server/.env +``` + +Run web mode: + +```bash +npm run dev +``` + +Run desktop mode: + +```bash +npm run dev:desktop +``` + +## Quality Checks + +Run these before opening a pull request: + +```bash +npm run lint +npm run build --prefix client +npm run build:runtime +``` + +`npm run check` runs the same core checks from the root package. + +## Pull Requests + +- Keep PRs focused and explain user-visible behavior changes. +- Include screenshots for UI changes where possible. +- Update docs when setup, environment variables, security posture, AI behavior, or data storage changes. +- Do not commit generated app output such as `out/`, `electron-build/`, `server/.build/`, or `client/dist/`. +- Do not commit local secrets, tokens, mailbox data, SQLite databases, or personal settings files. + +## Code Style + +- Follow existing React, Electron, and Express patterns before introducing new abstractions. +- Client code is real TypeScript and should pass ESLint. +- Server and Electron runtime sources are TypeScript-shaped JavaScript with `// @ts-nocheck`; `scripts/build-runtime-from-ts.mjs` copies them to runtime `.js` files. +- Keep network, file-system, and IPC boundaries explicit. This is especially important for email actions and AI tooling. + +## Security-Sensitive Changes + +Open a dedicated issue or PR note for changes that affect: + +- OAuth scopes, token storage, or token encryption. +- Manual mail passwords. +- `ALLOW_REMOTE_CLIENT`, CORS, API keys, or proxy behavior. +- MCP tools or endpoints. +- BRAIN BOX / REX prompt construction, embeddings, provider calls, or consent behavior. + +When in doubt, document the data flow and default to local-only behavior. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4d3b49c --- /dev/null +++ b/LICENSE @@ -0,0 +1,177 @@ +Apache License +Version 2.0, January 2004 +https://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the +copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other +entities that control, are controlled by, or are under common control with +that entity. For the purposes of this definition, "control" means (i) the +power, direct or indirect, to cause the direction or management of such +entity, whether by contract or otherwise, or (ii) ownership of fifty percent +(50%) or more of the outstanding shares, or (iii) beneficial ownership of such +entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation source, and +configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object +code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that is +included in or attached to the work (an example is provided in the Appendix +below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative +Works shall not include works that remain separable from, or merely link (or +bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original +version of the Work and any modifications or additions to that Work or +Derivative Works thereof, that is intentionally submitted to Licensor for +inclusion in the Work by the copyright owner or by an individual or Legal +Entity authorized to submit on behalf of the copyright owner. For the purposes +of this definition, "submitted" means any form of electronic, verbal, or +written communication sent to the Licensor or its representatives, including +but not limited to communication on electronic mailing lists, source code +control systems, and issue tracking systems that are managed by, or on behalf +of, the Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise designated +in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable copyright license to +reproduce, prepare Derivative Works of, publicly display, publicly perform, +sublicense, and distribute the Work and such Derivative Works in Source or +Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this +section) patent license to make, have made, use, offer to sell, sell, import, +and otherwise transfer the Work, where such license applies only to those +patent claims licensable by such Contributor that are necessarily infringed by +their Contribution(s) alone or by combination of their Contribution(s) with +the Work to which such Contribution(s) was submitted. If You institute patent +litigation against any entity (including a cross-claim or counterclaim in a +lawsuit) alleging that the Work or a Contribution incorporated within the Work +constitutes direct or contributory patent infringement, then any patent +licenses granted to You under this License for that Work shall terminate as of +the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and in +Source or Object form, provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy +of this License; and + +(b) You must cause any modified files to carry prominent notices stating that +You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You +distribute, all copyright, patent, trademark, and attribution notices from the +Source form of the Work, excluding those notices that do not pertain to any +part of the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its distribution, +then any Derivative Works that You distribute must include a readable copy of +the attribution notices contained within such NOTICE file, excluding those +notices that do not pertain to any part of the Derivative Works, in at least +one of the following places: within a NOTICE text file distributed as part of +the Derivative Works; within the Source form or documentation, if provided +along with the Derivative Works; or, within a display generated by the +Derivative Works, if and wherever such third-party notices normally appear. +The contents of the NOTICE file are for informational purposes only and do not +modify the License. You may add Your own attribution notices within Derivative +Works that You distribute, alongside or as an addendum to the NOTICE text from +the Work, provided that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a +whole, provided Your use, reproduction, and distribution of the Work otherwise +complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution intentionally submitted for inclusion in the Work by You to the +Licensor shall be under the terms and conditions of this License, without any +additional terms or conditions. Notwithstanding the above, nothing herein +shall supersede or modify the terms of any separate license agreement you may +have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, +trademarks, service marks, or product names of the Licensor, except as +required for reasonable and customary use in describing the origin of the Work +and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in +writing, Licensor provides the Work (and each Contributor provides its +Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied, including, without limitation, any warranties +or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any risks +associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in +tort (including negligence), contract, or otherwise, unless required by +applicable law (such as deliberate and grossly negligent acts) or agreed to in +writing, shall any Contributor be liable to You for damages, including any +direct, indirect, special, incidental, or consequential damages of any +character arising as a result of this License or out of the use or inability +to use the Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all other +commercial damages or losses), even if such Contributor has been advised of +the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or +Derivative Works thereof, You may choose to offer, and charge a fee for, +acceptance of support, warranty, indemnity, or other liability obligations +and/or rights consistent with this License. However, in accepting such +obligations, You may act only on Your own behalf and on Your sole +responsibility, not on behalf of any other Contributor, and only if You agree +to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +Copyright 2026 Deprogram Software + +Licensed under the Apache License, Version 2.0 (the "License"); you may not +use this file except in compliance with the License. You may obtain a copy of +the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations under +the License. diff --git a/README.md b/README.md index b252880..e876645 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,164 @@ -# dynobox +

+ Dynobox logo +

+ +

Dynobox

+ +

+ A local-first, keyboard-driven email client for people who live in their inbox. +

+ +

+ License: Apache-2.0 + Node.js 20+ + React + Electron +

+ +Dynobox is a personal email client that is dark by default and ships as both a browser app and an Electron desktop app named **DYNO BOX**. It supports Gmail OAuth, Outlook presets, custom IMAP/POP3 + SMTP accounts, local desktop mail storage, and an optional AI assistant currently surfaced in the UI as **BRAIN BOX**. Some internal APIs and environment variables still use the historical `REX` name. + +## Highlights + +| Focus | What Dynobox Provides | +| --- | --- | +| Local-first mail | Browser mode through the local Express API, desktop mode through Electron IPC and SQLite. | +| Keyboard workflow | Fast inbox navigation, search, compose, archive, trash, star, and read/unread actions. | +| Provider flexibility | Gmail OAuth plus Outlook presets and custom IMAP/POP3 + SMTP accounts. | +| Optional AI | BRAIN BOX chat, summaries, draft help, transcription, semantic search, BYOK, gateway, and Ollama paths. | +| Open-source ready | Apache-2.0 license, public security notes, architecture docs, contribution guide, and CI checks. | + +## Contents + +- [Project Status](#project-status) +- [Features](#features) +- [Requirements](#requirements) +- [Quick Start](#quick-start) +- [Desktop Mode](#desktop-mode) +- [Environment](#environment) +- [Scripts](#scripts) +- [Security and Privacy](#security-and-privacy) + +## Project Status + +Dynobox is being prepared for public development. Expect sharp edges around desktop packaging, provider coverage, and AI workflows. Please avoid using the app with irreplaceable production mailboxes until you have reviewed the security and privacy notes below. + +## Features + +- Gmail OAuth account connection and Gmail thread actions. +- Manual Outlook, IMAP, POP3, and SMTP account setup. +- Keyboard-first inbox navigation, compose, archive, trash, star, read/unread, and search. +- Electron desktop mode with SQLite-backed local mail state through IPC. +- Optional BRAIN BOX AI chat, summaries, draft assistance, transcription, semantic search, and MCP tooling. +- Optional local Ollama mode for AI chat, alongside BYOK and platform gateway modes. + +## Requirements + +- Node.js 20 or newer. +- npm 10 or newer. +- Google OAuth credentials for Gmail support. +- Native build tooling for desktop mode because `better-sqlite3` is used by Electron. On macOS this usually means Xcode Command Line Tools; Windows and Linux need their normal Node native module toolchains. + +## Repository Layout + +```text +client/ React + Vite + Tailwind renderer +server/ Express API for auth, Gmail/manual mail, REX/BRAIN BOX, and MCP +electron/ Electron main/preload process, IPC, SQLite, and desktop integration +docs/ Architecture, MCP, AI policy, and implementation notes +scripts/ Runtime build helpers +``` -A personal email client with Gmail OAuth plus manual SMTP/IMAP/POP3 account setup. Keyboard-first, Superhuman-style. Dark theme. Runs entirely on your machine. +See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the web, desktop, AI, and MCP data flows. -## Setup +## Quick Start -### 1. Google OAuth credentials +Install all workspaces: -1. Go to [Google Cloud Console](https://console.cloud.google.com/) -2. Create a project → **APIs & Services → Credentials** -3. Create **OAuth 2.0 Client ID** → Application type: **Web application** -4. Add authorized redirect URI: `http://localhost:3001/auth/callback` -5. Enable **Gmail API** under APIs & Services → Library +```bash +npm install +npm install --prefix client +npm install --prefix server +``` -### 2. Configure environment +Create a server environment file: ```bash cp server/.env.example server/.env ``` -Edit `server/.env`: -``` +Edit `server/.env` with your Google OAuth credentials: + +```dotenv GOOGLE_CLIENT_ID=your_client_id GOOGLE_CLIENT_SECRET=your_client_secret PORT=3001 CLIENT_URL=http://localhost:5173 +``` + +Start the browser app and API: + +```bash +npm run dev +``` + +Open `http://localhost:5173`, connect a Gmail account, and complete the OAuth flow. + +## Google OAuth Setup + +1. Open [Google Cloud Console](https://console.cloud.google.com/). +2. Create or select a project. +3. Enable the Gmail API under **APIs & Services -> Library**. +4. Create an OAuth 2.0 Client ID with application type **Web application**. +5. Add `http://localhost:3001/auth/callback` as an authorized redirect URI. +6. Copy the client ID and client secret into `server/.env`. + +The local server builds the OAuth redirect URL from `PORT`. If you run Dynobox on a non-localhost host, you must configure matching redirect URIs and review the remote access warnings in [`SECURITY.md`](SECURITY.md). + +## Desktop Mode + +Development desktop mode starts the server, Vite, and Electron shell: + +```bash +npm run dev:desktop +``` + +Production-like desktop mode builds the renderer/runtime and starts Electron: + +```bash +npm run build:desktop +npm run start:desktop +``` + +If Electron native modules are stale after install, rebuild them: + +```bash +npx electron-rebuild -f -w better-sqlite3 +``` + +Desktop SQLite lives under Electron's `userData` directory, for example `~/Library/Application Support/DYNO BOX/dynobox.sqlite` on macOS. OAuth tokens and manual account credentials for web mode are stored in `server/.tokens.json`. + +## Environment + +Server configuration is documented in [`server/.env.example`](server/.env.example). Common values: + +```dotenv +PORT=3001 +CLIENT_URL=http://localhost:5173 TOKEN_ENCRYPTION_KEY=optional_long_random_secret DYNOBOX_API_KEY=optional_shared_secret ALLOW_REMOTE_CLIENT=false TRUST_PROXY=false +``` -# BRAIN BOX AI (optional) +Client configuration is documented in [`client/.env.example`](client/.env.example). Desktop local-core mode uses: + +```dotenv +VITE_USE_LOCAL_CORE=true +``` + +Optional AI configuration: + +```dotenv REX_PLATFORM_API_KEY=optional_platform_provider_key VERCEL_AI_GATEWAY_API_KEY=optional_platform_provider_key AI_GATEWAY_API_KEY=optional_platform_provider_key @@ -38,85 +167,64 @@ REX_MODEL=google/gemini-2.5-flash-lite REX_MAX_OUTPUT_TOKENS=500 REX_GATEWAY_BASE_URL=https://ai-gateway.vercel.sh/v1 REX_ANTHROPIC_BASE_URL=https://api.anthropic.com +REX_OLLAMA_BASE_URL=http://127.0.0.1:11434 +REX_OLLAMA_MODEL=llama3.2:latest +REX_OLLAMA_KEEP_ALIVE=30m +REX_OLLAMA_CHAT_TIMEOUT_MS=35000 +REX_OLLAMA_CONTEXT_TIMEOUT_MS=5000 +REX_EMBEDDING_MODEL=text-embedding-3-small +REX_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe REX_SETTINGS_ENCRYPTION_KEY=optional_long_random_secret ``` -If you set `DYNOBOX_API_KEY`, add this to `client/.env`: -``` -VITE_DYNOBOX_API_KEY=the_same_shared_secret -``` - -To opt into the new desktop local-core read path (SQLite + IPC + TanStack Query), add: -``` -VITE_USE_LOCAL_CORE=true -``` +Optional Electron environment: -Optional client envs: -``` -VITE_API_BASE_URL=http://localhost:3001 -``` - -Optional Electron envs (for desktop mode): -``` +```dotenv +ELECTRON_START_URL=http://localhost:5173 DYNOBOX_SERVER_BASE_URL=http://localhost:3001 DYNOBOX_API_KEY=the_same_shared_secret_if_enabled DYNOBOX_SUPPRESS_CHROMIUM_WARNINGS=true DYNOBOX_DISABLE_HARDWARE_ACCELERATION=false ``` -`REX_*` env vars are retained for compatibility and configure BRAIN BOX AI. `REX_GATEWAY_PROVIDER` supports `openai` and `anthropic`. In BYOK mode, provider auto-detects from key prefix (`sk-ant-...` selects Anthropic). +See [`docs/rex-ai-policy.md`](docs/rex-ai-policy.md) before enabling AI features with real email data. -### 3. Install & run +## Scripts ```bash -npm install -npm run dev +npm run dev # server + client +npm run server # server only +npm run client # client only +npm run dev:desktop # server + client + Electron +npm run build:desktop # client dist + runtime JS +npm run build:runtime # server/.build + electron-build +npm run lint # client ESLint +npm run check # lint + client build + runtime build ``` -This starts both the Express backend (port 3001) and Vite frontend (port 5173). - -### Desktop (Electron) +Client-only scripts: -Dev mode (hot-reload client + backend + Electron shell): ```bash -npm run dev:desktop +npm run dev --prefix client +npm run build --prefix client +npm run lint --prefix client ``` -Production-like desktop mode: +Server-only scripts: + ```bash -npm run build:desktop -npm run start:desktop +npm run dev --prefix server +npm run build --prefix server +npm run start --prefix server ``` -TypeScript source is used across `client/`, `server/`, and `electron/`. -For Node/Electron runtime, JS output is generated automatically from TS source into: -- `server/.build` -- `electron-build` - -If Electron starts in Node mode by accident (for example if `ELECTRON_RUN_AS_NODE=1` is set in your shell), desktop startup can fail with errors like `app is undefined` or native module load failures. The desktop scripts explicitly unset `ELECTRON_RUN_AS_NODE` and `NODE_OPTIONS`. - -### 4. Connect your Gmail account - -Open `http://localhost:5173` → click **Connect Gmail Account** → complete Google OAuth flow. - -Tokens are stored in `server/.tokens.json` (gitignored). Repeat to add more accounts. - -### 5. Add Outlook or custom accounts - -Use **Add account** and choose: -- **Outlook preset** (pre-fills Outlook IMAP/SMTP servers) -- **Custom IMAP/POP3 + SMTP** (manual host/port/security) - -Manual account passwords are stored locally in `server/.tokens.json` only. -For manual accounts, Dynobox can load inbox threads over IMAP/POP3 and send via SMTP. - -## Keyboard shortcuts +## Keyboard Shortcuts | Key | Action | -|-----|--------| -| `j` / `↓` | Next thread | -| `k` / `↑` | Previous thread | -| `↵` | Open thread | +| --- | --- | +| `j` / `Down` | Next thread | +| `k` / `Up` | Previous thread | +| `Enter` | Open thread | | `e` | Archive | | `#` | Trash | | `r` | Reply | @@ -125,25 +233,27 @@ For manual accounts, Dynobox can load inbox threads over IMAP/POP3 and send via | `/` | Search | | `s` | Star | | `u` | Mark unread | -| `⌘↵` | Send (in compose) | +| `Cmd+Enter` | Send in compose | | `Esc` | Close compose | -## Stack +## Security and Privacy + +Dynobox handles mailbox contents, OAuth refresh tokens, manual mail passwords, local SQLite databases, and optional AI provider keys. Read [`SECURITY.md`](SECURITY.md) and [`docs/rex-ai-policy.md`](docs/rex-ai-policy.md) before using it with sensitive mail. + +Important defaults: + +- Local server access is loopback-only unless `ALLOW_REMOTE_CLIENT=true`. +- `DYNOBOX_API_KEY` can add a shared local API key for HTTP requests. +- `TOKEN_ENCRYPTION_KEY` enables encryption for token files that support it. +- BRAIN BOX may send selected email content to configured AI providers when enabled. +- MCP is intended for local automation and should not be exposed without strong controls. -- **Frontend**: React + Vite + TailwindCSS + TypeScript + DOMPurify -- **Backend**: Node.js + Express + TypeScript + googleapis -- **Auth**: OAuth 2.0, refresh tokens stored in `.tokens.json` +Never commit `.env`, `.tokens.json`, `.mcp-settings.json`, `.rex-settings.json`, generated `out/` builds, or desktop SQLite databases. -## MCP Reference +## Contributing -Use [docs/MCP_README.md](docs/MCP_README.md) for agent-facing MCP connection details, tool catalog, and request examples. +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for setup, branch, PR, generated output, and documentation expectations. -## Security notes +## License -- OAuth flow validates a one-time `state` value to prevent callback forgery. -- Refresh tokens are stored only in `server/.tokens.json` with restricted local file permissions. -- Outbound send payloads are validated server-side to prevent mail header injection. -- Optional token encryption at rest can be enabled with `TOKEN_ENCRYPTION_KEY`. -- Optional API access key can be enabled with `DYNOBOX_API_KEY` (send via `x-dynobox-key`). -- Auth and send routes are rate-limited to reduce abuse. -- Remote access is blocked by default unless `ALLOW_REMOTE_CLIENT=true`. +Dynobox is licensed under the [Apache License 2.0](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..69daa71 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,63 @@ +# Security Policy + +Dynobox is a local-first email client. It handles OAuth refresh tokens, manual mail credentials, mailbox contents, optional AI provider keys, local SQLite databases, and local automation endpoints. Treat local project and runtime files as sensitive. + +## Supported Versions + +The public repository currently supports the latest `main` branch only. Security fixes will be documented in release notes once tagged releases are established. + +## Reporting a Vulnerability + +Please report suspected vulnerabilities privately before opening a public issue. + +Use GitHub private vulnerability reporting if it is enabled for the repository. If it is not enabled yet, contact the project maintainers through the repository owner profile and avoid including secrets, tokens, mailbox contents, or exploit payloads in public issues. + +Useful report details: + +- Affected commit or version. +- Whether the issue affects browser mode, desktop mode, MCP, AI, or all modes. +- Reproduction steps using test accounts or redacted data. +- Expected impact and any known mitigations. + +## Local Threat Model + +Dynobox is designed for local use. It does not try to protect mailbox data from a fully compromised local machine or from users/processes that can read your project directory, Electron user data directory, or shell environment. + +Sensitive local files include: + +- `server/.env` +- `client/.env` +- `server/.tokens.json` +- `server/.mcp-settings.json` +- `server/.rex-settings.json` +- Electron `userData` SQLite databases such as `dynobox.sqlite` +- generated app bundles under `out/` + +These paths are ignored by git and should not be shared in issues, screenshots, logs, archives, or release artifacts. + +## Network Exposure + +The Express server is intended to run on localhost during development. + +- `ALLOW_REMOTE_CLIENT=false` blocks non-loopback clients by default. +- `DYNOBOX_API_KEY` adds a shared API key requirement via the `x-dynobox-key` header. +- `TRUST_PROXY=true` should only be used when Dynobox is behind a trusted local proxy. +- CORS and OAuth redirect settings must be reviewed before binding the server to non-loopback interfaces. + +Do not expose the Dynobox API, MCP endpoint, or Electron development ports to untrusted networks. + +## OAuth and Mail Credentials + +Gmail OAuth refresh tokens and manual mail credentials are stored locally. Use `TOKEN_ENCRYPTION_KEY` to enable encryption where supported, keep file permissions restricted, and rotate credentials if a local token file is exposed. + +For Google OAuth: + +- Use a development OAuth client for local testing. +- Keep OAuth client secrets out of the repository. +- Rotate any secret that was ever committed, pasted into chat, or exposed in logs. + +## AI and MCP + +BRAIN BOX / REX features can send mailbox content, prompts, summaries, and embeddings to configured AI providers. MCP can expose inbox operations to local automation clients. Both are powerful and should be enabled deliberately. + +Read [`docs/rex-ai-policy.md`](docs/rex-ai-policy.md) and [`docs/MCP_README.md`](docs/MCP_README.md) before using either feature with real data. diff --git a/client/.env.example b/client/.env.example index b9fa066..b8a31e7 100644 --- a/client/.env.example +++ b/client/.env.example @@ -1,5 +1,7 @@ VITE_USE_LOCAL_CORE=true + # Optional when backend is not on localhost:3001 # VITE_API_BASE_URL=http://localhost:3001 + # Optional if server enforces API key with DYNOBOX_API_KEY # VITE_DYNOBOX_API_KEY=shared_secret_for_local_api_requests diff --git a/client/.gitignore b/client/.gitignore index a547bf3..5cc5472 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +out \ No newline at end of file diff --git a/client/README.md b/client/README.md index 18bc70e..f9c7d9f 100644 --- a/client/README.md +++ b/client/README.md @@ -1,16 +1,15 @@ -# React + Vite +# Dynobox Client -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +This package contains the React + Vite renderer used by both browser development and the Electron desktop shell. -Currently, two official plugins are available: +Common commands: -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +```bash +npm run dev --prefix client +npm run build --prefix client +npm run lint --prefix client +``` -## React Compiler +Browser mode talks to the Express server through HTTP. Desktop local-core mode is enabled with `VITE_USE_LOCAL_CORE=true` and talks to Electron through `window.dynoboxDesktop`. -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. +For full setup, architecture, security, and contribution guidance, use the root [`README.md`](../README.md). diff --git a/client/eslint.config.js b/client/eslint.config.js index c6ef278..ad781d4 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -2,10 +2,12 @@ import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ globalIgnores(['dist']), + ...tseslint.configs.recommended, { files: ['**/*.{js,jsx,ts,tsx}'], extends: [ @@ -23,7 +25,18 @@ export default defineConfig([ }, }, rules: { - 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + 'no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + varsIgnorePattern: '^[A-Z_]', + }], + 'no-extra-boolean-cast': 'warn', + 'prefer-const': 'warn', + 'react-hooks/preserve-manual-memoization': 'off', + 'react-hooks/set-state-in-effect': 'off', + 'react-refresh/only-export-components': 'warn', }, }, ]) diff --git a/client/package-lock.json b/client/package-lock.json index 6cfc7e8..c629f7a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -7,21 +7,23 @@ "": { "name": "client", "version": "0.0.0", + "license": "Apache-2.0", "dependencies": { "@assistant-ui/react": "^0.12.11", "@assistant-ui/react-ai-sdk": "^1.3.8", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-select": "^2.2.6", "@tanstack/react-query": "^5.90.21", "assistant-ui": "^0.0.79", "class-variance-authority": "^0.7.1", "date-fns": "^4.1.0", "dompurify": "^3.3.1", - "goey-toast": "^0.2.0", "lucide-react": "^0.575.0", + "motion": "^12.35.1", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-hot-toast": "^2.6.0", - "react-speech-recognition": "^4.0.1" + "react-speech-recognition": "^4.0.1", + "sonner": "^2.0.7" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -36,7 +38,12 @@ "globals": "^16.5.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.19", + "typescript-eslint": "^8.59.3", "vite": "^7.3.1" + }, + "engines": { + "node": ">=20", + "npm": ">=10" } }, "node_modules/@ai-sdk/gateway": { @@ -2853,6 +2860,301 @@ "license": "MIT", "optional": true }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.3", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vercel/oidc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", @@ -3467,6 +3769,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, "license": "MIT" }, "node_modules/date-fns": { @@ -4038,13 +4341,12 @@ } }, "node_modules/framer-motion": { - "version": "12.34.3", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz", - "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==", + "version": "12.35.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.35.1.tgz", + "integrity": "sha512-rL8cLrjYZNShZqKV3U0Qj6Y5WDiZXYEM5giiTLfEqsIZxtspzMDCkKmrO5po76jWfvOg04+Vk+sfBvTD0iMmLw==", "license": "MIT", - "peer": true, "dependencies": { - "motion-dom": "^12.34.3", + "motion-dom": "^12.35.1", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, @@ -4199,32 +4501,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/goey-toast": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/goey-toast/-/goey-toast-0.2.0.tgz", - "integrity": "sha512-hWvL1XABCt+ntosl2ab2mSPE2i58eF9MCGxtCqcMiHq6pfo/iQDSCFh8f5DedrjdLoUlBA3L1I6CE3RbUqAY5A==", - "license": "MIT", - "dependencies": { - "sonner": "^2.0.7" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "framer-motion": ">=10.0.0", - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - } - }, - "node_modules/goober": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", - "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", - "license": "MIT", - "peerDependencies": { - "csstype": "^3.0.10" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4748,12 +5024,37 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion": { + "version": "12.35.1", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.35.1.tgz", + "integrity": "sha512-yEt/49kWC0VU/IEduDfeZw82eDemlPwa1cyo/gcEEUCN4WgpSJpUcxz6BUwakGabvJiTzLQ58J73515I5tfykQ==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.35.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/motion-dom": { - "version": "12.34.3", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", - "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==", + "version": "12.35.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.35.1.tgz", + "integrity": "sha512-7n6r7TtNOsH2UFSAXzTkfzOeO5616v9B178qBIjmu/WgEyJK0uqwytCEhwKBTuM/HJA40ptAw7hLFpxtPAMRZQ==", "license": "MIT", - "peer": true, "dependencies": { "motion-utils": "^12.29.2" } @@ -4762,8 +5063,7 @@ "version": "12.29.2", "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", @@ -5351,23 +5651,6 @@ "react": "^19.2.4" } }, - "node_modules/react-hot-toast": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", - "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", - "license": "MIT", - "dependencies": { - "csstype": "^3.1.3", - "goober": "^2.1.16" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -5995,6 +6278,19 @@ "node": ">=8.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -6021,6 +6317,45 @@ "node": ">= 0.8.0" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/client/package.json b/client/package.json index a35d85e..71b5cc8 100644 --- a/client/package.json +++ b/client/package.json @@ -2,6 +2,7 @@ "name": "client", "private": true, "version": "0.0.0", + "license": "Apache-2.0", "type": "module", "scripts": { "dev": "vite", @@ -9,21 +10,26 @@ "lint": "eslint .", "preview": "vite preview" }, + "engines": { + "node": ">=20", + "npm": ">=10" + }, "dependencies": { "@assistant-ui/react": "^0.12.11", "@assistant-ui/react-ai-sdk": "^1.3.8", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-select": "^2.2.6", "@tanstack/react-query": "^5.90.21", "assistant-ui": "^0.0.79", "class-variance-authority": "^0.7.1", "date-fns": "^4.1.0", "dompurify": "^3.3.1", - "goey-toast": "^0.2.0", "lucide-react": "^0.575.0", + "motion": "^12.35.1", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-hot-toast": "^2.6.0", - "react-speech-recognition": "^4.0.1" + "react-speech-recognition": "^4.0.1", + "sonner": "^2.0.7" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -38,6 +44,7 @@ "globals": "^16.5.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.19", + "typescript-eslint": "^8.59.3", "vite": "^7.3.1" } } diff --git a/client/src/App.tsx b/client/src/App.tsx index 6fa1242..579f121 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,4 @@ import React, { useRef, useEffect, useCallback, useState, useMemo, Suspense, lazy } from 'react'; -import { GoeyToaster } from 'goey-toast'; -import 'goey-toast/styles.css'; import { AppProvider, useApp } from './context/AppContext'; import Sidebar from './components/Sidebar'; import ThreadList from './components/ThreadList'; @@ -10,6 +8,8 @@ import { useKeyboard } from './hooks/useKeyboard'; import { useLocalCoreSyncInvalidation } from './hooks/useMailCoreQueries'; import rexTrexIcon from './assets/rex-trex-8bit.svg'; import { Button } from './components/ui/button'; +import { Toaster } from './components/ui/sonner'; +import { SIDE_PANEL_WIDTH } from './lib/layout'; const loadAiAssistantWindow = () => import('./components/AiAssistantWindow'); const loadThreadView = () => import('./components/ThreadView'); @@ -24,10 +24,32 @@ const LazySettingsModal = lazy(loadSettingsModal); const LazyCalendarPlanner = lazy(loadCalendarPlanner); const VALID_THEMES = ['dyno', 'dark', 'light', 'nebula', 'shades-of-gray', 'stella', 'fintecha']; const LIGHT_THEMES = ['light', 'fintecha']; -const AI_DRAWER_DEFAULT_WIDTH = 480; +const TERMOID_THEME_ID = 'dark'; +const BASE_FONT_DEFAULT = 'poppins'; +const BASE_FONT_STACKS = { + 'jetbrains-mono': "'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, monospace", + rubik: "'Rubik', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + 'dm-sans': "'DM Sans', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + inter: "'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + poppins: "'Poppins', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + 'libre-franklin': "'Libre Franklin', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif", +}; +const VALID_BASE_FONTS = Object.keys(BASE_FONT_STACKS); +const AI_DRAWER_DEFAULT_WIDTH = SIDE_PANEL_WIDTH; const AI_DRAWER_MIN_WIDTH = 400; const AI_DRAWER_EDGE_MARGIN = 220; +function normalizeBaseFontId(value) { + const next = String(value || '').trim().toLowerCase(); + if (VALID_BASE_FONTS.includes(next)) return next; + return BASE_FONT_DEFAULT; +} + +function defaultBaseFontForTheme(themeId = '') { + const normalizedTheme = String(themeId || '').trim().toLowerCase(); + return normalizedTheme === TERMOID_THEME_ID ? 'jetbrains-mono' : 'poppins'; +} + function parseComposeDraftFromUrl() { try { const params = new URLSearchParams(window.location.search); @@ -42,7 +64,66 @@ function parseComposeDraftFromUrl() { } } -function AppShell({ theme, onSetTheme }) { +function CalendarGridIcon({ className = '' }) { + return ( + + ); +} + +function GearIcon({ className = '' }) { + return ( + + ); +} + +function AppShell({ theme, onSetTheme, baseFont, onSetBaseFont }) { const { accounts, activeAccount, @@ -60,7 +141,6 @@ function AppShell({ theme, onSetTheme }) { const [settingsTab, setSettingsTab] = useState('preferences'); const [showTopActions, setShowTopActions] = useState(false); const [aiOpen, setAiOpen] = useState(false); - const [brainOpenCollapseSignal, setBrainOpenCollapseSignal] = useState(0); const [aiDrawerWidth, setAiDrawerWidth] = useState(AI_DRAWER_DEFAULT_WIDTH); const [viewportWidth, setViewportWidth] = useState(() => Math.max(1024, Number(window?.innerWidth || 1024))); const [isAiResizing, setIsAiResizing] = useState(false); @@ -203,7 +283,6 @@ function AppShell({ theme, onSetTheme }) { const openAiDrawer = useCallback(() => { loadAiAssistantWindow(); setAiOpen(true); - setBrainOpenCollapseSignal((prev) => prev + 1); }, []); if (accounts.length === 0) { @@ -223,7 +302,6 @@ function AppShell({ theme, onSetTheme }) { setSettingsTab('labels'); setSettingsOpen(true); }} - collapseOnBrainOpenSignal={brainOpenCollapseSignal} /> {/* Main content — solid background lives here, not on the root */} @@ -267,13 +345,14 @@ function AppShell({ theme, onSetTheme }) { onTouchStart={loadCalendarPlanner} onClick={() => setActiveView((prev) => (prev === 'calendar' ? 'mail' : 'calendar'))} variant="ghost" - className={`text-[14px] px-3 py-2 font-medium transition-colors ${ + className={`text-[14px] px-3 py-2 font-medium transition-colors inline-flex items-center gap-2 ${ activeView === 'calendar' ? 'text-text-primary bg-surface-3' : 'text-text-tertiary hover:text-text-primary' }`} title={activeView === 'calendar' ? 'Back to mail' : 'Open calendar'} > + {activeView === 'calendar' ? 'Mail' : 'Calendar'} @@ -311,8 +391,9 @@ function AppShell({ theme, onSetTheme }) { setActiveView((prev) => (prev === 'calendar' ? 'mail' : 'calendar')); }} variant="ghost" - className="w-full justify-start text-left text-xs text-text-tertiary hover:text-text-primary px-2 py-1.5" + className="w-full justify-start text-left text-xs text-text-tertiary hover:text-text-primary px-2 py-1.5 inline-flex items-center gap-2" > + {activeView === 'calendar' ? 'Mail' : 'Calendar'} {isMailView && showKeyboardHints && ( @@ -443,9 +525,20 @@ function AppShell({ theme, onSetTheme }) { theme={theme} onSetTheme={(next) => { if (VALID_THEMES.includes(next)) { - if (next !== theme) onSetTheme(next); + if (next === theme) return; + const currentThemeDefault = defaultBaseFontForTheme(theme); + const nextThemeDefault = defaultBaseFontForTheme(next); + if (baseFont === currentThemeDefault && nextThemeDefault !== baseFont) { + onSetBaseFont(nextThemeDefault); + } + onSetTheme(next); } }} + baseFont={baseFont} + onSetBaseFont={(next) => { + const normalized = normalizeBaseFontId(next); + if (normalized !== baseFont) onSetBaseFont(normalized); + }} /> )} @@ -573,6 +666,12 @@ export default function App() { if (stored && VALID_THEMES.includes(stored)) return stored; return 'dyno'; }); + const [baseFont, setBaseFont] = useState(() => { + const stored = localStorage.getItem('dynobox-base-font'); + const normalizedStored = normalizeBaseFontId(stored); + if (stored && normalizedStored === String(stored).trim().toLowerCase()) return normalizedStored; + return defaultBaseFontForTheme(theme); + }); useEffect(() => { document.documentElement.dataset.theme = theme; @@ -580,6 +679,13 @@ export default function App() { localStorage.setItem('dynobox-theme', theme); }, [theme]); + useEffect(() => { + const normalized = normalizeBaseFontId(baseFont); + const stack = BASE_FONT_STACKS[normalized] || BASE_FONT_STACKS[BASE_FONT_DEFAULT]; + document.documentElement.style.setProperty('--font-sans', stack); + localStorage.setItem('dynobox-base-font', normalized); + }, [baseFont]); + const search = new URLSearchParams(window.location.search); const isComposePopout = window.location.hash === '#compose-popout'; const isAssistantPopout = window.location.hash === '#assistant-popout' || search.get('assistantPopout') === '1'; @@ -592,15 +698,14 @@ export default function App() { ) : isAssistantPopout ? ( ) : ( - + )} - ); diff --git a/client/src/assets/dynobox-logo.png b/client/src/assets/dynobox-logo.png new file mode 100644 index 0000000..8fa3134 Binary files /dev/null and b/client/src/assets/dynobox-logo.png differ diff --git a/client/src/components/AiAssistantWindow.tsx b/client/src/components/AiAssistantWindow.tsx index 74be681..3f5f30e 100644 --- a/client/src/components/AiAssistantWindow.tsx +++ b/client/src/components/AiAssistantWindow.tsx @@ -6,6 +6,7 @@ import { Button } from './ui/button'; import toast from '../lib/toast'; import SpinningBox from './SpinningBox'; import AssistantAnswerRenderer from './AssistantAnswerRenderer'; +import { SIDE_PANEL_WIDTH } from '../lib/layout'; const MAX_REFERENCE_ATTACHMENTS = 4; const MAX_ATTACHMENT_TEXT_CHARS = 7_000; @@ -19,6 +20,27 @@ const DEFAULT_PROMPT_TAGS = [ { id: 'draft-replies', label: 'Draft replies', prompt: 'Draft concise replies for the top 3 urgent threads.' }, ]; +function normalizeRexMode(value = '') { + const mode = String(value || '').trim().toLowerCase(); + if (mode === 'byok') return 'byok'; + if (mode === 'ollama') return 'ollama'; + return 'platform'; +} + +function formatRexMethodBadge(settings = {}) { + const mode = normalizeRexMode(settings?.providerMode); + const model = String(settings?.model || '').trim(); + if (mode === 'ollama') return model ? `Local Ollama · ${model}` : 'Local Ollama'; + if (mode === 'byok') return model ? `BYOK · ${model}` : 'Bring Your Own Key'; + return model ? `Platform AI · ${model}` : 'Platform AI'; +} + +function isAbortError(error = null) { + if (error?.name === 'AbortError') return true; + const text = String(error?.message || '').toLowerCase(); + return text.includes('aborted') || text.includes('aborterror'); +} + function formatFileSize(bytes = 0) { const size = Number(bytes || 0); if (!Number.isFinite(size) || size <= 0) return '0 KB'; @@ -437,12 +459,18 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou currentLabel, switchLabel, openThread, + setComposing, } = useApp(); const [messages, setMessages] = useState([]); const [sending, setSending] = useState(false); const [minimized, setMinimized] = useState(false); const [attachments, setAttachments] = useState([]); + const [activeQueryMeta, setActiveQueryMeta] = useState(null); + const [activeQueryElapsedSeconds, setActiveQueryElapsedSeconds] = useState(0); const messagesRef = useRef(null); + const activeRequestIdRef = useRef(''); + const activeAssistantMessageIdRef = useRef(''); + const cancelActiveRequestRef = useRef(null); const promptTags = useMemo(() => { const threadSubject = String(activeThread?.subject || '').trim(); @@ -475,12 +503,41 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou if (!open) setMinimized(false); }, [open]); + useEffect(() => { + if (open) return; + cancelActiveRequestRef.current?.(); + cancelActiveRequestRef.current = null; + activeRequestIdRef.current = ''; + activeAssistantMessageIdRef.current = ''; + setActiveQueryMeta(null); + setSending(false); + }, [open]); + useEffect(() => { const viewport = messagesRef.current; if (!viewport) return; viewport.scrollTop = viewport.scrollHeight; }, [messages, sending, open]); + useEffect(() => { + if (!activeQueryMeta?.startedAt) { + setActiveQueryElapsedSeconds(0); + return undefined; + } + const updateElapsed = () => { + const startedAt = Number(activeQueryMeta.startedAt || 0); + if (!startedAt) { + setActiveQueryElapsedSeconds(0); + return; + } + const elapsed = Math.max(0, Math.floor((Date.now() - startedAt) / 1000)); + setActiveQueryElapsedSeconds(elapsed); + }; + updateElapsed(); + const timer = window.setInterval(updateElapsed, 1000); + return () => window.clearInterval(timer); + }, [activeQueryMeta?.startedAt]); + const handlePopout = useCallback(() => { if (popoutMode) return; try { @@ -488,7 +545,7 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou window.dynoboxDesktop.openAssistantPopout(); } else { const url = `${window.location.origin}${window.location.pathname}?assistantPopout=1#assistant-popout`; - window.open(url, 'dynobox-assistant-popout', 'width=980,height=860,resizable=yes,scrollbars=yes'); + window.open(url, 'dynobox-assistant-popout', `width=${SIDE_PANEL_WIDTH},height=860,resizable=yes,scrollbars=yes`); } onClose?.(); } catch { @@ -496,9 +553,35 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou } }, [onClose, popoutMode]); + const cancelActiveQuery = useCallback(() => { + const cancel = cancelActiveRequestRef.current; + if (typeof cancel === 'function') { + cancel(); + } + cancelActiveRequestRef.current = null; + const assistantId = String(activeAssistantMessageIdRef.current || '').trim(); + if (assistantId) { + setMessages((prev) => prev.map((item) => { + if (String(item?.id || '').trim() !== assistantId) return item; + const existing = String(item?.content || '').trim(); + return { + ...item, + content: existing || 'Request canceled.', + }; + })); + } + activeRequestIdRef.current = ''; + activeAssistantMessageIdRef.current = ''; + setActiveQueryMeta(null); + setSending(false); + }, []); + const submitFromComposer = useCallback(async ({ message, history, onDelta }) => { const content = String(message || '').trim(); if (!content || sending) return; + const requestId = `brain-request-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const abortController = typeof AbortController !== 'undefined' ? new AbortController() : null; + let cancelled = false; const attachmentPayload = attachments.map((item) => ({ name: item.name, mimeType: item.mimeType, @@ -512,7 +595,19 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou setAttachments([]); setSending(true); const assistantId = `brain-box-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + activeRequestIdRef.current = requestId; + activeAssistantMessageIdRef.current = assistantId; + setActiveQueryMeta({ + id: requestId, + prompt: content, + startedAt: Date.now(), + }); + cancelActiveRequestRef.current = () => { + cancelled = true; + abortController?.abort(); + }; setMessages((prev) => [...prev, { role: 'user', content: `${content}${attachmentSuffix}` }, { id: assistantId, role: 'assistant', content: '' }]); + const isActiveRequest = () => activeRequestIdRef.current === requestId && !cancelled; try { if (localCoreEnabled) { const localResult = await runAIAssistant({ @@ -521,6 +616,7 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou threadId: String(activeThread?.id || '').trim(), attachments: attachmentPayload, }); + if (!isActiveRequest()) return; const actions = Array.isArray(localResult?.proposedActions) ? localResult.proposedActions : []; const actionResult = actions.length > 0 ? await runAIAgentActions({ @@ -528,6 +624,7 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou toolCalls: actions, }).catch(() => null) : null; + if (!isActiveRequest()) return; const executedActions = Array.isArray(actionResult?.executed) ? actionResult.executed : []; const overviewText = executedActions .filter((item) => item?.tool === 'summarizeInbox' && String(item?.summary || '').trim()) @@ -574,6 +671,7 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou const finalText = String(localResult?.reply || '').trim() || 'No response.'; const output = `${finalText}${overviewText ? `\n\nInbox overview:\n${overviewText}` : ''}${actionText}`; for (let i = 0; i < output.length; i += 28) { + if (!isActiveRequest()) break; const chunk = output.slice(i, i + 28); onDelta?.(chunk); setMessages((prev) => prev.map((item) => ( @@ -590,7 +688,9 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou history, attachments: attachmentPayload, }, { + signal: abortController?.signal, onDelta: (delta) => { + if (!isActiveRequest()) return; const chunk = String(delta || ''); if (!chunk) return; onDelta?.(chunk); @@ -602,18 +702,28 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou }, }); } catch (e) { + if (!isActiveRequest() || isAbortError(e)) { + return; + } const fallback = await sendRexMessage({ message: content, history, attachments: attachmentPayload, }).catch(() => null); + if (!isActiveRequest()) return; const fallbackText = !fallback?.ok && fallback?.error ? `BRAIN BOX error: ${fallback.error}` : String(fallback?.reply || `BRAIN BOX error: ${String(e?.message || 'Request failed')}`); onDelta?.(fallbackText); setMessages((prev) => prev.map((item) => (item?.id === assistantId ? { ...item, content: fallbackText } : item))); } finally { - setSending(false); + if (activeRequestIdRef.current === requestId) { + setSending(false); + setActiveQueryMeta(null); + cancelActiveRequestRef.current = null; + activeRequestIdRef.current = ''; + activeAssistantMessageIdRef.current = ''; + } } }, [activeThread?.id, attachments, localCoreEnabled, runAIAgentActions, runAIAssistant, sendRexMessage, sendRexMessageStream, sending]); @@ -651,6 +761,13 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou const isDrawer = drawerMode && !popoutMode; const isDesktop = Boolean(window?.dynoboxDesktop?.isDesktop); + const activeRexMode = normalizeRexMode(rexSettings?.providerMode); + const activeRexMethodBadge = formatRexMethodBadge(rexSettings || {}); + const activeRexMethodTone = activeRexMode === 'ollama' + ? 'border-emerald-400/45 bg-emerald-400/10 text-emerald-200' + : activeRexMode === 'byok' + ? 'border-cyan-400/45 bg-cyan-400/10 text-cyan-200' + : 'border-amber-300/45 bg-amber-300/10 text-amber-100'; const transcribeDesktopAudio = useCallback(async (blob) => { const input = blob instanceof Blob ? blob : null; @@ -675,7 +792,7 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou return text; }, [sendRexTranscription]); - const openThreadFromAssistant = useCallback(({ threadId, accountId, hint } = {}) => { + const resolveThreadReferenceFromAssistant = useCallback(({ threadId, accountId, hint } = {}) => { const normalize = (value = '') => String(value || '').toLowerCase().replace(/\s+/g, ' ').trim(); let resolvedThreadId = String(threadId || '').trim(); let resolvedAccountId = String(accountId || '').trim(); @@ -695,6 +812,13 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou if (!resolvedAccountId) resolvedAccountId = String(fuzzy.accountId || '').trim(); } } + return { threadId: resolvedThreadId, accountId: resolvedAccountId }; + }, [dynoBoxState?.threads, threads]); + + const openThreadFromAssistant = useCallback(({ threadId, accountId, hint } = {}) => { + const resolved = resolveThreadReferenceFromAssistant({ threadId, accountId, hint }); + const resolvedThreadId = String(resolved?.threadId || '').trim(); + const resolvedAccountId = String(resolved?.accountId || '').trim(); if (!resolvedThreadId) { toast('Could not locate that thread in DYNO BOX yet.', { icon: 'ℹ️' }); return; @@ -703,7 +827,63 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou switchLabel(DYNOBOX_LABEL, '', '', 'DYNO BOX'); } openThread(resolvedThreadId, { accountId: resolvedAccountId || undefined }); - }, [currentLabel, dynoBoxState?.threads, openThread, switchLabel, threads]); + }, [currentLabel, openThread, resolveThreadReferenceFromAssistant, switchLabel]); + + const useDraftSuggestion = useCallback(async ({ threadId, accountId, hint, suggestion } = {}) => { + const resolved = resolveThreadReferenceFromAssistant({ threadId, accountId, hint }); + const resolvedThreadId = String(resolved?.threadId || '').trim(); + const resolvedAccountId = String(resolved?.accountId || '').trim(); + const suggestionText = String(suggestion || '').trim(); + if (!resolvedThreadId) { + toast('Could not locate that thread for this draft suggestion.', { icon: 'ℹ️' }); + return; + } + if (currentLabel !== DYNOBOX_LABEL) { + switchLabel(DYNOBOX_LABEL, '', '', 'DYNO BOX'); + } + const loadedThread = await openThread(resolvedThreadId, { accountId: resolvedAccountId || undefined }); + const thread = loadedThread && typeof loadedThread === 'object' ? loadedThread : null; + const messages = Array.isArray(thread?.messages) ? thread.messages : []; + const latestMessage = messages.length > 0 ? messages[messages.length - 1] : null; + if (!thread || !latestMessage) { + toast.error('Could not open a reply draft for this thread'); + return; + } + if (typeof window?.dynoboxDesktop?.openComposePopout === 'function') { + const maxQuotedChars = 12000; + const rawBodyHtml = String(latestMessage?.body?.html || ''); + const rawBodyPlain = String(latestMessage?.body?.plain || ''); + const replyPayload = { + mode: 'reply', + thread: { + id: String(thread?.id || resolvedThreadId || '').trim(), + myEmail: String(thread?.myEmail || '').trim(), + }, + message: { + headers: { + ...(latestMessage?.headers || {}), + }, + body: { + html: rawBodyHtml.length <= maxQuotedChars ? rawBodyHtml : '', + plain: rawBodyPlain.slice(0, maxQuotedChars), + }, + }, + aiSuggestion: suggestionText, + }; + try { + await window.dynoboxDesktop.openComposePopout(replyPayload); + return; + } catch { + toast('Could not open popout. Opening reply draft inline.', { icon: 'ℹ️' }); + } + } + setComposing({ + mode: 'reply', + thread, + message: latestMessage, + aiSuggestion: suggestionText, + }); + }, [currentLabel, openThread, resolveThreadReferenceFromAssistant, setComposing, switchLabel]); const openOverviewCardInDynoBox = useCallback(({ label, value } = {}) => { const normalize = (text = '') => String(text || '').toLowerCase().replace(/\s+/g, ' ').trim(); @@ -762,7 +942,7 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou
{/* Header */}
-
+
@@ -771,15 +951,23 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou - - - - + + + + BRAIN BOX Assistant +
{!popoutMode && !isDrawer && ( @@ -854,6 +1042,7 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou content={content} onThreadReferenceClick={openThreadFromAssistant} onOverviewCardClick={openOverviewCardInDynoBox} + onUseDraftSuggestion={useDraftSuggestion} /> ) : ( content @@ -872,6 +1061,27 @@ export default function AiAssistantWindow({ open, onClose, onOpenSettings, popou )}
+ {sending && activeQueryMeta && ( +
+
+

+ Ongoing query · {activeQueryElapsedSeconds}s +

+

+ {String(activeQueryMeta?.prompt || '').trim() || 'Working on request...'} +

+
+ +
+ )} {attachments.length > 0 && (
{attachments.map((att) => ( diff --git a/client/src/components/AssistantAnswerRenderer.tsx b/client/src/components/AssistantAnswerRenderer.tsx index e5128ac..c3d41e2 100644 --- a/client/src/components/AssistantAnswerRenderer.tsx +++ b/client/src/components/AssistantAnswerRenderer.tsx @@ -195,6 +195,24 @@ function deriveThreadHint(text = '') { return head; } +function deriveThreadContextFromNumberedItem(text = '') { + const raw = String(text || '').trim(); + if (!raw) return null; + const parsed = splitActionText(raw); + if (parsed.action) return null; + const threadRef = parseThreadReference(raw); + const content = cleanInlineMarkup(threadRef ? threadRef.text : raw); + const subjectMatch = content.match(/\bsubject\s*:\s*(.+)$/i); + const hinted = String(subjectMatch?.[1] || deriveThreadHint(content) || content || '').trim(); + const normalizedHint = hinted.replace(/^["'“”]+|["'“”]+$/g, '').trim(); + if (!threadRef && !normalizedHint) return null; + return { + threadId: String(threadRef?.threadId || '').trim(), + accountId: String(threadRef?.accountId || '').trim(), + hint: normalizedHint, + }; +} + function renderInlineText(text = '', keyPrefix = 'inline') { const source = String(text || ''); const tokenRe = /(\*\*\*[^*]+?\*\*\*|\*\*[^*]+?\*\*|___[^_]+?___|__[^_]+?__)/g; @@ -268,7 +286,16 @@ function renderThreadLine( ); } -function renderBlock(block, blockIdx, { onThreadReferenceClick, onOverviewCardClick } = {}) { +function renderBlock( + block, + blockIdx, + { + onThreadReferenceClick, + onOverviewCardClick, + onUseDraftSuggestion, + threadContext = null, + } = {} +) { if (block.type === 'paragraph') { return (

@@ -345,16 +372,44 @@ function renderBlock(block, blockIdx, { onThreadReferenceClick, onOverviewCardCl return (

    - {block.items.map((item, itemIdx) => ( -
  • - - {renderThreadLine(item, { - className: 'text-[13px] leading-relaxed text-text-primary', - onThreadReferenceClick, - keyPrefix: `bullet-${blockIdx}-${itemIdx}`, - })} -
  • - ))} + {block.items.map((item, itemIdx) => { + const suggestionText = cleanInlineMarkup(String(item || '')).replace(/^["'“”]+|["'“”]+$/g, '').trim(); + const canUseSuggestion = Boolean( + typeof onUseDraftSuggestion === 'function' + && threadContext + && (threadContext.threadId || threadContext.hint) + && suggestionText + ); + return ( +
  • + +
    +
    + {renderThreadLine(item, { + className: 'text-[13px] leading-relaxed text-text-primary', + onThreadReferenceClick, + keyPrefix: `bullet-${blockIdx}-${itemIdx}`, + })} +
    + {canUseSuggestion ? ( + + ) : null} +
    +
  • + ); + })}
); } @@ -363,6 +418,7 @@ export default function AssistantAnswerRenderer({ content = '', onThreadReferenceClick = null, onOverviewCardClick = null, + onUseDraftSuggestion = null, }) { const sections = useMemo(() => { const parsed = parseSections(content); @@ -387,12 +443,34 @@ export default function AssistantAnswerRenderer({ {sections.map((section, sectionIdx) => { const hasStructuredBlocks = section.blocks.some((block) => block.type !== 'paragraph'); const shouldCard = Boolean(section.title) || hasStructuredBlocks; - const sectionBody = ( -
- {section.blocks.map((block, blockIdx) => renderBlock(block, blockIdx, { + let pendingThreadContext = null; + const sectionBlocks = section.blocks.map((block, blockIdx) => { + if (block.type === 'numbered') { + const inferred = Array.isArray(block.items) + ? block.items.map((item) => deriveThreadContextFromNumberedItem(item?.text || '')).find(Boolean) || null + : null; + pendingThreadContext = inferred; + return renderBlock(block, blockIdx, { onThreadReferenceClick, onOverviewCardClick, - }))} + onUseDraftSuggestion, + threadContext: null, + }); + } + const node = renderBlock(block, blockIdx, { + onThreadReferenceClick, + onOverviewCardClick, + onUseDraftSuggestion, + threadContext: block.type === 'bullets' ? pendingThreadContext : null, + }); + if (block.type === 'bullets') { + pendingThreadContext = null; + } + return node; + }); + const sectionBody = ( +
+ {sectionBlocks}
); diff --git a/client/src/components/SettingsModal.tsx b/client/src/components/SettingsModal.tsx index bf946f5..e8c9c0a 100644 --- a/client/src/components/SettingsModal.tsx +++ b/client/src/components/SettingsModal.tsx @@ -11,6 +11,7 @@ import { Button } from './ui/button'; import { Input } from './ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Textarea } from './ui/textarea'; +import { SIDE_PANEL_WIDTH } from '../lib/layout'; const TABS = [ { id: 'preferences', label: 'Preferences' }, @@ -39,6 +40,29 @@ const DEFAULT_REX_SYSTEM_PROMPT = [ 'You can also handle inbox and calendar tasks such as triage, prioritization, drafting, and planning.', 'Be concise and actionable. Never claim you sent or changed anything.', ].join(' '); +const BASE_FONT_CHOICES = [ + { id: 'jetbrains-mono', label: 'JetBrains Mono' }, + { id: 'rubik', label: 'Rubik' }, + { id: 'dm-sans', label: 'DM Sans' }, + { id: 'inter', label: 'Inter' }, + { id: 'poppins', label: 'Poppins' }, + { id: 'libre-franklin', label: 'Libre Franklin' }, +]; +const ZERO_RETENTION_MODEL_OPTIONS = [ + { id: 'google/gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite (Preview)' }, + { id: 'anthropic/claude-haiku-4.5', label: 'Claude Haiku 4.5' }, + { id: 'anthropic/claude-sonnet-4.5', label: 'Claude Sonnet 4.5' }, + { id: 'google/gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' }, + { id: 'google/gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, +]; +const DEFAULT_OLLAMA_MODEL = 'llama3.2:latest'; + +function normalizeRexMode(value = '') { + const mode = String(value || '').trim().toLowerCase(); + if (mode === 'byok') return 'byok'; + if (mode === 'ollama') return 'ollama'; + return 'platform'; +} function getIncomingDefaults(protocol) { if (String(protocol || '').toUpperCase() === 'POP3') { @@ -71,6 +95,8 @@ export default function SettingsModal({ initialTab = 'preferences', theme, onSetTheme, + baseFont = 'poppins', + onSetBaseFont = () => {}, }) { const { accounts, @@ -120,6 +146,8 @@ export default function SettingsModal({ allowEmailAccess: true, allowCalendarAccess: true, systemPrompt: DEFAULT_REX_SYSTEM_PROMPT, + model: 'google/gemini-3.1-flash-lite-preview', + enforceZeroDataRetention: true, }); const [savingRex, setSavingRex] = useState(false); const [testingRex, setTestingRex] = useState(false); @@ -135,14 +163,23 @@ export default function SettingsModal({ }, [open, initialTab, activeAccount?.id, loadRexSettings]); useEffect(() => { + const mode = normalizeRexMode(rexSettings?.providerMode); + const localModels = Array.isArray(rexSettings?.availableLocalModels) + ? rexSettings.availableLocalModels.map((item) => String(item || '').trim()).filter(Boolean) + : []; + const fallbackModel = mode === 'ollama' + ? (localModels[0] || DEFAULT_OLLAMA_MODEL) + : (ZERO_RETENTION_MODEL_OPTIONS[0]?.id || 'google/gemini-3.1-flash-lite-preview'); setRexDraft((prev) => ({ ...prev, enabled: Boolean(rexSettings?.enabled), - providerMode: rexSettings?.providerMode || 'platform', + providerMode: mode, apiKey: '', allowEmailAccess: Boolean(rexSettings?.allowEmailAccess), allowCalendarAccess: Boolean(rexSettings?.allowCalendarAccess), systemPrompt: String(rexSettings?.systemPrompt || DEFAULT_REX_SYSTEM_PROMPT), + model: String(rexSettings?.model || fallbackModel), + enforceZeroDataRetention: rexSettings?.enforceZeroDataRetention !== false, })); }, [rexSettings]); @@ -190,6 +227,30 @@ export default function SettingsModal({ if (notificationPermission === 'denied') return 'Blocked'; return 'Not enabled'; }, [notificationSupported, notificationPermission, notificationsEnabled]); + const rexModelOptions = useMemo(() => { + const available = Array.isArray(rexSettings?.availableModels) && rexSettings.availableModels.length > 0 + ? rexSettings.availableModels + : ZERO_RETENTION_MODEL_OPTIONS.map((option) => option.id); + const deduped = Array.from(new Set(available.map((item) => String(item || '').trim()).filter(Boolean))); + return deduped.map((id) => { + const known = ZERO_RETENTION_MODEL_OPTIONS.find((option) => option.id === id); + return { + id, + label: known?.label || id, + }; + }); + }, [rexSettings?.availableModels]); + const rexLocalModelOptions = useMemo(() => { + const available = Array.isArray(rexSettings?.availableLocalModels) + ? rexSettings.availableLocalModels + : []; + return Array.from(new Set(available.map((item) => String(item || '').trim()).filter(Boolean))) + .map((id) => ({ id, label: id })); + }, [rexSettings?.availableLocalModels]); + const defaultZeroRetentionModel = rexModelOptions[0]?.id + || ZERO_RETENTION_MODEL_OPTIONS[0]?.id + || 'google/gemini-3.1-flash-lite-preview'; + const defaultLocalModel = rexLocalModelOptions[0]?.id || DEFAULT_OLLAMA_MODEL; const toggleNotifications = async (checked) => { if (!notificationSupported) { @@ -393,6 +454,11 @@ export default function SettingsModal({ }; const saveRex = async () => { + const nextModel = String(rexDraft.model || '').trim(); + if (rexDraft.providerMode === 'ollama' && !nextModel) { + toast.error('Enter a local Ollama model (example: llama3.2:latest)'); + return; + } try { setSavingRex(true); await updateRexSettings({ @@ -402,6 +468,8 @@ export default function SettingsModal({ allowEmailAccess: rexDraft.allowEmailAccess, allowCalendarAccess: rexDraft.allowCalendarAccess, systemPrompt: rexDraft.systemPrompt, + model: nextModel, + enforceZeroDataRetention: rexDraft.enforceZeroDataRetention, }); setRexDraft((prev) => ({ ...prev, apiKey: '' })); toast.success('BRAIN BOX settings saved'); @@ -413,11 +481,17 @@ export default function SettingsModal({ }; const runRexTest = async () => { + const nextModel = String(rexDraft.model || '').trim(); + if (rexDraft.providerMode === 'ollama' && !nextModel) { + toast.error('Enter a local Ollama model before testing'); + return; + } try { setTestingRex(true); const result = await testRexConnection({ providerMode: rexDraft.providerMode, apiKey: rexDraft.providerMode === 'byok' ? rexDraft.apiKey : '', + model: nextModel, }); if (result?.ok) { toast.success(result?.message || 'BRAIN BOX connection OK'); @@ -437,8 +511,9 @@ export default function SettingsModal({ !next && onClose?.()}>

Settings

@@ -492,6 +567,28 @@ export default function SettingsModal({
+
+

Typography

+
+
+

Base interface font

+

Applies across mail, settings, and BRAIN BOX.

+
+ +
+
+

Display

What to show in the thread list

@@ -563,22 +660,49 @@ export default function SettingsModal({
-

AI mode

-
+

Set AI method

+
+
@@ -595,6 +719,55 @@ export default function SettingsModal({ )} + {rexDraft.providerMode === 'ollama' ? ( +
+ + {rexLocalModelOptions.length > 0 && ( +

+ Detected locally: {rexLocalModelOptions.slice(0, 8).map((option) => option.id).join(', ')} + {rexLocalModelOptions.length > 8 ? ', ...' : ''} +

+ )} +
+ ) : ( +
+ + {rexDraft.providerMode === 'platform' && ( + + )} +
+ )} +
-
+
+ + + + + + + + BRAIN BOX + + + Draft reply + + + + Draft 3 variants + + + + Summarize thread + + + + Extract tasks + + + + {isGoogleAccount && ( - <> - setShowLabelsMenu((prev) => !prev)} title="Apply labels"> - Labels - - {showLabelsMenu && ( - - )} - + + + )} - handleReply(lastMsg)} title="Reply (r)">↩ Reply - handleReplyAll(lastMsg)} title="Reply all (a)">↩↩ All - BRAIN BOX Draft - 3 Drafts - Summary - Tasks + {isGoogleAccount && ( - <> - - {isThreadStarred ? 'Unstar' : 'Star'} - - archiveThread(resolvedActiveThread.id)} title="Archive (e)">Archive - trashThread(resolvedActiveThread.id)} - title="Trash (#)" - danger - >Trash - + trashThread(resolvedActiveThread.id)} + title="Trash (#)" + danger + > + + )} -
-
- setShowActionsMenu((prev) => !prev)} title="Actions">... - {showActionsMenu && ( -
- {isGoogleAccount && ( - - )} - - - - - - + + + + + + + + + Thread actions {isGoogleAccount && ( - + + + + Labels + + + Apply labels + {userLabels.length === 0 ? ( + No labels yet + ) : ( + userLabels.map((label) => { + const applied = threadLabelIds.includes(label.id); + const color = getLabelColor(label); + return ( + handleToggleLabel(label.id, applied)} + > + + {label.name} + + ); + }) + )} + + + + + Create label + + + )} + handleReply(lastMsg)}> + + Reply + R + + handleReplyAll(lastMsg)}> + + Reply all + {isGoogleAccount && ( <> - - + + archiveThread(resolvedActiveThread.id)}> + + Archive + E + )} -
- )} - {showLabelsMenu && isGoogleAccount && ( - - )} + +
@@ -927,60 +929,21 @@ export default function ThreadView({ theme = 'dark', onClose }) { ); } -function ActionBtn({ children, onClick, title, danger }) { +function HeaderIconButton({ children, className = '', accent = false, active = false, danger = false, ...props }) { return ( ); } - -function LabelsMenu({ labels, activeLabelIds, onToggle, onCreateLabel }) { - return ( -
-
- Labels - -
-
- {labels.length === 0 ? ( -

No labels yet

- ) : ( - labels.map((label) => { - const applied = activeLabelIds.includes(label.id); - const color = getLabelColor(label); - return ( - - ); - }) - )} -
-
- ); -} diff --git a/client/src/components/ui/dropdown-menu.tsx b/client/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..47ac3f9 --- /dev/null +++ b/client/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,168 @@ +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; +import { cn } from '../../lib/cn'; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef( + ({ className, inset, children, ...props }, ref) => ( + + {children} + + + ) +); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef( + ({ className, sideOffset = 6, ...props }, ref) => ( + + + + ) +); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef( + ({ className, inset, ...props }, ref) => ( + + ) +); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef( + ({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + + ) +); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef( + ({ className, children, ...props }, ref) => ( + + + + + + + {children} + + ) +); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef( + ({ className, inset, ...props }, ref) => ( + + ) +); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef( + ({ className, ...props }, ref) => ( + + ) +); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/client/src/components/ui/sonner.tsx b/client/src/components/ui/sonner.tsx new file mode 100644 index 0000000..05052ec --- /dev/null +++ b/client/src/components/ui/sonner.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Toaster as Sonner } from 'sonner'; +import { cn } from '../../lib/cn'; + +type ToasterProps = React.ComponentProps; + +export function Toaster({ + className, + richColors = true, + style, + toastOptions, + ...props +}: ToasterProps) { + const classNames = toastOptions?.classNames; + + return ( + + ); +} diff --git a/client/src/context/AppContext.tsx b/client/src/context/AppContext.tsx index f26aa1c..6f47d92 100644 --- a/client/src/context/AppContext.tsx +++ b/client/src/context/AppContext.tsx @@ -457,6 +457,10 @@ export function AppProvider({ children }) { allowEmailAccess: true, allowCalendarAccess: true, systemPrompt: DEFAULT_REX_SYSTEM_PROMPT, + model: 'google/gemini-3.1-flash-lite-preview', + enforceZeroDataRetention: true, + availableModels: [], + availableLocalModels: [], }); const [dynoBoxState, setDynoBoxState] = useState(() => emptyDynoBoxState()); const [focusedIndex, setFocusedIndex] = useState(0); @@ -469,9 +473,11 @@ export function AppProvider({ children }) { const activeThreadQueryKeyRef = useRef('label:INBOX'); const loadingMoreRef = useRef(false); const threadsCacheRef = useRef(new Map()); // scopedKey -> { threads, nextPageToken } + const labelQueryRef = useRef(''); const autoSyncAttemptedRef = useRef(new Set()); // accountId -> attempted const lastAutoSyncAtRef = useRef(0); const labelSyncAttemptedRef = useRef(new Set()); // `${accountId}:${label}` -> attempted + const labelFullSyncAttemptedRef = useRef(new Set()); // `${accountId}:${label}` -> attempted const labelRemoteFallbackAttemptedRef = useRef(new Set()); // `${accountId}:${label}` -> attempted const contactsRef = useRef(new Map()); const prevAccountIdRef = useRef(null); @@ -1269,7 +1275,8 @@ export function AppProvider({ children }) { if (!getCurrentAccount()) return; const { append = false, query, label, labelId, token } = opts; if (append && loadingMoreRef.current) return; - const q = query !== undefined ? query : searchQuery; + const persistedLabelQuery = localCoreEnabled ? '' : String(labelQueryRef.current || '').trim(); + const q = query !== undefined ? query : (searchQuery || persistedLabelQuery); const lbl = label !== undefined ? label : currentLabel; const lblId = labelId !== undefined ? labelId : currentLabelId; const queryKey = q ? `q:${q}` : lblId ? `labelId:${lblId}` : `label:${lbl}`; @@ -1299,18 +1306,19 @@ export function AppProvider({ children }) { nextPageToken: null, }; } else if (localCoreEnabled) { + const accountId = String(getCurrentAccount() || '').trim(); if (q) { const queryText = String(q || '').trim(); const useSemantic = /^nl:\s*/i.test(queryText); const normalizedQuery = queryText.replace(/^nl:\s*/i, ''); const results = useSemantic ? await mailCoreClient.searchSemantic({ - accountId: getCurrentAccount(), + accountId, query: normalizedQuery, topK: 40, }) : await mailCoreClient.searchKeyword({ - accountId: getCurrentAccount(), + accountId, query: normalizedQuery, limit: 80, }); @@ -1320,21 +1328,36 @@ export function AppProvider({ children }) { }; } else { const labelValue = lblId || lbl || 'INBOX'; + const shouldWarmFullLabel = isGoogleAccountActive && accountId && ( + Boolean(lblId) + || ['STARRED', 'SENT', 'DRAFT', 'DRAFTS', 'TRASH', 'SPAM'].includes(String(labelValue || '').toUpperCase()) + ); + if (shouldWarmFullLabel) { + const fullSyncKey = `${accountId}:${String(labelValue || '').trim().toUpperCase()}`; + if (!labelFullSyncAttemptedRef.current.has(fullSyncKey)) { + labelFullSyncAttemptedRef.current.add(fullSyncKey); + mailCoreClient.runLabelSyncNow({ + accountId, + labelId: labelValue, + perLabelLimit: 0, + }).catch(() => null); + } + } let threads = await mailCoreClient.getLabelThreads({ - accountId: getCurrentAccount(), + accountId, label: labelValue, limit: 120, }); if (Array.isArray(threads) && threads.length > 0 && isGoogleAccountActive && hasSparseLocalThreadMetadata(threads)) { - const metadataRefreshKey = `${String(getCurrentAccount() || '')}:${String(labelValue || '')}:metadata-refresh`; + const metadataRefreshKey = `${accountId}:${String(labelValue || '')}:metadata-refresh`; if (!labelSyncAttemptedRef.current.has(metadataRefreshKey)) { labelSyncAttemptedRef.current.add(metadataRefreshKey); await mailCoreClient.runLabelSyncNow({ - accountId: getCurrentAccount(), + accountId, labelId: labelValue, }).catch(() => null); threads = await mailCoreClient.getLabelThreads({ - accountId: getCurrentAccount(), + accountId, label: labelValue, limit: 120, }); @@ -1342,15 +1365,15 @@ export function AppProvider({ children }) { } // If this label has not been cached yet, trigger one sync + retry. if ((!Array.isArray(threads) || threads.length === 0) && labelValue && labelValue.toUpperCase() !== 'INBOX') { - const syncKey = `${String(getCurrentAccount() || '')}:${String(labelValue || '')}`; + const syncKey = `${accountId}:${String(labelValue || '')}`; if (!labelSyncAttemptedRef.current.has(syncKey)) { labelSyncAttemptedRef.current.add(syncKey); await mailCoreClient.runLabelSyncNow({ - accountId: getCurrentAccount(), + accountId, labelId: labelValue, }); threads = await mailCoreClient.getLabelThreads({ - accountId: getCurrentAccount(), + accountId, label: labelValue, limit: 120, }); @@ -1359,7 +1382,7 @@ export function AppProvider({ children }) { // If local cache still has no rows, use one remote fallback fetch so // Electron label switching remains functional while cache warms up. if ((!Array.isArray(threads) || threads.length === 0) && isGoogleAccountActive && labelValue) { - const remoteKey = `${String(getCurrentAccount() || '')}:${String(labelValue || '')}`; + const remoteKey = `${accountId}:${String(labelValue || '')}`; if (!labelRemoteFallbackAttemptedRef.current.has(remoteKey)) { labelRemoteFallbackAttemptedRef.current.add(remoteKey); const remoteParams = {}; @@ -2636,11 +2659,13 @@ export function AppProvider({ children }) { }, [nextPageToken, loadThreads]); const search = useCallback((q) => { + labelQueryRef.current = ''; setSearchQuery(q); loadThreads({ query: q }); }, [loadThreads]); const switchLabel = useCallback((label, query = '', labelId = '', labelName = '') => { + labelQueryRef.current = String(query || '').trim(); setCurrentLabel(label); setCurrentLabelId(labelId || ''); setCurrentLabelName(labelName || label || 'Inbox'); @@ -2707,6 +2732,8 @@ export function AppProvider({ children }) { prevAccountIdRef.current = activeAccount.id; if (accountChanged) { + labelQueryRef.current = ''; + labelFullSyncAttemptedRef.current = new Set(); contactsRef.current = new Map(); contactPhotosRef.current = new Map(); setContactSuggestions([]); diff --git a/client/src/hooks/useLocalMailViewState.ts b/client/src/hooks/useLocalMailViewState.ts index 8e5fa14..dfef3d9 100644 --- a/client/src/hooks/useLocalMailViewState.ts +++ b/client/src/hooks/useLocalMailViewState.ts @@ -7,7 +7,18 @@ import { useLocalCoreMessageQuery, } from './useMailCoreQueries'; -const SYSTEM_ROLE_BY_LABEL = {}; +const SYSTEM_ROLE_BY_LABEL = { + INBOX: 'inbox', + SENT: 'sent', + DRAFT: 'drafts', + DRAFTS: 'drafts', + TRASH: 'trash', + SPAM: 'spam', +}; + +function normalizeMailboxKey(value) { + return String(value || '').trim().toUpperCase(); +} function parseJsonSafe(value, fallback) { if (!value) return fallback; @@ -47,14 +58,35 @@ function threadRowFromLocalMessage(message) { } function mailboxIdForSelection(mailboxes = [], label, labelId) { + const items = Array.isArray(mailboxes) ? mailboxes : []; + const resolveMailbox = (needle) => { + const normalizedNeedle = normalizeMailboxKey(needle); + if (!normalizedNeedle) return null; + return items.find((item) => { + const id = normalizeMailboxKey(item?.id); + const remoteId = normalizeMailboxKey(item?.remoteId); + const name = normalizeMailboxKey(item?.name); + const role = normalizeMailboxKey(item?.role); + return ( + id === normalizedNeedle + || remoteId === normalizedNeedle + || name === normalizedNeedle + || role === normalizedNeedle + ); + }) || null; + }; + if (labelId) { - const byId = mailboxes.find((m) => m.id === labelId); - if (byId) return byId.id; + const mailbox = resolveMailbox(labelId); + if (mailbox?.id) return mailbox.id; return null; } - const role = SYSTEM_ROLE_BY_LABEL[label]; - if (!role) return null; - return mailboxes.find((item) => String(item?.role || '').toLowerCase() === role)?.id || null; + const role = SYSTEM_ROLE_BY_LABEL[normalizeMailboxKey(label)]; + if (role) { + const byRole = resolveMailbox(role); + if (byRole?.id) return byRole.id; + } + return resolveMailbox(label)?.id || null; } function threadDetailFromLocalMessages(messages = [], fallbackId = '') { diff --git a/client/src/index.css b/client/src/index.css index 2de7a6e..699bb6f 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Inter:wght@400;500;700&family=Libre+Franklin:wght@400;500;700&family=Poppins:wght@400;500;600;700&family=Rubik:wght@400;500;700&family=Space+Grotesk:wght@500;600;700&display=swap'); @tailwind base; @tailwind components; @@ -312,10 +312,10 @@ h1, h2, h3, h4, h5, h6 { :root[data-theme="dyno"] body::before { background: - radial-gradient(70% 55% at 2% 18%, rgba(245, 162, 128, 0.16), transparent 70%), + radial-gradient(70% 55% at 2% 18%, rgba(245, 162, 128, 0.16), transparent 40%), radial-gradient(62% 50% at 42% 12%, rgba(224, 145, 204, 0.14), transparent 72%), radial-gradient(60% 54% at 102% 80%, rgba(128, 191, 255, 0.13), transparent 72%), - linear-gradient(180deg, rgba(3, 5, 14, 0.86) 0%, rgba(4, 7, 17, 0.94) 100%); + linear-gradient(180deg, rgba(3, 5, 14, 0.6) 0%, rgba(4, 7, 17, 0.74) 100%); } :root[data-theme="nebula"] body::before { @@ -421,25 +421,61 @@ h1, h2, h3, h4, h5, h6 { /* Sidebar glass panel */ .sidebar-glass { - background: rgb(var(--surface-1) / 0.34); - backdrop-filter: blur(32px) saturate(165%); - -webkit-backdrop-filter: blur(32px) saturate(165%); + position: relative; + isolation: isolate; + /* background: + linear-gradient(180deg, rgb(var(--surface-1) / 0.08) 0%, rgb(var(--surface-0) / 0.16) 100%), + linear-gradient(135deg, rgb(var(--accent) / 0.08) 0%, transparent 88%, transparent 100%); */ + backdrop-filter: blur(28px); + /* -webkit-backdrop-filter: blur(36px) saturate(170%); */ + border-right: 1px solid rgb(var(--border-default) / 0.35); +} + +.sidebar-glass::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + /* background: + radial-gradient(120% 58% at -4% 0%, rgb(var(--accent) / 0.16), transparent 54%), + linear-gradient(180deg, rgb(255 255 255 / 0.08) 0%, transparent 26%, transparent 100%); */ + opacity: 0.95; + z-index: -1; +} + +.sidebar-glass::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(180deg, rgb(255 255 255 / 0.08) 0%, transparent 18%), + linear-gradient(90deg, transparent 0%, rgb(255 255 255 / 0.02) 100%); + mix-blend-mode: screen; + opacity: 0.5; + z-index: -1; } :root[data-theme="light"] .sidebar-glass { - background: rgb(255 255 255 / 0.42); + background: + linear-gradient(180deg, rgb(255 255 255 / 0.58) 0%, rgb(248 251 255 / 0.44) 100%), + linear-gradient(135deg, rgb(var(--accent) / 0.08) 0%, transparent 38%, transparent 100%); } /* In web/browser mode (no Electron vibrancy) apply a CSS blur fallback */ @supports not (-webkit-app-region: drag) { .sidebar-glass { - background: rgba(8, 8, 8, 0.42); + background: + linear-gradient(180deg, rgb(var(--surface-1) / 0.36) 0%, rgb(var(--surface-0) / 0.54) 100%), + linear-gradient(135deg, rgb(var(--accent) / 0.07) 0%, transparent 40%, transparent 100%); backdrop-filter: blur(32px) saturate(165%); -webkit-backdrop-filter: blur(32px) saturate(165%); } :root[data-theme="light"] .sidebar-glass { - background: rgba(246, 246, 248, 0.46); + background: + linear-gradient(180deg, rgb(255 255 255 / 0.72) 0%, rgb(246 246 248 / 0.58) 100%), + linear-gradient(135deg, rgb(var(--accent) / 0.08) 0%, transparent 40%, transparent 100%); } } @@ -541,41 +577,89 @@ h1, h2, h3, h4, h5, h6 { -webkit-backdrop-filter: blur(14px) saturate(130%); } -/* ─── Goey-toast: dark theme overrides ───────────────────────────────────── - SVG presentation attributes (fill=) are lower specificity than CSS rules, - so we can override the white blob default with the app's dark surfaces. - ─────────────────────────────────────────────────────────────────────────── */ - -/* Blob shape — near-black to match the app's terminal dark surfaces */ -[data-sonner-toaster][data-theme=dark] .goey-blobSvg path { - fill: rgb(18 18 18); -} - -/* Drop shadow — deeper to lift the dark blob off the background */ -[data-sonner-toaster][data-theme=dark] .goey-blobSvg { - filter: drop-shadow(0 8px 24px rgba(0,0,0,0.65)) drop-shadow(0 2px 6px rgba(0,0,0,0.5)); -} - -/* Title text — app palette semantic colours */ -[data-sonner-toaster][data-theme=dark] .goey-titleDefault { color: #d0d0d0; } -[data-sonner-toaster][data-theme=dark] .goey-titleSuccess { color: #c8f040; } /* app accent */ -[data-sonner-toaster][data-theme=dark] .goey-titleError { color: #ff6b6b; } -[data-sonner-toaster][data-theme=dark] .goey-titleWarning { color: #ffcc55; } -[data-sonner-toaster][data-theme=dark] .goey-titleInfo { color: #6eb5ff; } -[data-sonner-toaster][data-theme=dark] .goey-titleLoading { color: #d0d0d0; } -[data-sonner-toaster][data-theme=dark] .goey-description { color: #666; } - -/* Action buttons */ -[data-sonner-toaster][data-theme=dark] .goey-actionDefault { background: rgb(32 32 32); color: #d0d0d0; border: 1px solid rgba(255,255,255,0.08); } -[data-sonner-toaster][data-theme=dark] .goey-actionDefault:hover { background: rgb(42 42 42); } -[data-sonner-toaster][data-theme=dark] .goey-actionSuccess { background: rgb(16 32 8); color: #c8f040; border: 1px solid rgba(200,240,64,0.2); } -[data-sonner-toaster][data-theme=dark] .goey-actionSuccess:hover { background: rgb(22 42 12); } -[data-sonner-toaster][data-theme=dark] .goey-actionError { background: rgb(36 10 10); color: #ff6b6b; border: 1px solid rgba(255,107,107,0.2); } -[data-sonner-toaster][data-theme=dark] .goey-actionError:hover { background: rgb(46 14 14); } -[data-sonner-toaster][data-theme=dark] .goey-actionWarning { background: rgb(36 26 6); color: #ffcc55; border: 1px solid rgba(255,204,85,0.2); } -[data-sonner-toaster][data-theme=dark] .goey-actionWarning:hover { background: rgb(46 34 8); } -[data-sonner-toaster][data-theme=dark] .goey-actionInfo { background: rgb(6 18 40); color: #6eb5ff; border: 1px solid rgba(110,181,255,0.2); } -[data-sonner-toaster][data-theme=dark] .goey-actionInfo:hover { background: rgb(10 24 52); } +/* Sonner toast theme */ +[data-sonner-toaster] { + --normal-bg: rgb(var(--surface-2) / 0.92); + --normal-bg-hover: rgb(var(--surface-3) / 0.98); + --normal-border: rgb(var(--border-default)); + --normal-border-hover: rgb(var(--border-strong)); + --normal-text: rgb(var(--text-primary)); + --success-bg: rgb(var(--surface-3) / 0.96); + --success-border: rgb(var(--accent) / 0.24); + --success-text: rgb(var(--accent)); + --info-bg: rgba(6, 18, 40, 0.96); + --info-border: rgba(110, 181, 255, 0.22); + --info-text: rgb(110 181 255); + --warning-bg: rgba(36, 26, 6, 0.96); + --warning-border: rgba(255, 204, 85, 0.22); + --warning-text: rgb(255 204 85); + --error-bg: rgba(36, 10, 10, 0.96); + --error-border: rgba(255, 107, 107, 0.22); + --error-text: rgb(255 107 107); +} + +:root[data-theme="light"] [data-sonner-toaster], +:root[data-theme="fintecha"] [data-sonner-toaster] { + --normal-bg: rgba(255, 255, 255, 0.94); + --normal-bg-hover: rgb(var(--surface-2) / 0.98); + --success-bg: rgb(var(--accent) / 0.11); + --success-border: rgb(var(--accent) / 0.22); + --success-text: rgb(var(--accent-dim)); + --info-bg: rgba(20, 110, 225, 0.1); + --info-border: rgba(20, 110, 225, 0.18); + --info-text: rgb(20 110 225); + --warning-bg: rgba(220, 138, 28, 0.12); + --warning-border: rgba(220, 138, 28, 0.18); + --warning-text: rgb(162 97 8); + --error-bg: rgba(220, 38, 38, 0.1); + --error-border: rgba(220, 38, 38, 0.16); + --error-text: rgb(185 28 28); +} + +[data-sonner-toast][data-styled=true] { + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35), 0 6px 18px rgba(0, 0, 0, 0.18); +} + +[data-sonner-toast][data-styled=true] [data-content] { + gap: 4px; +} + +[data-sonner-toast][data-styled=true] [data-description] { + color: rgb(var(--text-secondary)); +} + +[data-sonner-toast][data-styled=true] [data-button] { + height: 28px; + border-radius: 8px; + padding: 0 10px; + border: 1px solid rgb(var(--border-default)); + background: rgb(var(--surface-3) / 0.92); + color: rgb(var(--text-primary)); +} + +[data-sonner-toast][data-styled=true] [data-button]:hover { + background: rgb(var(--surface-4) / 0.94); + border-color: rgb(var(--border-strong)); +} + +[data-sonner-toast][data-styled=true] [data-cancel] { + background: transparent; + color: rgb(var(--text-secondary)); +} + +[data-sonner-toast][data-styled=true] [data-close-button] { + background: rgb(var(--surface-1) / 0.96); + border-color: rgb(var(--border-default)); + color: rgb(var(--text-secondary)); +} + +[data-sonner-toast][data-styled=true] [data-close-button]:hover { + background: rgb(var(--surface-3) / 0.96); + border-color: rgb(var(--border-strong)); + color: rgb(var(--text-primary)); +} /* AI drawer glass panel */ .ai-drawer-glass { diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 8e77806..6fb5710 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -149,7 +149,9 @@ export const api = { // MCP metadata mcp: { - metadata: () => request('/api/mcp/metadata'), + getSettings: () => request('/api/mcp/settings'), + updateSettings: (payload = {}) => request('/api/mcp/settings', { method: 'POST', body: JSON.stringify(payload) }), + metadata: (options = {}) => request('/api/mcp/metadata', options), }, // BRAIN BOX assistant @@ -166,10 +168,12 @@ export const api = { const onDelta = typeof handlers?.onDelta === 'function' ? handlers.onDelta : () => {}; const onDone = typeof handlers?.onDone === 'function' ? handlers.onDone : () => {}; const onError = typeof handlers?.onError === 'function' ? handlers.onError : () => {}; + const signal = handlers?.signal || undefined; const res = await fetch(`${BASE}/api/rex/chat/stream`, { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify(payload || {}), + ...(signal ? { signal } : {}), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); diff --git a/client/src/lib/layout.ts b/client/src/lib/layout.ts new file mode 100644 index 0000000..e178a00 --- /dev/null +++ b/client/src/lib/layout.ts @@ -0,0 +1 @@ +export const SIDE_PANEL_WIDTH = 980; diff --git a/client/src/lib/toast.ts b/client/src/lib/toast.ts index 78d355c..766ac20 100644 --- a/client/src/lib/toast.ts +++ b/client/src/lib/toast.ts @@ -1,51 +1,115 @@ -/** - * Thin shim over goey-toast that exposes the same API surface as - * react-hot-toast so all call-sites stay unchanged. - * - * Mapping rules: - * toast('msg', { icon: 'ℹ️' }) → goeyToast.info - * toast('msg', { icon: '⚠' }) → goeyToast.warning - * toast('msg') → goeyToast (neutral/default) - * toast.success / .error / .warning / .info → direct passthrough - */ -import { goeyToast } from 'goey-toast'; - -type ToastOptions = { +import type { CSSProperties, MouseEvent, ReactNode } from 'react'; +import { toast as sonnerToast } from 'sonner'; +import type { ExternalToast } from 'sonner'; + +type ToastAction = { + label: ReactNode; + onClick: (event: MouseEvent) => void; + successLabel?: ReactNode; + actionButtonStyle?: CSSProperties; +}; + +type ToastDescriptionMap = { + default?: ReactNode; + success?: ReactNode; + error?: ReactNode; + loading?: ReactNode; +}; + +type ToastOptions = ExternalToast & { icon?: string; - duration?: number; - id?: string | number; - description?: unknown; + description?: ExternalToast['description'] | ToastDescriptionMap; + action?: ToastAction | ExternalToast['action']; + [key: string]: unknown; +}; + +type PromiseOptions = { + loading?: ReactNode; + success?: ReactNode | ((value: unknown) => ReactNode | { message: ReactNode }); + error?: ReactNode | ((value: unknown) => ReactNode | { message: ReactNode }); + description?: ReactNode | ToastDescriptionMap | ((value: unknown) => ReactNode); + finally?: () => void | Promise; [key: string]: unknown; }; -function stripIcon(opts?: ToastOptions) { - if (!opts) return undefined; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { icon: _icon, ...rest } = opts; - return Object.keys(rest).length > 0 ? rest : undefined; +function isDescriptionMap(value: unknown): value is ToastDescriptionMap { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + if (typeof value === 'function') return false; + return ['default', 'success', 'error', 'loading'].some((key) => key in (value as Record)); +} + +function pickDescription( + description: ToastOptions['description'] | PromiseOptions['description'], + variant: keyof ToastDescriptionMap = 'default' +) { + if (!isDescriptionMap(description)) return description; + return description[variant] ?? description.default ?? description.success ?? description.error ?? description.loading; +} + +function stripCustomFields(options?: ToastOptions, variant: keyof ToastDescriptionMap = 'default') { + if (!options) return undefined; + + const next: Record = { ...options }; + + delete next.icon; + + if (next.description !== undefined) { + next.description = pickDescription(options.description, variant); + } + + const action = options.action; + if (action && typeof action === 'object' && 'label' in action && 'onClick' in action) { + const { successLabel: _successLabel, ...restAction } = action as ToastAction; + next.action = restAction; + } + + return Object.keys(next).length > 0 ? next : undefined; +} + +function mapDescriptionForPromise(description: PromiseOptions['description']) { + if (!isDescriptionMap(description)) return description; + return (value: unknown) => { + if (value instanceof Error) return description.error ?? description.default; + return description.success ?? description.default ?? description.loading; + }; +} + +function normalizePromiseOptions(options?: PromiseOptions) { + if (!options) return undefined; + const next: Record = { ...options }; + if (options.description !== undefined) { + next.description = mapDescriptionForPromise(options.description); + } + return next; } -function toast(message: string, options?: ToastOptions) { +function toast(message: ReactNode, options?: ToastOptions) { const icon = options?.icon ?? ''; - const mapped = stripIcon(options); - if (icon === 'ℹ️' || icon === 'ℹ') return goeyToast.info(message, mapped as never); - if (icon === '⚠' || icon === '⚠️') return goeyToast.warning(message, mapped as never); - return goeyToast(message, mapped as never); + const mapped = stripCustomFields(options); + + if (icon === 'ℹ️' || icon === 'ℹ') return sonnerToast.info(message, mapped as never); + if (icon === '⚠' || icon === '⚠️') return sonnerToast.warning(message, mapped as never); + return sonnerToast(message, mapped as never); } -toast.success = (message: string, options?: ToastOptions) => - goeyToast.success(message, stripIcon(options) as never); +toast.success = (message: ReactNode, options?: ToastOptions) => + sonnerToast.success(message, stripCustomFields(options, 'success') as never); + +toast.error = (message: ReactNode, options?: ToastOptions) => + sonnerToast.error(message, stripCustomFields(options, 'error') as never); + +toast.warning = (message: ReactNode, options?: ToastOptions) => + sonnerToast.warning(message, stripCustomFields(options, 'default') as never); -toast.error = (message: string, options?: ToastOptions) => - goeyToast.error(message, stripIcon(options) as never); +toast.info = (message: ReactNode, options?: ToastOptions) => + sonnerToast.info(message, stripCustomFields(options, 'default') as never); -toast.warning = (message: string, options?: ToastOptions) => - goeyToast.warning(message, stripIcon(options) as never); +toast.loading = (message: ReactNode, options?: ToastOptions) => + sonnerToast.loading(message, stripCustomFields(options, 'loading') as never); -toast.info = (message: string, options?: ToastOptions) => - goeyToast.info(message, stripIcon(options) as never); +toast.promise = (promise: Promise | (() => Promise), options?: PromiseOptions) => + sonnerToast.promise(promise as never, normalizePromiseOptions(options) as never); -toast.promise = goeyToast.promise; -toast.dismiss = goeyToast.dismiss; +toast.dismiss = sonnerToast.dismiss; export default toast; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..c925939 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,126 @@ +# Architecture + +Dynobox is a three-package app with two runtime modes. + +```mermaid +flowchart LR + user[User] + client["client React renderer"] + server["server Express API"] + providers["Gmail, IMAP, POP3, SMTP"] + electron["Electron main process"] + sqlite["SQLite local store"] + ai["AI providers or Ollama"] + + user --> client + client -->|"web HTTP"| server + server --> providers + client -->|"desktop IPC"| electron + electron --> sqlite + electron -->|"sync via local server"| server + server --> ai + electron --> ai +``` + +## Packages + +- `client/`: React, Vite, TailwindCSS, and TypeScript renderer. +- `server/`: Express API for OAuth, Gmail, manual mail, MCP, and BRAIN BOX / REX. +- `electron/`: Electron main/preload code, IPC handlers, SQLite schema/migrations, and desktop integration. + +The client uses real TypeScript through Vite. Server and Electron source files are TypeScript-shaped JavaScript with `// @ts-nocheck`; `scripts/build-runtime-from-ts.mjs` copies them into runtime `.js` output. + +## Web Mode + +Web mode is the default local development path. + +```mermaid +flowchart LR + renderer["React renderer"] + apiClient["client src lib api"] + express["Express server"] + gmail["Gmail API"] + manual["IMAP POP3 SMTP"] + tokenFile["server token files"] + + renderer --> apiClient + apiClient --> express + express --> gmail + express --> manual + express --> tokenFile +``` + +Important files: + +- `client/src/lib/api.ts` +- `client/src/context/AppContext.tsx` +- `server/index.ts` +- `server/auth/oauth.ts` +- `server/routes/gmail.ts` +- `server/routes/mail.ts` +- `server/routes/rex.ts` +- `server/routes/mcp.ts` + +## Desktop Local-Core Mode + +Desktop mode uses Electron IPC and SQLite when `VITE_USE_LOCAL_CORE=true`. + +```mermaid +flowchart LR + renderer["React renderer"] + bridge["preload bridge"] + ipc["Electron IPC handlers"] + db["SQLite via better-sqlite3"] + query["TanStack Query cache"] + express["Local Express server"] + + renderer --> query + query --> bridge + bridge --> ipc + ipc --> db + ipc -->|"provider sync"| express +``` + +Important files: + +- `electron/main.ts` +- `electron/preload.ts` +- `electron/ipc/mailCore.ts` +- `electron/db/index.ts` +- `electron/db/schema.ts` +- `electron/db/migrations/` +- `client/src/lib/mailCoreClient.ts` +- `client/src/hooks/useMailCoreQueries.ts` +- `client/src/hooks/useLocalMailViewState.ts` + +SQLite is initialized in Electron's main process. The renderer never opens the database directly. + +## Data Storage + +Web-mode credentials and settings are local files under `server/` unless overridden: + +- `server/.tokens.json` +- `server/.mcp-settings.json` +- `server/.rex-settings.json` + +Desktop data lives under Electron's `userData` directory. On macOS this is typically `~/Library/Application Support/DYNO BOX/`. + +Do not commit runtime data, token files, generated builds, or packaged apps. + +## BRAIN BOX / REX + +The assistant has three provider modes: + +- Platform/gateway key from environment variables. +- BYOK key stored in local settings. +- Local Ollama endpoint. + +In desktop mode, semantic search may chunk email text, create embeddings, and store local message chunks and embedding jobs in SQLite. Provider-backed embeddings or chat can send selected mailbox context to the configured provider. + +See [`rex-ai-policy.md`](rex-ai-policy.md) for public privacy expectations and consent rules. + +## MCP + +The server exposes a local MCP endpoint for inbox automation. It is intended for loopback clients and can be protected by `DYNOBOX_API_KEY`. + +See [`MCP_README.md`](MCP_README.md) for endpoint details and tool examples. diff --git a/docs/local-core-implementation.md b/docs/local-core-implementation.md index 01f27a3..7eea8d6 100644 --- a/docs/local-core-implementation.md +++ b/docs/local-core-implementation.md @@ -1,4 +1,4 @@ -# DynoBox Local Storage + Caching — Implementation Summary +# Dynobox Local Storage + Caching - Implementation Summary ## Architecture Implemented @@ -20,28 +20,28 @@ React UI (ThreadList / ThreadView / Sidebar) | File | Purpose | |------|---------| -| `electron/db/schema.js` | Drizzle ORM table definitions: `accounts`, `mailboxes`, `threads`, `messages`, `messageMailboxes`, `attachments`, `syncState` | +| `electron/db/schema.ts` | Drizzle ORM table definitions: `accounts`, `mailboxes`, `threads`, `messages`, `messageMailboxes`, `attachments`, `syncState` | | `electron/db/migrations/0001_mail_core.sql` | Raw SQL migration: tables, indexes, FTS5 virtual table + insert/update/delete triggers | -| `electron/db/index.js` | DB bootstrap: WAL mode, foreign keys, migration runner, Drizzle init | -| `electron/ipc/mailCore.js` | All IPC handlers: CRUD queries, search, archive/trash/markRead actions, `db:stats`, and Gmail sync via existing Express server | -| `client/src/lib/mailCoreClient.js` | Renderer IPC bridge wrapper (feature-flagged by `VITE_USE_LOCAL_CORE=true` + desktop detection) | -| `client/src/lib/queryClient.js` | TanStack Query client (`staleTime: 2min`, `gcTime: 10min`) | -| `client/src/lib/queryKeys.js` | Shared query key factories for local-core queries | -| `client/src/hooks/useMailCoreQueries.js` | TanStack Query hooks: accounts, mailboxes, messages-by-mailbox, thread, message, sync invalidation listener | -| `client/src/hooks/useLocalMailViewState.js` | Shared view-state hook: resolves mailbox from label, maps local rows to UI shapes, dedupes threads, provides loading/syncing flags | +| `electron/db/index.ts` | DB bootstrap: WAL mode, foreign keys, migration runner, Drizzle init | +| `electron/ipc/mailCore.ts` | All IPC handlers: CRUD queries, search, archive/trash/markRead actions, `db:stats`, and Gmail sync via existing Express server | +| `client/src/lib/mailCoreClient.ts` | Renderer IPC bridge wrapper (feature-flagged by `VITE_USE_LOCAL_CORE=true` + desktop detection) | +| `client/src/lib/queryClient.ts` | TanStack Query client (`staleTime: 2min`, `gcTime: 10min`) | +| `client/src/lib/queryKeys.ts` | Shared query key factories for local-core queries | +| `client/src/hooks/useMailCoreQueries.ts` | TanStack Query hooks: accounts, mailboxes, messages-by-mailbox, thread, message, sync invalidation listener | +| `client/src/hooks/useLocalMailViewState.ts` | Shared view-state hook: resolves mailbox from label, maps local rows to UI shapes, dedupes threads, provides loading/syncing flags | ## Files Modified | File | Changes | |------|---------| -| `electron/main.js` | Initializes SQLite on startup (`app.getPath('userData')/dynobox.sqlite`), registers IPC handlers | -| `electron/preload.js` | Exposes `window.dynoboxDesktop.db.*` methods + `onSyncChanged` listener | -| `client/src/main.jsx` | Wraps app with `QueryClientProvider` | -| `client/src/App.jsx` | Mounts `useLocalCoreSyncInvalidation()` globally | -| `client/src/context/AppContext.jsx` | Hybrid local/API fallback: label hydration from local mailboxes, per-scope thread cache (legacy path), auto-sync on account selection, local-core actions for archive/trash/markRead | -| `client/src/components/ThreadList.jsx` | Consumes `useLocalMailViewState` for local-mode list rendering with syncing indicators | -| `client/src/components/ThreadView.jsx` | Consumes `useLocalMailViewState` for local-mode thread detail with fallback | -| `client/src/hooks/useKeyboard.js` | Uses effective local threads/active-thread for keyboard navigation | +| `electron/main.ts` | Initializes SQLite on startup (`app.getPath('userData')/dynobox.sqlite`), registers IPC handlers | +| `electron/preload.ts` | Exposes `window.dynoboxDesktop.db.*` methods + `onSyncChanged` listener | +| `client/src/main.tsx` | Wraps app with `QueryClientProvider` | +| `client/src/App.tsx` | Mounts `useLocalCoreSyncInvalidation()` globally | +| `client/src/context/AppContext.tsx` | Hybrid local/API fallback: label hydration from local mailboxes, per-scope thread cache (legacy path), auto-sync on account selection, local-core actions for archive/trash/markRead | +| `client/src/components/ThreadList.tsx` | Consumes `useLocalMailViewState` for local-mode list rendering with syncing indicators | +| `client/src/components/ThreadView.tsx` | Consumes `useLocalMailViewState` for local-mode thread detail with fallback | +| `client/src/hooks/useKeyboard.ts` | Uses effective local threads/active-thread for keyboard navigation | | `package.json` | Added `better-sqlite3`, `drizzle-orm`; fixed desktop scripts to unset `ELECTRON_RUN_AS_NODE` + `NODE_OPTIONS` | | `client/package.json` | Added `@tanstack/react-query` | | `README.md` | Documented `VITE_USE_LOCAL_CORE=true` flag and Electron env troubleshooting | @@ -166,6 +166,10 @@ Plus `window.dynoboxDesktop.onSyncChanged(callback)` for event listener. - SQLite location: `~/Library/Application Support/dynobox/dynobox.sqlite` (Electron `userData`) - Native module: `npx electron-rebuild -f -w better-sqlite3` after install +## Runtime Output + +Server and Electron runtime files are generated by `scripts/build-runtime-from-ts.mjs`. Generated `.js` output belongs in `server/.build/` and `electron-build/`; do not edit or commit those directories. + ## Known State / What's Next ### Working diff --git a/docs/rex-ai-implementation-backlog.md b/docs/rex-ai-implementation-backlog.md index 1ed250f..532b41a 100644 --- a/docs/rex-ai-implementation-backlog.md +++ b/docs/rex-ai-implementation-backlog.md @@ -1,5 +1,7 @@ # REX AI Integration Backlog +This is a historical implementation backlog. For the current public security and privacy policy, see [`rex-ai-policy.md`](rex-ai-policy.md). + ## Goal Integrate `REX` (AI assistant) across Dynobox with: - Platform-provided AI key (usage billed by Dynobox) diff --git a/docs/rex-ai-policy.md b/docs/rex-ai-policy.md new file mode 100644 index 0000000..c51e0ec --- /dev/null +++ b/docs/rex-ai-policy.md @@ -0,0 +1,64 @@ +# BRAIN BOX / REX AI Policy + +BRAIN BOX is the user-facing AI assistant in Dynobox. Some code and environment variables still use the historical `REX` name. + +AI features are optional. Do not enable them with sensitive mail until you understand which provider mode is active and what data may leave your machine. + +## Provider Modes + +Dynobox supports three AI paths: + +- **Platform or gateway key**: the server or Electron process reads `REX_PLATFORM_API_KEY`, `VERCEL_AI_GATEWAY_API_KEY`, or `AI_GATEWAY_API_KEY` from the environment and sends requests to the configured gateway. +- **BYOK**: the user stores their own API key in BRAIN BOX settings. Provider selection is inferred from settings and key shape where supported. +- **Ollama**: requests are sent to a local Ollama endpoint, usually `http://127.0.0.1:11434`. + +Environment names currently use `REX_*` for compatibility. + +## Data That May Be Sent to Providers + +Depending on the action, Dynobox may include: + +- The user's prompt. +- Selected thread subject, snippets, participants, dates, and message bodies. +- Draft content from the compose editor. +- Summaries or extracted task context. +- Search results used as retrieval context. +- Audio transcription payloads when transcription is used. +- Embedding inputs for semantic search. + +Attachments should not be sent to AI providers unless a feature explicitly implements that behavior and documents it. + +## Data That Stays Local + +Dynobox stores local AI settings and desktop semantic-search state locally: + +- `server/.rex-settings.json` in web/server mode. +- `rex_settings`, `message_chunks`, `embeddings`, and `embedding_jobs` tables in desktop SQLite. +- Local Ollama requests stay on the local machine if Ollama is bound only to loopback and no remote model endpoint is configured. + +Local storage is still sensitive. Anyone who can read the token files, settings files, or desktop database may be able to inspect mailbox-derived content or provider credentials. + +## Consent and Control Expectations + +AI features should follow these rules: + +- AI must be opt-in for real mailbox context. +- Sending, deleting, archiving, or modifying mail should require explicit user action. +- Provider mode and model should be visible in settings. +- BYOK keys should not be logged, rendered in full, or included in crash reports. +- Errors should redact provider keys and mailbox content. +- New AI workflows should document what mailbox fields are included in prompts. + +## Embeddings and Retrieval + +Desktop semantic search may chunk email content and create embeddings. The chunk text is stored locally in SQLite. Embedding vectors are also local, but the text used to create them may be sent to the configured embedding provider unless the app is configured for a local-only embedding path. + +Before enabling embeddings for real mail, review: + +- Which provider creates embeddings. +- Whether the provider offers zero-retention or no-training guarantees. +- Whether local SQLite backups are encrypted. + +## Public Development Notes + +Contributors should treat AI changes as security- and privacy-sensitive. Pull requests that change prompt construction, context selection, provider routing, key storage, transcription, embeddings, or tool execution should update this policy and `SECURITY.md`. diff --git a/electron/assets/dynobox-icon-1024.png b/electron/assets/dynobox-icon-1024.png deleted file mode 100644 index aa56314..0000000 Binary files a/electron/assets/dynobox-icon-1024.png and /dev/null differ diff --git a/electron/assets/dynobox-icon-256.png b/electron/assets/dynobox-icon-256.png deleted file mode 100644 index f253943..0000000 Binary files a/electron/assets/dynobox-icon-256.png and /dev/null differ diff --git a/electron/assets/dynobox-icon-512.png b/electron/assets/dynobox-icon-512.png deleted file mode 100644 index 7610e42..0000000 Binary files a/electron/assets/dynobox-icon-512.png and /dev/null differ diff --git a/electron/assets/dynobox.icns b/electron/assets/dynobox.icns deleted file mode 100644 index f2a3da2..0000000 Binary files a/electron/assets/dynobox.icns and /dev/null differ diff --git a/electron/assets/dynobox.iconset/icon_128x128.png b/electron/assets/dynobox.iconset/icon_128x128.png deleted file mode 100644 index 19423ed..0000000 Binary files a/electron/assets/dynobox.iconset/icon_128x128.png and /dev/null differ diff --git a/electron/assets/dynobox.iconset/icon_128x128@2x.png b/electron/assets/dynobox.iconset/icon_128x128@2x.png deleted file mode 100644 index f253943..0000000 Binary files a/electron/assets/dynobox.iconset/icon_128x128@2x.png and /dev/null differ diff --git a/electron/assets/dynobox.iconset/icon_16x16.png b/electron/assets/dynobox.iconset/icon_16x16.png deleted file mode 100644 index 94bc868..0000000 Binary files a/electron/assets/dynobox.iconset/icon_16x16.png and /dev/null differ diff --git a/electron/assets/dynobox.iconset/icon_16x16@2x.png b/electron/assets/dynobox.iconset/icon_16x16@2x.png deleted file mode 100644 index 28ec7e3..0000000 Binary files a/electron/assets/dynobox.iconset/icon_16x16@2x.png and /dev/null differ diff --git a/electron/assets/dynobox.iconset/icon_256x256.png b/electron/assets/dynobox.iconset/icon_256x256.png deleted file mode 100644 index f253943..0000000 Binary files a/electron/assets/dynobox.iconset/icon_256x256.png and /dev/null differ diff --git a/electron/assets/dynobox.iconset/icon_256x256@2x.png b/electron/assets/dynobox.iconset/icon_256x256@2x.png deleted file mode 100644 index 7610e42..0000000 Binary files a/electron/assets/dynobox.iconset/icon_256x256@2x.png and /dev/null differ diff --git a/electron/assets/dynobox.iconset/icon_32x32.png b/electron/assets/dynobox.iconset/icon_32x32.png deleted file mode 100644 index 28ec7e3..0000000 Binary files a/electron/assets/dynobox.iconset/icon_32x32.png and /dev/null differ diff --git a/electron/assets/dynobox.iconset/icon_32x32@2x.png b/electron/assets/dynobox.iconset/icon_32x32@2x.png deleted file mode 100644 index cb1793f..0000000 Binary files a/electron/assets/dynobox.iconset/icon_32x32@2x.png and /dev/null differ diff --git a/electron/assets/dynobox.iconset/icon_512x512.png b/electron/assets/dynobox.iconset/icon_512x512.png deleted file mode 100644 index 7610e42..0000000 Binary files a/electron/assets/dynobox.iconset/icon_512x512.png and /dev/null differ diff --git a/electron/assets/dynobox.iconset/icon_512x512@2x.png b/electron/assets/dynobox.iconset/icon_512x512@2x.png deleted file mode 100644 index aa56314..0000000 Binary files a/electron/assets/dynobox.iconset/icon_512x512@2x.png and /dev/null differ diff --git a/electron/assets/icon.png b/electron/assets/icon.png new file mode 100644 index 0000000..012d061 Binary files /dev/null and b/electron/assets/icon.png differ diff --git a/electron/assets/linux/icons/128x128.png b/electron/assets/linux/icons/128x128.png new file mode 100644 index 0000000..bed22fc Binary files /dev/null and b/electron/assets/linux/icons/128x128.png differ diff --git a/electron/assets/linux/icons/16x16.png b/electron/assets/linux/icons/16x16.png new file mode 100644 index 0000000..5413400 Binary files /dev/null and b/electron/assets/linux/icons/16x16.png differ diff --git a/electron/assets/linux/icons/256x256.png b/electron/assets/linux/icons/256x256.png new file mode 100644 index 0000000..caff654 Binary files /dev/null and b/electron/assets/linux/icons/256x256.png differ diff --git a/electron/assets/linux/icons/32x32.png b/electron/assets/linux/icons/32x32.png new file mode 100644 index 0000000..4463208 Binary files /dev/null and b/electron/assets/linux/icons/32x32.png differ diff --git a/electron/assets/linux/icons/48x48.png b/electron/assets/linux/icons/48x48.png new file mode 100644 index 0000000..57b6092 Binary files /dev/null and b/electron/assets/linux/icons/48x48.png differ diff --git a/electron/assets/linux/icons/512x512.png b/electron/assets/linux/icons/512x512.png new file mode 100644 index 0000000..012942c Binary files /dev/null and b/electron/assets/linux/icons/512x512.png differ diff --git a/electron/assets/linux/icons/64x64.png b/electron/assets/linux/icons/64x64.png new file mode 100644 index 0000000..15779e4 Binary files /dev/null and b/electron/assets/linux/icons/64x64.png differ diff --git a/electron/assets/macos/1024x1024.png b/electron/assets/macos/1024x1024.png new file mode 100644 index 0000000..012d061 Binary files /dev/null and b/electron/assets/macos/1024x1024.png differ diff --git a/electron/assets/macos/128x128.png b/electron/assets/macos/128x128.png new file mode 100644 index 0000000..1b7796f Binary files /dev/null and b/electron/assets/macos/128x128.png differ diff --git a/electron/assets/macos/16x16.png b/electron/assets/macos/16x16.png new file mode 100644 index 0000000..ea4abcf Binary files /dev/null and b/electron/assets/macos/16x16.png differ diff --git a/electron/assets/macos/256x256.png b/electron/assets/macos/256x256.png new file mode 100644 index 0000000..6c99b0b Binary files /dev/null and b/electron/assets/macos/256x256.png differ diff --git a/electron/assets/macos/32x32.png b/electron/assets/macos/32x32.png new file mode 100644 index 0000000..f3d9203 Binary files /dev/null and b/electron/assets/macos/32x32.png differ diff --git a/electron/assets/macos/512x512.png b/electron/assets/macos/512x512.png new file mode 100644 index 0000000..28474cb Binary files /dev/null and b/electron/assets/macos/512x512.png differ diff --git a/electron/assets/macos/64x64.png b/electron/assets/macos/64x64.png new file mode 100644 index 0000000..5f78eb9 Binary files /dev/null and b/electron/assets/macos/64x64.png differ diff --git a/electron/assets/macos/icon.icns b/electron/assets/macos/icon.icns new file mode 100644 index 0000000..cd27dcb Binary files /dev/null and b/electron/assets/macos/icon.icns differ diff --git a/electron/assets/windows/128x128.png b/electron/assets/windows/128x128.png new file mode 100644 index 0000000..847e221 Binary files /dev/null and b/electron/assets/windows/128x128.png differ diff --git a/electron/assets/windows/16x16.png b/electron/assets/windows/16x16.png new file mode 100644 index 0000000..aff6837 Binary files /dev/null and b/electron/assets/windows/16x16.png differ diff --git a/electron/assets/windows/256x256.png b/electron/assets/windows/256x256.png new file mode 100644 index 0000000..6c99b0b Binary files /dev/null and b/electron/assets/windows/256x256.png differ diff --git a/electron/assets/windows/32x32.png b/electron/assets/windows/32x32.png new file mode 100644 index 0000000..e69da2f Binary files /dev/null and b/electron/assets/windows/32x32.png differ diff --git a/electron/assets/windows/48x48.png b/electron/assets/windows/48x48.png new file mode 100644 index 0000000..48b488a Binary files /dev/null and b/electron/assets/windows/48x48.png differ diff --git a/electron/assets/windows/64x64.png b/electron/assets/windows/64x64.png new file mode 100644 index 0000000..d64c7d0 Binary files /dev/null and b/electron/assets/windows/64x64.png differ diff --git a/electron/assets/windows/icon.ico b/electron/assets/windows/icon.ico new file mode 100644 index 0000000..21ae9d7 Binary files /dev/null and b/electron/assets/windows/icon.ico differ diff --git a/electron/db/index.ts b/electron/db/index.ts index 5125a6e..3582d60 100644 --- a/electron/db/index.ts +++ b/electron/db/index.ts @@ -20,6 +20,25 @@ function ensureMigrationsTable(database) { `); } +function quoteSqlIdentifier(identifier) { + return `"${String(identifier || '').replace(/"/g, '""')}"`; +} + +function hasColumn(database, tableName, columnName) { + const rows = database.prepare(`PRAGMA table_info(${quoteSqlIdentifier(tableName)})`).all(); + return rows.some((row) => String(row?.name || '').toLowerCase() === String(columnName || '').toLowerCase()); +} + +function stripAlreadyAppliedAddColumnStatements(database, sqlText) { + const addColumnPattern = /ALTER\s+TABLE\s+("?[\w.]+"?)\s+ADD\s+COLUMN\s+("?[\w.]+"?)[\s\S]*?;/gi; + return String(sqlText || '').replace(addColumnPattern, (statement, rawTableName, rawColumnName) => { + const tableName = String(rawTableName || '').replace(/^"|"$/g, ''); + const columnName = String(rawColumnName || '').replace(/^"|"$/g, ''); + if (!tableName || !columnName) return statement; + return hasColumn(database, tableName, columnName) ? `-- skipped existing column ${tableName}.${columnName};` : statement; + }); +} + function runMigrations(database) { ensureMigrationsTable(database); @@ -33,7 +52,8 @@ function runMigrations(database) { const insertMigration = database.prepare('INSERT INTO __migrations(name) VALUES (?)'); const runInTransaction = database.transaction((name, sqlText) => { - database.exec(sqlText); + const normalizedSql = stripAlreadyAppliedAddColumnStatements(database, sqlText); + database.exec(normalizedSql); insertMigration.run(name); }); diff --git a/electron/db/migrations/0005_rex_model_policy.sql b/electron/db/migrations/0005_rex_model_policy.sql new file mode 100644 index 0000000..2620c59 --- /dev/null +++ b/electron/db/migrations/0005_rex_model_policy.sql @@ -0,0 +1,7 @@ +PRAGMA foreign_keys = ON; + +ALTER TABLE rex_settings + ADD COLUMN model TEXT NOT NULL DEFAULT 'google/gemini-3.1-flash-lite-preview'; + +ALTER TABLE rex_settings + ADD COLUMN enforce_zero_data_retention INTEGER NOT NULL DEFAULT 1; diff --git a/electron/db/schema.ts b/electron/db/schema.ts index 625f272..a460057 100644 --- a/electron/db/schema.ts +++ b/electron/db/schema.ts @@ -132,6 +132,8 @@ const rexSettings = sqliteTable('rex_settings', { allowEmailAccess: integer('allow_email_access').notNull(), allowCalendarAccess: integer('allow_calendar_access').notNull(), systemPrompt: text('system_prompt'), + model: text('model').notNull(), + enforceZeroDataRetention: integer('enforce_zero_data_retention').notNull(), updatedAt: integer('updated_at').notNull(), }); diff --git a/electron/ipc/mailCore.ts b/electron/ipc/mailCore.ts index f49b5cb..5c58cc9 100644 --- a/electron/ipc/mailCore.ts +++ b/electron/ipc/mailCore.ts @@ -15,12 +15,29 @@ const SYNC_QUOTA_BACKOFF_MS = 120_000; const SYNC_LABELS_CACHE_TTL_MS = 10 * 60_000; const DEFAULT_SYNC_LABEL_IDS = ['INBOX']; const DEFAULT_SYNC_PER_LABEL_LIMIT = 80; +const MAX_ON_DEMAND_SYNC_PER_LABEL = 2000; const DEFAULT_REX_SYSTEM_PROMPT = [ 'You are BRAIN BOX, a general assistant for this Dynobox account.', 'You can perform cross-account semantic search, auto-tagging, intent detection, reply suggestions, thread summarisation, task extraction, and follow-up detection.', 'You can also handle inbox and calendar tasks such as triage, prioritization, drafting, and planning.', 'Be concise and actionable. Never claim you sent or changed anything.', ].join(' '); +const ZERO_RETENTION_MODELS = [ + 'google/gemini-3.1-flash-lite-preview', + 'anthropic/claude-haiku-4.5', + 'anthropic/claude-sonnet-4.5', + 'google/gemini-2.5-flash-lite', + 'google/gemini-2.5-pro', +]; +const DEFAULT_REX_MODEL = ZERO_RETENTION_MODELS.includes(String(process.env.REX_MODEL || '').trim()) + ? String(process.env.REX_MODEL || '').trim() + : 'google/gemini-3.1-flash-lite-preview'; +const DEFAULT_OLLAMA_MODEL = String(process.env.REX_OLLAMA_MODEL || '').trim() || 'llama3.2:latest'; +const DEFAULT_OLLAMA_KEEP_ALIVE = String(process.env.REX_OLLAMA_KEEP_ALIVE || '30m').trim() || '30m'; +const OLLAMA_CHAT_TIMEOUT_MS = Math.max( + 5_000, + Math.min(Number(process.env.REX_OLLAMA_CHAT_TIMEOUT_MS || 35_000) || 35_000, 120_000) +); function safeJsonParse(value, fallback) { if (!value) return fallback; @@ -37,6 +54,13 @@ function clampLimit(value, defaultValue = 50, max = 200) { return Math.min(Math.floor(numeric), max); } +function normalizeSyncPerLabelLimit(value, defaultValue = DEFAULT_SYNC_PER_LABEL_LIMIT) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return defaultValue; + if (numeric <= 0) return Number.POSITIVE_INFINITY; + return Math.min(Math.floor(numeric), MAX_ON_DEMAND_SYNC_PER_LABEL); +} + function normalizeSyncLabelIds(input) { if (!Array.isArray(input)) return []; return input @@ -66,7 +90,16 @@ function normalizeEnabled(value, fallback = true) { } function normalizeProviderMode(value) { - return String(value || '').toLowerCase() === 'byok' ? 'byok' : 'platform'; + const normalized = String(value || '').toLowerCase(); + if (normalized === 'byok') return 'byok'; + if (normalized === 'ollama') return 'ollama'; + return 'platform'; +} + +function isAbortError(error = null) { + if (error?.name === 'AbortError') return true; + const message = String(error?.message || '').toLowerCase(); + return message.includes('aborted') || message.includes('aborterror'); } function normalizeSystemPrompt(value, fallback = DEFAULT_REX_SYSTEM_PROMPT) { @@ -76,6 +109,43 @@ function normalizeSystemPrompt(value, fallback = DEFAULT_REX_SYSTEM_PROMPT) { return trimmed.slice(0, 12_000); } +function normalizeModel(value, fallback = DEFAULT_REX_MODEL, providerMode = 'platform') { + const mode = normalizeProviderMode(providerMode); + const model = String(value || '').trim(); + if (mode === 'ollama') { + if (!model) return String(fallback || DEFAULT_OLLAMA_MODEL).slice(0, 160); + return model.slice(0, 160); + } + if (!model) return String(fallback || DEFAULT_REX_MODEL); + if (ZERO_RETENTION_MODELS.includes(model)) return model; + return String(fallback || DEFAULT_REX_MODEL); +} + +function zeroRetentionProviderOptions(model = '') { + const provider = String(model || '').trim().toLowerCase().split('/')[0]; + if (provider === 'anthropic') { + return { + anthropic: { + cacheControl: { type: 'ephemeral' }, + }, + }; + } + if (provider === 'google') { + return { + google: { + thinkingConfig: { thinkingBudget: 0 }, + }, + }; + } + return null; +} + +function resolveByokProvider(apiKey = '') { + const normalized = String(apiKey || '').trim().toLowerCase(); + if (normalized.startsWith('sk-ant-')) return 'anthropic'; + return 'openai'; +} + function encryptSecret(value = '') { const text = String(value || ''); if (!text) return ''; @@ -223,7 +293,9 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { api_key_encrypted AS apiKeyEncrypted, allow_email_access AS allowEmailAccess, allow_calendar_access AS allowCalendarAccess, - system_prompt AS systemPrompt + system_prompt AS systemPrompt, + model, + enforce_zero_data_retention AS enforceZeroDataRetention FROM rex_settings WHERE account_id = ? LIMIT 1` @@ -237,9 +309,11 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { allow_email_access, allow_calendar_access, system_prompt, + model, + enforce_zero_data_retention, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, unixepoch()) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch()) ON CONFLICT(account_id) DO UPDATE SET enabled = excluded.enabled, provider_mode = excluded.provider_mode, @@ -247,6 +321,8 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { allow_email_access = excluded.allow_email_access, allow_calendar_access = excluded.allow_calendar_access, system_prompt = excluded.system_prompt, + model = excluded.model, + enforce_zero_data_retention = excluded.enforce_zero_data_retention, updated_at = unixepoch() `); @@ -509,19 +585,25 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { allowEmailAccess: true, allowCalendarAccess: true, systemPrompt: DEFAULT_REX_SYSTEM_PROMPT, + model: DEFAULT_REX_MODEL, + enforceZeroDataRetention: true, }; } const row = selectRexSettingsByAccount.get(resolvedAccountId); const decrypted = decryptSecret(row?.apiKeyEncrypted || ''); + const providerMode = normalizeProviderMode(row?.providerMode || 'platform'); + const fallbackModel = providerMode === 'ollama' ? DEFAULT_OLLAMA_MODEL : DEFAULT_REX_MODEL; return { accountId: resolvedAccountId, enabled: row ? Boolean(row.enabled) : true, - providerMode: row?.providerMode || 'platform', + providerMode, apiKeyConfigured: Boolean(decrypted), apiKey: decrypted, allowEmailAccess: row ? Boolean(row.allowEmailAccess) : true, allowCalendarAccess: row ? Boolean(row.allowCalendarAccess) : true, systemPrompt: normalizeSystemPrompt(row?.systemPrompt, DEFAULT_REX_SYSTEM_PROMPT), + model: normalizeModel(row?.model, fallbackModel, providerMode), + enforceZeroDataRetention: row ? Boolean(row.enforceZeroDataRetention) : true, }; } @@ -555,6 +637,7 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { ).trim(); if (envKey) return envKey; const settings = getRexSettingsForAccount(accountId); + if (normalizeProviderMode(settings?.providerMode) === 'ollama') return ''; return String(settings?.apiKey || '').trim(); } @@ -562,8 +645,23 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { return String(process.env.REX_GATEWAY_BASE_URL || 'https://ai-gateway.vercel.sh/v1').replace(/\/+$/, ''); } - function chatModelName() { - return String(process.env.REX_MODEL || 'google/gemini-2.5-flash-lite').trim(); + function anthropicBaseUrl() { + return String(process.env.REX_ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/+$/, ''); + } + + function ollamaBaseUrl() { + return String(process.env.REX_OLLAMA_BASE_URL || 'http://127.0.0.1:11434').replace(/\/+$/, ''); + } + + function rexMaxOutputTokens() { + return Math.max(64, Number(process.env.REX_MAX_OUTPUT_TOKENS || 500) || 500); + } + + function chatModelName(accountId = '') { + const settings = getRexSettingsForAccount(accountId); + const mode = normalizeProviderMode(settings?.providerMode); + const fallback = mode === 'ollama' ? DEFAULT_OLLAMA_MODEL : DEFAULT_REX_MODEL; + return normalizeModel(settings?.model, fallback, mode); } function embeddingModelName() { @@ -735,9 +833,104 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { return Float32Array.from(embedding.map((n) => Number(n) || 0)); } - async function runGatewayChat(messages = [], accountId = '') { - const apiKey = resolveGatewayApiKey(accountId); - if (!apiKey) throw new Error('Gateway key is not configured'); + async function runGatewayChat(messages = [], accountId = '', options = {}) { + const settings = getRexSettingsForAccount(accountId); + const mode = normalizeProviderMode(settings?.providerMode); + const model = chatModelName(accountId); + if (mode === 'ollama') { + const timeoutMs = Math.max( + 5_000, + Math.min(Number(options?.timeoutMs || OLLAMA_CHAT_TIMEOUT_MS) || OLLAMA_CHAT_TIMEOUT_MS, 180_000) + ); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + let res; + try { + res = await fetch(`${ollamaBaseUrl()}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + stream: false, + keep_alive: DEFAULT_OLLAMA_KEEP_ALIVE, + messages, + options: { + temperature: 0.2, + }, + }), + signal: controller.signal, + }); + } catch (err) { + if (isAbortError(err)) { + throw new Error(`Ollama response timed out after ${Math.round(timeoutMs / 1000)}s`); + } + throw err; + } + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(String(payload?.error || payload?.message || `Ollama chat request failed (${res.status})`)); + } + const content = payload?.message?.content || payload?.response || ''; + return typeof content === 'string' ? content.trim() : ''; + } finally { + clearTimeout(timeout); + } + } + const apiKey = mode === 'byok' + ? String(settings?.apiKey || '').trim() + : resolveGatewayApiKey(accountId); + if (!apiKey) { + throw new Error(mode === 'byok' ? 'BYOK API key is not configured' : 'Gateway key is not configured'); + } + const provider = mode === 'byok' ? resolveByokProvider(apiKey) : 'openai'; + if (provider === 'anthropic') { + const system = messages + .filter((item) => item?.role === 'system') + .map((item) => String(item?.content || '').trim()) + .filter(Boolean) + .join('\n\n'); + const anthropicMessages = messages + .filter((item) => item?.role !== 'system') + .map((item) => ({ + role: item?.role === 'assistant' ? 'assistant' : 'user', + content: String(item?.content || ''), + })) + .filter((item) => item.content); + if (!anthropicMessages.length) { + anthropicMessages.push({ + role: 'user', + content: 'Respond to the provided instructions.', + }); + } + const res = await fetch(`${anthropicBaseUrl()}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model, + max_tokens: rexMaxOutputTokens(), + ...(system ? { system } : {}), + messages: anthropicMessages, + }), + }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(payload?.error?.message || `Anthropic request failed (${res.status})`); + } + const content = Array.isArray(payload?.content) + ? payload.content.find((item) => item?.type === 'text')?.text || '' + : ''; + return typeof content === 'string' ? content.trim() : ''; + } + const providerOptions = mode === 'platform' && settings?.enforceZeroDataRetention + ? zeroRetentionProviderOptions(model) + : null; const baseUrl = gatewayBaseUrl(); const res = await fetch(`${baseUrl}/chat/completions`, { method: 'POST', @@ -746,9 +939,11 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ - model: chatModelName(), + model, messages, + max_tokens: rexMaxOutputTokens(), temperature: 0.2, + ...(providerOptions && typeof providerOptions === 'object' ? { providerOptions } : {}), }), }); const payload = await res.json().catch(() => ({})); @@ -759,6 +954,27 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { return typeof content === 'string' ? content.trim() : ''; } + async function listLocalOllamaModels({ timeoutMs = 1800 } = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Math.max(250, Number(timeoutMs) || 1800)); + try { + const res = await fetch(`${ollamaBaseUrl()}/api/tags`, { + method: 'GET', + signal: controller.signal, + }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(String(payload?.error || payload?.message || `Ollama tags request failed (${res.status})`)); + } + const models = Array.isArray(payload?.models) ? payload.models : []; + return Array.from(new Set(models + .map((item) => String(item?.name || item?.model || '').trim()) + .filter(Boolean))).slice(0, 80); + } finally { + clearTimeout(timeout); + } + } + function upsertChunksForMessage(message = {}, { priority = 10 } = {}) { const messageId = String(message?.id || '').trim(); const accountId = String(message?.accountId || '').trim(); @@ -1204,7 +1420,7 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { }); runMailboxTx(); - const SYNC_MAX_THREADS_PER_LABEL = Math.max(10, Math.min(Number(perLabelLimit) || 200, 500)); + const SYNC_MAX_THREADS_PER_LABEL = normalizeSyncPerLabelLimit(perLabelLimit, 200); async function syncLabelThreads(labelId) { let pageToken = null; @@ -1809,6 +2025,7 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { } if (!accountId) accountId = resolveRexAccountId(''); const assistantSettings = getRexSettingsForAccount(accountId); + const assistantMode = normalizeProviderMode(assistantSettings?.providerMode); const assistantSystemPrompt = normalizeSystemPrompt( assistantSettings?.systemPrompt, DEFAULT_REX_SYSTEM_PROMPT @@ -1898,9 +2115,15 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { }, ], accountId); } catch (err) { + const errorMessage = String(err?.message || 'gateway unavailable'); + const reply = assistantMode === 'ollama' + ? `BRAIN BOX could not reach Ollama in Electron. Start Ollama and confirm the configured local model is installed. Error: ${errorMessage}` + : assistantMode === 'byok' + ? `BRAIN BOX could not use the configured BYOK API key in Electron. Check the selected model and key, then try again. Error: ${errorMessage}` + : `BRAIN BOX could not access the AI Gateway key in Electron. Configure one of: REX_PLATFORM_API_KEY, VERCEL_AI_GATEWAY_API_KEY, AI_GATEWAY_API_KEY (or set BYOK key in BRAIN BOX settings). Error: ${errorMessage}`; return { ok: true, - reply: `BRAIN BOX could not access the AI Gateway key in Electron. Configure one of: REX_PLATFORM_API_KEY, VERCEL_AI_GATEWAY_API_KEY, AI_GATEWAY_API_KEY (or set BYOK key in BRAIN BOX settings). Error: ${String(err?.message || 'gateway unavailable')}`, + reply, proposedActions: [], requiresConfirmation: false, context: { @@ -1953,6 +2176,12 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { safeHandle('rex:getSettings', async (_event, { accountId } = {}) => { const settings = getRexSettingsForAccount(accountId); + let availableLocalModels = []; + try { + availableLocalModels = await listLocalOllamaModels(); + } catch { + availableLocalModels = []; + } return { accountId: settings.accountId, enabled: settings.enabled, @@ -1961,6 +2190,10 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { allowEmailAccess: settings.allowEmailAccess, allowCalendarAccess: settings.allowCalendarAccess, systemPrompt: settings.systemPrompt, + model: settings.model, + enforceZeroDataRetention: settings.enforceZeroDataRetention, + availableModels: ZERO_RETENTION_MODELS, + availableLocalModels, }; }); @@ -1977,9 +2210,18 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { upsertAccountRow.run(resolvedAccountId, 'imap', resolvedAccountId, resolvedAccountId); const previous = getRexSettingsForAccount(resolvedAccountId); const providerMode = normalizeProviderMode(settings?.providerMode ?? previous.providerMode); + const modeChanged = providerMode !== previous.providerMode; const apiKeyInput = settings?.apiKey; const nextApiKey = apiKeyInput === undefined ? previous.apiKey : String(apiKeyInput || '').trim(); const nextSystemPrompt = normalizeSystemPrompt(settings?.systemPrompt, previous.systemPrompt); + const modelFallback = providerMode === 'ollama' + ? (modeChanged ? DEFAULT_OLLAMA_MODEL : previous.model) + : (modeChanged ? DEFAULT_REX_MODEL : previous.model); + const nextModel = normalizeModel(settings?.model, modelFallback, providerMode); + const nextZeroRetention = normalizeEnabled( + settings?.enforceZeroDataRetention, + previous.enforceZeroDataRetention + ); upsertRexSettings.run( resolvedAccountId, @@ -1988,10 +2230,18 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { encryptSecret(nextApiKey), normalizeEnabled(settings?.allowEmailAccess, previous.allowEmailAccess) ? 1 : 0, normalizeEnabled(settings?.allowCalendarAccess, previous.allowCalendarAccess) ? 1 : 0, - nextSystemPrompt + nextSystemPrompt, + nextModel, + nextZeroRetention ? 1 : 0 ); const updated = getRexSettingsForAccount(resolvedAccountId); + let availableLocalModels = []; + try { + availableLocalModels = await listLocalOllamaModels(); + } catch { + availableLocalModels = []; + } return { accountId: updated.accountId, enabled: updated.enabled, @@ -2000,16 +2250,25 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { allowEmailAccess: updated.allowEmailAccess, allowCalendarAccess: updated.allowCalendarAccess, systemPrompt: updated.systemPrompt, + model: updated.model, + enforceZeroDataRetention: updated.enforceZeroDataRetention, + availableModels: ZERO_RETENTION_MODELS, + availableLocalModels, }; }); - safeHandle('rex:testConnection', async (_event, { accountId, providerMode, apiKey } = {}) => { + safeHandle('rex:testConnection', async (_event, { accountId, providerMode, apiKey, model } = {}) => { const resolvedAccountId = resolveRexAccountId(accountId); - const mode = normalizeProviderMode(providerMode); + const currentSettings = getRexSettingsForAccount(resolvedAccountId); + const mode = normalizeProviderMode(providerMode ?? currentSettings.providerMode); const key = mode === 'byok' ? String(apiKey || '').trim() : ''; + const selectedModel = normalizeModel(model, currentSettings.model, mode); if (mode === 'byok' && !key) { return { ok: false, message: 'API key is required for BYOK mode' }; } + if (mode === 'ollama' && !selectedModel) { + return { ok: false, message: 'Local Ollama model is required' }; + } const baseUrl = process.env.DYNOBOX_SERVER_BASE_URL || 'http://localhost:3001'; const headerApiKey = process.env.DYNOBOX_API_KEY || ''; @@ -2024,12 +2283,14 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { body: JSON.stringify({ accountId: resolvedAccountId, providerMode: mode, + model: selectedModel, ...(mode === 'byok' ? { apiKey: key } : {}), }), timeoutMs: 20_000, }); if (result?.ok) { if (mode === 'platform') return { ok: true, message: 'Platform AI is available' }; + if (mode === 'ollama') return { ok: true, message: String(result?.message || 'Ollama is available') }; return { ok: true, message: 'API key accepted' }; } return { ok: false, message: result?.message || 'Connection failed' }; @@ -2037,6 +2298,9 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { if (mode === 'platform') { return { ok: false, message: `Platform check failed: ${String(err?.message || 'Request failed')}` }; } + if (mode === 'ollama') { + return { ok: false, message: `Ollama check failed: ${String(err?.message || 'Request failed')}` }; + } return { ok: false, message: `API key test failed: ${String(err?.message || 'Request failed')}` }; } }); @@ -2047,6 +2311,11 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { if (!settings.enabled) { return { ok: false, error: 'BRAIN BOX is disabled in settings' }; } + const mode = normalizeProviderMode(settings.providerMode); + const selectedModel = normalizeModel(payload?.model, settings.model, mode); + const enforceZeroDataRetention = mode === 'platform' + ? normalizeEnabled(payload?.enforceZeroDataRetention, settings.enforceZeroDataRetention) + : settings.enforceZeroDataRetention; const baseUrl = process.env.DYNOBOX_SERVER_BASE_URL || 'http://localhost:3001'; const headerApiKey = process.env.DYNOBOX_API_KEY || ''; @@ -2054,11 +2323,13 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { message: String(payload?.message || '').trim(), history: clampHistory(payload?.history, 16), attachments: clampAttachments(payload?.attachments, 4), - mode: settings.providerMode, - byokApiKey: settings.providerMode === 'byok' ? settings.apiKey : '', + mode, + byokApiKey: mode === 'byok' ? settings.apiKey : '', allowEmailAccess: settings.allowEmailAccess, allowCalendarAccess: settings.allowCalendarAccess, systemPrompt: normalizeSystemPrompt(payload?.systemPrompt, settings.systemPrompt), + model: selectedModel, + enforceZeroDataRetention, }; if (!body.message) return { ok: false, error: 'Message is required' }; @@ -2099,6 +2370,10 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { if (!settings.enabled) { return { ok: false, error: 'BRAIN BOX is disabled in settings' }; } + const mode = normalizeProviderMode(settings.providerMode); + if (mode === 'ollama') { + return { ok: false, error: 'Audio transcription is not available for Ollama local mode' }; + } const audioBase64 = String(payload?.audioBase64 || '').trim(); if (!audioBase64) return { ok: false, error: 'audioBase64 is required' }; @@ -2110,8 +2385,8 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { mimeType: String(payload?.mimeType || 'audio/webm'), filename: String(payload?.filename || 'recording.webm'), language: String(payload?.language || '').trim(), - mode: settings.providerMode, - byokApiKey: settings.providerMode === 'byok' ? settings.apiKey : '', + mode, + byokApiKey: mode === 'byok' ? settings.apiKey : '', }; const controller = new AbortController(); @@ -2151,7 +2426,7 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { const id = String(accountId || '').trim(); const normalizedLabelIds = normalizeSyncLabelIds(labelIds); const effectiveLabelIds = normalizedLabelIds.length > 0 ? normalizedLabelIds : DEFAULT_SYNC_LABEL_IDS; - const effectivePerLabelLimit = clampLimit(perLabelLimit, DEFAULT_SYNC_PER_LABEL_LIMIT, 200); + const effectivePerLabelLimit = normalizeSyncPerLabelLimit(perLabelLimit, DEFAULT_SYNC_PER_LABEL_LIMIT); const trySync = async (urlBase, acctId, { force = false, labelIds = DEFAULT_SYNC_LABEL_IDS, perLabelLimit = DEFAULT_SYNC_PER_LABEL_LIMIT } = {}) => { const now = Date.now(); @@ -2178,7 +2453,7 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { baseUrl: urlBase, accountId: acctId, labelIds: normalizeSyncLabelIds(labelIds), - perLabelLimit: clampLimit(perLabelLimit, DEFAULT_SYNC_PER_LABEL_LIMIT, 200), + perLabelLimit: normalizeSyncPerLabelLimit(perLabelLimit, DEFAULT_SYNC_PER_LABEL_LIMIT), }); syncInFlightByAccount.set(acctId, syncPromise); @@ -2257,7 +2532,7 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { } }); - safeHandle('sync:labelNow', async (_event, { accountId, labelId } = {}) => { + safeHandle('sync:labelNow', async (_event, { accountId, labelId, perLabelLimit } = {}) => { const baseUrl = process.env.DYNOBOX_SERVER_BASE_URL || 'http://localhost:3001'; const id = String(accountId || '').trim(); const label = String(labelId || '').trim(); @@ -2275,7 +2550,7 @@ function registerMailCoreIpcHandlers({ db, dbClient }) { baseUrl, accountId: id, labelIds: [label], - perLabelLimit: DEFAULT_SYNC_PER_LABEL_LIMIT, + perLabelLimit: normalizeSyncPerLabelLimit(perLabelLimit, DEFAULT_SYNC_PER_LABEL_LIMIT), }); syncQuotaBackoffUntilByAccount.delete(id); emitSyncChanged({ accountId: id, reason: 'label-synced', labelId: label }); diff --git a/electron/main.ts b/electron/main.ts index 5363912..da72f39 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -10,8 +10,11 @@ let mainWindow = null; const composeWindows = new Set(); let assistantWindow = null; const APP_DISPLAY_NAME = 'DYNO BOX'; -const APP_ICON_1024 = path.join(__dirname, '../electron/assets/dynobox-icon-1024.png'); -const APP_ICON_512 = path.join(__dirname, '../electron/assets/dynobox-icon-512.png'); +const ASSETS_DIR = path.join(__dirname, '../electron/assets'); +const WINDOWS_ICON_PATH = path.join(ASSETS_DIR, 'windows/icon.ico'); +const LINUX_ICON_PATH = path.join(ASSETS_DIR, 'linux/icons/512x512.png'); +const MACOS_ICON_PATH = path.join(ASSETS_DIR, 'macos/icon.icns'); +const MACOS_DOCK_ICON_PATH = path.join(ASSETS_DIR, 'macos/512x512.png'); function pickExistingFile(...candidates) { for (const candidate of candidates) { @@ -24,8 +27,21 @@ function pickExistingFile(...candidates) { return undefined; } -const WINDOW_ICON_PATH = pickExistingFile(APP_ICON_512, APP_ICON_1024); -const DOCK_ICON_PATH = pickExistingFile(APP_ICON_1024, APP_ICON_512); +function getWindowIconPath() { + if (process.platform === 'win32') { + return pickExistingFile(WINDOWS_ICON_PATH); + } + if (process.platform === 'linux') { + return pickExistingFile(LINUX_ICON_PATH); + } + if (process.platform === 'darwin') { + return pickExistingFile(MACOS_ICON_PATH, MACOS_DOCK_ICON_PATH); + } + return pickExistingFile(LINUX_ICON_PATH, WINDOWS_ICON_PATH, MACOS_DOCK_ICON_PATH); +} + +const WINDOW_ICON_PATH = getWindowIconPath(); +const DOCK_ICON_PATH = pickExistingFile(MACOS_DOCK_ICON_PATH, MACOS_ICON_PATH); try { app.setName(APP_DISPLAY_NAME); diff --git a/package-lock.json b/package-lock.json index 5b1bc73..5db5f93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "dynobox", "version": "1.0.0", + "license": "Apache-2.0", "dependencies": { "better-sqlite3": "^12.6.2", "drizzle-orm": "^0.45.1" @@ -16,6 +17,10 @@ "electron": "^36.2.0", "electron-rebuild": "^3.2.9", "wait-on": "^8.0.3" + }, + "engines": { + "node": ">=20", + "npm": ">=10" } }, "node_modules/@babel/runtime": { diff --git a/package.json b/package.json index 5f35cf9..29e33e3 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,35 @@ "productName": "DYNO BOX", "version": "1.0.0", "description": "Personal Gmail client — keyboard-first, Superhuman-style", + "license": "Apache-2.0", + "author": "Deprogram Software", + "homepage": "https://github.com/deprogram-software/dynomail#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/deprogram-software/dynomail.git" + }, + "bugs": { + "url": "https://github.com/deprogram-software/dynomail/issues" + }, + "keywords": [ + "email", + "gmail", + "imap", + "smtp", + "electron", + "react", + "ai" + ], "main": "electron-build/main.js", "scripts": { "dev": "concurrently --kill-others-on-fail -n server,client -c cyan,yellow \"npm run dev --prefix server\" \"npm run dev --prefix client\"", "predev:desktop": "node scripts/free-port.mjs 3001", + "lint": "npm run lint --prefix client", + "build": "npm run build:desktop", "build:electron": "node scripts/build-runtime-from-ts.mjs electron", "build:server": "node scripts/build-runtime-from-ts.mjs server", "build:runtime": "node scripts/build-runtime-from-ts.mjs all", + "check": "npm run lint && npm run build --prefix client && npm run build:runtime", "dev:desktop": "concurrently --kill-others-on-fail -n server,client,electron -c cyan,yellow,green \"npm run dev --prefix server\" \"npm run dev --prefix client\" \"npm run build:electron && wait-on tcp:3001 tcp:5173 && env -u NODE_OPTIONS -u ELECTRON_RUN_AS_NODE DYNOBOX_SUPPRESS_CHROMIUM_WARNINGS=${DYNOBOX_SUPPRESS_CHROMIUM_WARNINGS:-1} electron .\"", "build:desktop": "npm run build --prefix client && npm run build:runtime", "prestart:desktop": "node scripts/free-port.mjs 3001", @@ -17,6 +39,10 @@ "server": "npm run dev --prefix server", "client": "npm run dev --prefix client" }, + "engines": { + "node": ">=20", + "npm": ">=10" + }, "devDependencies": { "concurrently": "^8.2.2", "electron": "^36.2.0", diff --git a/server/.env.example b/server/.env.example index 0b6e277..ce97cbf 100644 --- a/server/.env.example +++ b/server/.env.example @@ -7,11 +7,19 @@ CLIENT_URL=http://localhost:5173 REX_PLATFORM_API_KEY=optional_platform_provider_key VERCEL_AI_GATEWAY_API_KEY=optional_platform_provider_key AI_GATEWAY_API_KEY=optional_platform_provider_key + # REX_GATEWAY_PROVIDER=openai # REX_MODEL=google/gemini-2.5-flash-lite # REX_MAX_OUTPUT_TOKENS=500 # REX_GATEWAY_BASE_URL=https://ai-gateway.vercel.sh/v1 # REX_ANTHROPIC_BASE_URL=https://api.anthropic.com +# REX_OLLAMA_BASE_URL=http://127.0.0.1:11434 +# REX_OLLAMA_MODEL=llama3.2:latest +# REX_OLLAMA_KEEP_ALIVE=30m +# REX_OLLAMA_CHAT_TIMEOUT_MS=35000 +# REX_OLLAMA_CONTEXT_TIMEOUT_MS=5000 +# REX_EMBEDDING_MODEL=text-embedding-3-small +# REX_TRANSCRIBE_MODEL=gpt-4o-mini-transcribe # REX_SETTINGS_ENCRYPTION_KEY=long_random_secret_for_rex_settings # Optional hardening: @@ -19,3 +27,15 @@ AI_GATEWAY_API_KEY=optional_platform_provider_key # DYNOBOX_API_KEY=shared_secret_for_local_api_requests # ALLOW_REMOTE_CLIENT=false # TRUST_PROXY=false + +# Optional Electron/desktop runtime: +# ELECTRON_START_URL=http://localhost:5173 +# DYNOBOX_SERVER_BASE_URL=http://localhost:3001 +# DYNOBOX_SUPPRESS_CHROMIUM_WARNINGS=true +# DYNOBOX_DISABLE_HARDWARE_ACCELERATION=false + +# Optional local data paths: +# DYNOBOX_SERVER_DATA_DIR=/absolute/path/for/local/runtime-data +# DYNOBOX_TOKENS_FILE=/absolute/path/tokens.json +# DYNOBOX_MCP_SETTINGS_FILE=/absolute/path/mcp-settings.json +# DYNOBOX_REX_SETTINGS_FILE=/absolute/path/rex-settings.json diff --git a/server/index.ts b/server/index.ts index a9f33cb..8bddac5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,6 +8,7 @@ import gmailRouter from './routes/gmail.js'; import mailRouter from './routes/mail.js'; import rexRouter from './routes/rex.js'; import mcpRouter from './routes/mcp.js'; +import { getMcpSettings } from './lib/mcpSettings.js'; dotenv.config(); @@ -94,9 +95,14 @@ app.use('/api/mcp', mcpRouter); app.get('/health', (req, res) => res.json({ status: 'ok', app: 'dynobox' })); app.listen(PORT, () => { + const mcpSettings = getMcpSettings(); console.log(`\n ⚡ dynobox server running on http://localhost:${PORT}`); console.log(` → Auth: http://localhost:${PORT}/auth`); console.log(` → API: http://localhost:${PORT}/api/gmail/threads\n`); - console.log(` → MCP: http://localhost:${PORT}/api/mcp`); - console.log(` → MCP meta: http://localhost:${PORT}/api/mcp/metadata\n`); + if (mcpSettings.enabled) { + console.log(` → MCP: http://localhost:${PORT}/api/mcp`); + console.log(` → MCP meta: http://localhost:${PORT}/api/mcp/metadata\n`); + } else { + console.log(' → MCP: disabled in settings\n'); + } }); diff --git a/server/lib/mcpSettings.ts b/server/lib/mcpSettings.ts new file mode 100644 index 0000000..6430219 --- /dev/null +++ b/server/lib/mcpSettings.ts @@ -0,0 +1,86 @@ +// @ts-nocheck +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const runtimeRootDir = path.resolve(__dirname, '..'); + +function resolveDataRootDir() { + const explicitDir = String(process.env.DYNOBOX_SERVER_DATA_DIR || '').trim(); + if (explicitDir) return explicitDir; + if (path.basename(runtimeRootDir) === '.build') { + return path.resolve(runtimeRootDir, '..'); + } + return runtimeRootDir; +} + +const dataRootDir = resolveDataRootDir(); +const SETTINGS_FILE = String(process.env.DYNOBOX_MCP_SETTINGS_FILE || '').trim() + || path.join(dataRootDir, '.mcp-settings.json'); +const LEGACY_SETTINGS_FILE = path.join(runtimeRootDir, '.mcp-settings.json'); +const HOME_FALLBACK_SETTINGS_FILE = path.join(os.homedir(), '.dynobox', 'mcp-settings.json'); +const DEFAULT_SETTINGS = { + enabled: true, +}; + +function normalizeEnabled(value, fallback = true) { + if (value === undefined || value === null) return Boolean(fallback); + return Boolean(value); +} + +function normalizeStore(store = {}) { + return { + enabled: normalizeEnabled(store?.enabled, DEFAULT_SETTINGS.enabled), + updatedAt: Number(store?.updatedAt || 0) || Date.now(), + }; +} + +function loadStore() { + try { + const candidates = [ + SETTINGS_FILE, + LEGACY_SETTINGS_FILE !== SETTINGS_FILE ? LEGACY_SETTINGS_FILE : '', + HOME_FALLBACK_SETTINGS_FILE !== SETTINGS_FILE ? HOME_FALLBACK_SETTINGS_FILE : '', + ].filter(Boolean); + + for (const filePath of candidates) { + if (!fs.existsSync(filePath)) continue; + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + const normalized = normalizeStore(parsed); + if (filePath !== SETTINGS_FILE) saveStore(normalized); + return normalized; + } + } catch { + // Fall through to defaults. + } + return normalizeStore(DEFAULT_SETTINGS); +} + +function saveStore(nextStore) { + fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true }); + const tmp = `${SETTINGS_FILE}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(normalizeStore(nextStore), null, 2), { mode: 0o600 }); + fs.renameSync(tmp, SETTINGS_FILE); + fs.chmodSync(SETTINGS_FILE, 0o600); +} + +export function getMcpSettings() { + return normalizeStore(loadStore()); +} + +export function updateMcpSettings(input = {}) { + const current = getMcpSettings(); + const next = { + enabled: normalizeEnabled(input?.enabled, current.enabled), + updatedAt: Date.now(), + }; + saveStore(next); + return getMcpSettings(); +} + +export function isMcpEnabled() { + return getMcpSettings().enabled !== false; +} diff --git a/server/lib/rex/gateway.ts b/server/lib/rex/gateway.ts index 865f0a5..277bb6f 100644 --- a/server/lib/rex/gateway.ts +++ b/server/lib/rex/gateway.ts @@ -5,9 +5,24 @@ function normalizeProvider(value = '') { return 'openai'; } +function normalizeMode(value = '') { + const mode = String(value || '').trim().toLowerCase(); + if (mode === 'byok') return 'byok'; + if (mode === 'ollama') return 'ollama'; + return 'platform'; +} + +const DEFAULT_CHAT_MODEL = 'google/gemini-3.1-flash-lite-preview'; +const DEFAULT_OLLAMA_MODEL = String(process.env.REX_OLLAMA_MODEL || '').trim() || 'llama3.2:latest'; +const DEFAULT_OLLAMA_KEEP_ALIVE = String(process.env.REX_OLLAMA_KEEP_ALIVE || '30m').trim() || '30m'; + function resolveModeAndKey({ mode, byokApiKey = '' }) { + const normalizedMode = normalizeMode(mode); + if (normalizedMode === 'ollama') { + return { provider: 'ollama', apiKey: '' }; + } const defaultProvider = normalizeProvider(process.env.REX_GATEWAY_PROVIDER || 'openai'); - const isByok = String(mode || '').toLowerCase() === 'byok'; + const isByok = normalizedMode === 'byok'; const apiKey = isByok ? String(byokApiKey || '').trim() : resolvePlatformApiKey(); @@ -33,6 +48,17 @@ function normalizeUsage(usage = {}) { }; } +function normalizeOllamaUsage(payload = {}) { + const inputTokens = Number(payload?.prompt_eval_count || 0) || 0; + const outputTokens = Number(payload?.eval_count || 0) || 0; + if (!inputTokens && !outputTokens) return null; + return { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + }; +} + function extractTextFromContent(content) { if (typeof content === 'string') return content; if (Array.isArray(content)) { @@ -76,6 +102,147 @@ function extractOpenAIText(payload = {}) { return ''; } +function resolveOllamaBaseUrl() { + return String(process.env.REX_OLLAMA_BASE_URL || 'http://127.0.0.1:11434').replace(/\/+$/, ''); +} + +function resolveOllamaModel(model = '') { + const explicit = String(model || '').trim(); + if (explicit) return explicit.slice(0, 160); + return DEFAULT_OLLAMA_MODEL; +} + +async function callOllamaChat({ prompt, model, signal } = {}) { + const selectedModel = resolveOllamaModel(model); + const baseUrl = resolveOllamaBaseUrl(); + const res = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: selectedModel, + stream: false, + keep_alive: DEFAULT_OLLAMA_KEEP_ALIVE, + messages: [{ role: 'user', content: String(prompt || '') }], + options: { + temperature: 0.2, + }, + }), + ...(signal ? { signal } : {}), + }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(String(payload?.error || payload?.message || `Ollama request failed (${res.status})`)); + } + const text = extractTextFromContent(payload?.message?.content || payload?.response || ''); + return { + text: String(text || '').trim(), + usage: normalizeOllamaUsage(payload), + rawModel: String(payload?.model || selectedModel), + }; +} + +function tryParseJsonLine(raw = '') { + const text = String(raw || '').trim(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +async function streamOllamaChat({ prompt, model, signal, onToken = () => {} }) { + const selectedModel = resolveOllamaModel(model); + const baseUrl = resolveOllamaBaseUrl(); + const res = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: selectedModel, + stream: true, + keep_alive: DEFAULT_OLLAMA_KEEP_ALIVE, + messages: [{ role: 'user', content: String(prompt || '') }], + options: { + temperature: 0.2, + }, + }), + ...(signal ? { signal } : {}), + }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(String(payload?.error || payload?.message || `Ollama stream request failed (${res.status})`)); + } + if (!res.body) { + throw new Error('Stream response body is missing'); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let fullText = ''; + let lastPayload = null; + + const processLine = (line = '') => { + const parsed = tryParseJsonLine(line); + if (!parsed) return; + if (parsed?.error) { + throw new Error(String(parsed.error)); + } + lastPayload = parsed; + const delta = extractTextFromContent(parsed?.message?.content || parsed?.response || ''); + if (!delta) return; + fullText += delta; + onToken(delta); + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + processLine(line); + } + } + const trailing = buffer.trim(); + if (trailing) { + processLine(trailing); + } + + return { + text: String(fullText || '').trim(), + usage: normalizeOllamaUsage(lastPayload || {}), + rawModel: String(lastPayload?.model || selectedModel), + }; +} + +export async function listOllamaModels({ timeoutMs = 1800 } = {}) { + const baseUrl = resolveOllamaBaseUrl(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Math.max(250, Number(timeoutMs) || 1800)); + try { + const res = await fetch(`${baseUrl}/api/tags`, { + method: 'GET', + signal: controller.signal, + }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(String(payload?.error || payload?.message || `Ollama tags request failed (${res.status})`)); + } + const list = Array.isArray(payload?.models) ? payload.models : []; + return Array.from(new Set(list + .map((item) => String(item?.name || item?.model || '').trim()) + .filter(Boolean))).slice(0, 80); + } finally { + clearTimeout(timeout); + } +} + export function resolvePlatformApiKey() { return String( process.env.REX_PLATFORM_API_KEY @@ -85,20 +252,23 @@ export function resolvePlatformApiKey() { ).trim(); } -async function callOpenAIGateway({ apiKey, prompt, model }) { +async function callOpenAIGateway({ apiKey, prompt, model, providerOptions = null, signal } = {}) { const baseUrl = String(process.env.REX_GATEWAY_BASE_URL || 'https://ai-gateway.vercel.sh/v1').replace(/\/+$/, ''); + const requestPayload = { + model, + messages: [{ role: 'user', content: prompt }], + max_tokens: Number(process.env.REX_MAX_OUTPUT_TOKENS || 500), + temperature: 0.2, + ...(providerOptions && typeof providerOptions === 'object' ? { providerOptions } : {}), + }; const res = await fetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, - body: JSON.stringify({ - model, - messages: [{ role: 'user', content: prompt }], - max_tokens: Number(process.env.REX_MAX_OUTPUT_TOKENS || 500), - temperature: 0.2, - }), + body: JSON.stringify(requestPayload), + ...(signal ? { signal } : {}), }); const payload = await res.json().catch(() => ({})); if (!res.ok) { @@ -112,7 +282,7 @@ async function callOpenAIGateway({ apiKey, prompt, model }) { }; } -async function callAnthropicGateway({ apiKey, prompt, model }) { +async function callAnthropicGateway({ apiKey, prompt, model, signal } = {}) { const baseUrl = String(process.env.REX_ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/+$/, ''); const res = await fetch(`${baseUrl}/v1/messages`, { method: 'POST', @@ -126,6 +296,7 @@ async function callAnthropicGateway({ apiKey, prompt, model }) { max_tokens: Number(process.env.REX_MAX_OUTPUT_TOKENS || 500), messages: [{ role: 'user', content: prompt }], }), + ...(signal ? { signal } : {}), }); const payload = await res.json().catch(() => ({})); if (!res.ok) { @@ -141,22 +312,82 @@ async function callAnthropicGateway({ apiKey, prompt, model }) { }; } -export async function generateRexResponse({ prompt, mode, byokApiKey = '' }) { - const model = String(process.env.REX_MODEL || 'google/gemini-2.5-flash-lite'); - const { provider, apiKey } = resolveModeAndKey({ mode, byokApiKey }); +function zeroRetentionProviderOptions(model = '') { + const provider = String(model || '').trim().toLowerCase().split('/')[0]; if (provider === 'anthropic') { - const result = await callAnthropicGateway({ apiKey, prompt, model }); + return { + anthropic: { + cacheControl: { type: 'ephemeral' }, + }, + }; + } + if (provider === 'google') { + return { + google: { + thinkingConfig: { thinkingBudget: 0 }, + }, + }; + } + return null; +} + +export async function generateRexResponse({ + prompt, + mode, + byokApiKey = '', + model = '', + signal = null, + enforceZeroDataRetention = true, +}) { + const normalizedMode = normalizeMode(mode); + const selectedModel = normalizedMode === 'ollama' + ? resolveOllamaModel(model) + : String(model || process.env.REX_MODEL || DEFAULT_CHAT_MODEL).trim(); + if (normalizedMode === 'ollama') { + const result = await callOllamaChat({ prompt, model: selectedModel, signal }); + return { ...result, provider: 'ollama' }; + } + const { provider, apiKey } = resolveModeAndKey({ mode: normalizedMode, byokApiKey }); + const providerOptions = normalizedMode === 'platform' && enforceZeroDataRetention + ? zeroRetentionProviderOptions(selectedModel) + : null; + if (provider === 'anthropic') { + const result = await callAnthropicGateway({ apiKey, prompt, model: selectedModel, signal }); return { ...result, provider }; } - const result = await callOpenAIGateway({ apiKey, prompt, model }); + const result = await callOpenAIGateway({ + apiKey, + prompt, + model: selectedModel, + providerOptions, + signal, + }); return { ...result, provider: 'openai' }; } -export async function streamRexResponse({ prompt, mode, byokApiKey = '', onToken = () => {} }) { - const model = String(process.env.REX_MODEL || 'google/gemini-2.5-flash-lite'); - const { provider, apiKey } = resolveModeAndKey({ mode, byokApiKey }); +export async function streamRexResponse({ + prompt, + mode, + byokApiKey = '', + model = '', + signal = null, + enforceZeroDataRetention = true, + onToken = () => {}, +}) { + const normalizedMode = normalizeMode(mode); + const selectedModel = normalizedMode === 'ollama' + ? resolveOllamaModel(model) + : String(model || process.env.REX_MODEL || DEFAULT_CHAT_MODEL).trim(); + if (normalizedMode === 'ollama') { + const result = await streamOllamaChat({ prompt, model: selectedModel, signal, onToken }); + return { ...result, provider: 'ollama' }; + } + const { provider, apiKey } = resolveModeAndKey({ mode: normalizedMode, byokApiKey }); + const providerOptions = normalizedMode === 'platform' && enforceZeroDataRetention + ? zeroRetentionProviderOptions(selectedModel) + : null; if (provider === 'anthropic') { - const full = await callAnthropicGateway({ apiKey, prompt, model }); + const full = await callAnthropicGateway({ apiKey, prompt, model: selectedModel, signal }); if (full.text) onToken(full.text); return { ...full, provider }; } @@ -169,12 +400,14 @@ export async function streamRexResponse({ prompt, mode, byokApiKey = '', onToken Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ - model, + model: selectedModel, messages: [{ role: 'user', content: prompt }], max_tokens: Number(process.env.REX_MAX_OUTPUT_TOKENS || 500), temperature: 0.2, stream: true, + ...(providerOptions && typeof providerOptions === 'object' ? { providerOptions } : {}), }), + ...(signal ? { signal } : {}), }); if (!res.ok) { const payload = await res.json().catch(() => ({})); @@ -214,7 +447,7 @@ export async function streamRexResponse({ prompt, mode, byokApiKey = '', onToken return { text: String(fullText || '').trim(), usage: null, - rawModel: model, + rawModel: selectedModel, provider: 'openai', }; } @@ -227,7 +460,10 @@ export async function transcribeRexAudio({ filename = 'recording.webm', language = '', }) { - const { provider, apiKey } = resolveModeAndKey({ mode, byokApiKey }); + const { provider, apiKey } = resolveModeAndKey({ mode: normalizeMode(mode), byokApiKey }); + if (provider === 'ollama') { + throw new Error('Audio transcription is not supported for Ollama local mode'); + } if (provider === 'anthropic') { throw new Error('Audio transcription is not supported for Anthropic BYOK keys'); } diff --git a/server/package-lock.json b/server/package-lock.json index 8e82596..53e9344 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "dynobox-server", "version": "1.0.0", + "license": "Apache-2.0", "dependencies": { "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -19,6 +20,10 @@ }, "devDependencies": { "nodemon": "^3.1.4" + }, + "engines": { + "node": ">=20", + "npm": ">=10" } }, "node_modules/@pinojs/redact": { diff --git a/server/package.json b/server/package.json index 9f380ef..dc003b0 100644 --- a/server/package.json +++ b/server/package.json @@ -2,6 +2,8 @@ "name": "dynobox-server", "version": "1.0.0", "description": "Dynobox backend server", + "private": true, + "license": "Apache-2.0", "main": "index.ts", "type": "module", "scripts": { @@ -9,6 +11,10 @@ "start": "npm run build && node .build/index.js", "dev": "nodemon --watch . --ext ts --exec \"npm run build && node .build/index.js\"" }, + "engines": { + "node": ">=20", + "npm": ">=10" + }, "dependencies": { "cors": "^2.8.5", "dotenv": "^16.4.5", diff --git a/server/routes/mcp.ts b/server/routes/mcp.ts index 3579331..c956312 100644 --- a/server/routes/mcp.ts +++ b/server/routes/mcp.ts @@ -1,6 +1,7 @@ // @ts-nocheck import express from 'express'; import { getAccount, listAccounts } from '../auth/oauth.js'; +import { getMcpSettings, updateMcpSettings } from '../lib/mcpSettings.js'; const router = express.Router(); @@ -561,6 +562,24 @@ function mcpMetadata(req) { }; } +router.get('/settings', (req, res) => { + res.json(getMcpSettings()); +}); + +router.post('/settings', (req, res) => { + try { + res.json(updateMcpSettings(req.body || {})); + } catch (err) { + res.status(500).json({ error: String(err?.message || 'Failed to update MCP settings') }); + } +}); + +router.use((req, res, next) => { + const settings = getMcpSettings(); + if (settings.enabled) return next(); + return res.status(403).json({ error: 'MCP server is disabled in settings' }); +}); + router.get('/metadata', (req, res) => { res.json(mcpMetadata(req)); }); diff --git a/server/routes/rex.ts b/server/routes/rex.ts index 9575bf9..de4ee6e 100644 --- a/server/routes/rex.ts +++ b/server/routes/rex.ts @@ -8,7 +8,13 @@ import { google } from 'googleapis'; import { fileURLToPath } from 'url'; import { getAccount, getAuthenticatedClient, listAccounts } from '../auth/oauth.js'; import { listManualThreads } from '../lib/manualMail.js'; -import { generateRexResponse, resolvePlatformApiKey, streamRexResponse, transcribeRexAudio } from '../lib/rex/gateway.js'; +import { + generateRexResponse, + listOllamaModels, + resolvePlatformApiKey, + streamRexResponse, + transcribeRexAudio, +} from '../lib/rex/gateway.js'; const router = express.Router(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -35,6 +41,21 @@ const DEFAULT_REX_SYSTEM_PROMPT = [ 'You can also handle inbox and calendar tasks such as triage, prioritization, drafting, and planning.', 'Be concise and actionable. Never claim you sent or changed anything.', ].join(' '); +const ZERO_RETENTION_MODELS = [ + 'google/gemini-3.1-flash-lite-preview', + 'anthropic/claude-haiku-4.5', + 'anthropic/claude-sonnet-4.5', + 'google/gemini-2.5-flash-lite', + 'google/gemini-2.5-pro', +]; +const DEFAULT_REX_MODEL = ZERO_RETENTION_MODELS.includes(String(process.env.REX_MODEL || '').trim()) + ? String(process.env.REX_MODEL || '').trim() + : 'google/gemini-3.1-flash-lite-preview'; +const DEFAULT_OLLAMA_MODEL = String(process.env.REX_OLLAMA_MODEL || '').trim() || 'llama3.2:latest'; +const OLLAMA_CONTEXT_TIMEOUT_MS = Math.max( + 300, + Math.min(Number(process.env.REX_OLLAMA_CONTEXT_TIMEOUT_MS || 5000) || 5000, 15_000) +); const DEFAULT_SETTINGS = { enabled: true, providerMode: 'platform', @@ -42,6 +63,8 @@ const DEFAULT_SETTINGS = { allowEmailAccess: true, allowCalendarAccess: true, systemPrompt: DEFAULT_REX_SYSTEM_PROMPT, + model: DEFAULT_REX_MODEL, + enforceZeroDataRetention: true, }; function getEncryptionKey() { @@ -122,7 +145,10 @@ function saveStore(nextStore) { } function normalizeProviderMode(mode) { - return String(mode || '').toLowerCase() === 'byok' ? 'byok' : 'platform'; + const normalized = String(mode || '').toLowerCase(); + if (normalized === 'byok') return 'byok'; + if (normalized === 'ollama') return 'ollama'; + return 'platform'; } function normalizeBoolean(value, fallback) { @@ -137,6 +163,18 @@ function normalizeSystemPrompt(value, fallback = DEFAULT_REX_SYSTEM_PROMPT) { return trimmed.slice(0, 12_000); } +function normalizeModel(value, fallback = DEFAULT_REX_MODEL, providerMode = 'platform') { + const mode = normalizeProviderMode(providerMode); + const model = String(value || '').trim(); + if (mode === 'ollama') { + if (!model) return String(fallback || DEFAULT_OLLAMA_MODEL).slice(0, 160); + return model.slice(0, 160); + } + if (!model) return String(fallback || DEFAULT_REX_MODEL); + if (ZERO_RETENTION_MODELS.includes(model)) return model; + return String(fallback || DEFAULT_REX_MODEL); +} + function resolveAccountId(req, preferred = '') { const byHeader = Array.isArray(req.headers['x-account-id']) ? req.headers['x-account-id'][0] @@ -153,14 +191,18 @@ function resolveAccountId(req, preferred = '') { function getSettingsForAccount(accountId) { const store = loadStore(); const raw = store.accounts?.[accountId] || {}; + const providerMode = normalizeProviderMode(raw.providerMode || DEFAULT_SETTINGS.providerMode); + const defaultModel = providerMode === 'ollama' ? DEFAULT_OLLAMA_MODEL : DEFAULT_SETTINGS.model; const merged = { ...DEFAULT_SETTINGS, ...raw, - providerMode: normalizeProviderMode(raw.providerMode || DEFAULT_SETTINGS.providerMode), + providerMode, allowEmailAccess: normalizeBoolean(raw.allowEmailAccess, true), allowCalendarAccess: normalizeBoolean(raw.allowCalendarAccess, true), enabled: normalizeBoolean(raw.enabled, true), systemPrompt: normalizeSystemPrompt(raw.systemPrompt, DEFAULT_SETTINGS.systemPrompt), + model: normalizeModel(raw.model, defaultModel, providerMode), + enforceZeroDataRetention: normalizeBoolean(raw.enforceZeroDataRetention, true), }; const apiKey = decryptValue(merged.apiKeyEncrypted || ''); return { @@ -170,6 +212,8 @@ function getSettingsForAccount(accountId) { allowEmailAccess: merged.allowEmailAccess, allowCalendarAccess: merged.allowCalendarAccess, systemPrompt: merged.systemPrompt, + model: merged.model, + enforceZeroDataRetention: merged.enforceZeroDataRetention, apiKeyConfigured: Boolean(apiKey), apiKey, }; @@ -178,8 +222,13 @@ function getSettingsForAccount(accountId) { function updateSettingsForAccount(accountId, input = {}) { const current = getSettingsForAccount(accountId); const providerMode = normalizeProviderMode(input.providerMode ?? current.providerMode); + const modeChanged = providerMode !== current.providerMode; const nextApiKey = input.apiKey === undefined ? current.apiKey : String(input.apiKey || '').trim(); const systemPrompt = normalizeSystemPrompt(input.systemPrompt, current.systemPrompt); + const modelFallback = providerMode === 'ollama' + ? (modeChanged ? DEFAULT_OLLAMA_MODEL : current.model) + : (modeChanged ? DEFAULT_REX_MODEL : current.model); + const model = normalizeModel(input.model, modelFallback, providerMode); const next = { enabled: normalizeBoolean(input.enabled, current.enabled), providerMode, @@ -187,6 +236,8 @@ function updateSettingsForAccount(accountId, input = {}) { allowEmailAccess: normalizeBoolean(input.allowEmailAccess, current.allowEmailAccess), allowCalendarAccess: normalizeBoolean(input.allowCalendarAccess, current.allowCalendarAccess), systemPrompt, + model, + enforceZeroDataRetention: normalizeBoolean(input.enforceZeroDataRetention, current.enforceZeroDataRetention), updatedAt: Date.now(), }; const store = loadStore(); @@ -311,6 +362,75 @@ async function fetchCalendarContext(accountId, maxEvents = 6) { } } +function withTimeout(task, timeoutMs, fallbackValue = []) { + const timeout = Math.max(1, Number(timeoutMs || 0)); + if (!Number.isFinite(timeout) || timeout <= 0) { + return Promise.resolve().then(() => task()).catch(() => fallbackValue); + } + return new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve(fallbackValue); + }, timeout); + Promise.resolve() + .then(() => task()) + .then((value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }) + .catch(() => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(fallbackValue); + }); + }); +} + +async function loadContextForChat({ + accountId, + mode = 'platform', + allowEmailAccess = true, + allowCalendarAccess = true, + requestIntent = {}, +}) { + const unreadOnly = Boolean(requestIntent?.unreadOnly); + const isOllama = normalizeProviderMode(mode) === 'ollama'; + const emailMaxThreads = unreadOnly + ? (isOllama ? 8 : 12) + : (isOllama ? 6 : 8); + const calendarMaxEvents = isOllama ? 4 : 6; + + const emailTask = allowEmailAccess + ? () => fetchEmailContext(accountId, { maxThreads: emailMaxThreads, unreadOnly }) + : null; + const calendarTask = allowCalendarAccess + ? () => fetchCalendarContext(accountId, calendarMaxEvents) + : null; + + const [emailContext, calendarContext] = await Promise.all([ + emailTask + ? (isOllama + ? withTimeout(emailTask, OLLAMA_CONTEXT_TIMEOUT_MS, []) + : emailTask().catch(() => [])) + : Promise.resolve([]), + calendarTask + ? (isOllama + ? withTimeout(calendarTask, Math.max(300, Math.round(OLLAMA_CONTEXT_TIMEOUT_MS * 0.75)), []) + : calendarTask().catch(() => [])) + : Promise.resolve([]), + ]); + + return { + emailContext: Array.isArray(emailContext) ? emailContext : [], + calendarContext: Array.isArray(calendarContext) ? calendarContext : [], + }; +} + function summarizeContext(emailItems = [], calendarItems = []) { const emailLines = emailItems.slice(0, 5).map((item, idx) => `${idx + 1}. ${item.isUnread ? '[Unread] ' : ''}${item.subject} — ${item.from || 'unknown sender'}` @@ -353,10 +473,16 @@ function clampAttachments(items = [], max = 4) { }); } -router.get('/settings', (req, res) => { +router.get('/settings', async (req, res) => { const accountId = resolveAccountId(req); if (!accountId) return res.status(400).json({ error: 'Missing account id' }); const settings = getSettingsForAccount(accountId); + let availableLocalModels = []; + try { + availableLocalModels = await listOllamaModels(); + } catch { + availableLocalModels = []; + } res.json({ accountId: settings.accountId, enabled: settings.enabled, @@ -365,22 +491,42 @@ router.get('/settings', (req, res) => { allowEmailAccess: settings.allowEmailAccess, allowCalendarAccess: settings.allowCalendarAccess, systemPrompt: settings.systemPrompt, + model: settings.model, + enforceZeroDataRetention: settings.enforceZeroDataRetention, + availableModels: ZERO_RETENTION_MODELS, + availableLocalModels, }); }); -router.post('/settings', (req, res) => { - const accountId = resolveAccountId(req); - if (!accountId) return res.status(400).json({ error: 'Missing account id' }); - const settings = updateSettingsForAccount(accountId, req.body || {}); - res.json({ - accountId: settings.accountId, - enabled: settings.enabled, - providerMode: settings.providerMode, - apiKeyConfigured: settings.apiKeyConfigured, - allowEmailAccess: settings.allowEmailAccess, - allowCalendarAccess: settings.allowCalendarAccess, - systemPrompt: settings.systemPrompt, - }); +router.post('/settings', async (req, res) => { + try { + const accountId = resolveAccountId(req); + if (!accountId) return res.status(400).json({ error: 'Missing account id' }); + const settings = updateSettingsForAccount(accountId, req.body || {}); + let availableLocalModels = []; + try { + availableLocalModels = await listOllamaModels(); + } catch { + availableLocalModels = []; + } + return res.json({ + accountId: settings.accountId, + enabled: settings.enabled, + providerMode: settings.providerMode, + apiKeyConfigured: settings.apiKeyConfigured, + allowEmailAccess: settings.allowEmailAccess, + allowCalendarAccess: settings.allowCalendarAccess, + systemPrompt: settings.systemPrompt, + model: settings.model, + enforceZeroDataRetention: settings.enforceZeroDataRetention, + availableModels: ZERO_RETENTION_MODELS, + availableLocalModels, + }); + } catch (err) { + return res.status(400).json({ + error: String(err?.message || 'Failed to update BRAIN BOX settings'), + }); + } }); router.post('/test', async (req, res) => { @@ -396,6 +542,29 @@ router.post('/test', async (req, res) => { } return res.json({ ok: true, message: 'Platform AI is available' }); } + if (mode === 'ollama') { + const selectedModel = normalizeModel(req.body?.model, settings.model, 'ollama'); + try { + const models = await listOllamaModels({ timeoutMs: 2200 }); + if (models.length > 0 && !models.includes(selectedModel)) { + return res.json({ + ok: false, + message: `Ollama is reachable but model "${selectedModel}" is not installed`, + }); + } + return res.json({ + ok: true, + message: models.length > 0 + ? `Ollama is available (${models.length} model${models.length === 1 ? '' : 's'} detected)` + : 'Ollama is available', + }); + } catch (err) { + return res.json({ + ok: false, + message: `Ollama check failed: ${String(err?.message || 'request failed')}`, + }); + } + } const key = byokApiKey || settings.apiKey; if (!key) return res.json({ ok: false, message: 'API key is required in BYOK mode' }); if (key.length < 16) return res.json({ ok: false, message: 'API key format appears invalid' }); @@ -423,6 +592,9 @@ router.post('/transcribe', async (req, res) => { if (!settings.enabled) return res.status(403).json({ ok: false, error: 'BRAIN BOX is disabled in settings' }); const mode = normalizeProviderMode(req.body?.mode || settings.providerMode); const effectiveByokApiKey = String(req.body?.byokApiKey || settings.apiKey || '').trim(); + if (mode === 'ollama') { + return res.status(400).json({ ok: false, error: 'Audio transcription is not available for Ollama local mode' }); + } if (mode === 'platform' && !resolvePlatformApiKey()) { return res.status(503).json({ ok: false, error: 'Platform AI key is not configured' }); } @@ -468,27 +640,27 @@ router.post('/chat', async (req, res) => { const mode = normalizeProviderMode(req.body?.mode || settings.providerMode); const allowEmailAccess = normalizeBoolean(req.body?.allowEmailAccess, settings.allowEmailAccess); const allowCalendarAccess = normalizeBoolean(req.body?.allowCalendarAccess, settings.allowCalendarAccess); + const selectedModel = mode === 'platform' + ? normalizeModel(req.body?.model, settings.model, mode) + : mode === 'ollama' + ? normalizeModel(req.body?.model, settings.model, mode) + : String(process.env.REX_MODEL || DEFAULT_REX_MODEL).trim() || DEFAULT_REX_MODEL; + const enforceZeroDataRetention = mode === 'platform' + ? normalizeBoolean(req.body?.enforceZeroDataRetention, settings.enforceZeroDataRetention) + : false; const effectiveSystemPrompt = normalizeSystemPrompt( req.body?.systemPrompt, settings.systemPrompt ); const requestIntent = analyzeRequestIntent(input); - let emailContext = []; - let calendarContext = []; - if (allowEmailAccess) { - try { - emailContext = await fetchEmailContext(accountId, { - maxThreads: requestIntent.unreadOnly ? 12 : 8, - unreadOnly: requestIntent.unreadOnly, - }); - } catch { - emailContext = []; - } - } - if (allowCalendarAccess) { - calendarContext = await fetchCalendarContext(accountId); - } + const { emailContext, calendarContext } = await loadContextForChat({ + accountId, + mode, + allowEmailAccess, + allowCalendarAccess, + requestIntent, + }); const contextSummary = summarizeContext(emailContext, calendarContext); const fallbackLines = []; @@ -526,6 +698,20 @@ router.post('/chat', async (req, res) => { return res.json({ reply: fallbackLines.join('\n'), usage: null, + model: selectedModel, + provider: null, + context: { + emailItems: emailContext, + calendarItems: calendarContext, + }, + }); + } + if (mode === 'byok' && !effectiveByokApiKey) { + return res.json({ + reply: fallbackLines.join('\n'), + usage: null, + model: selectedModel, + provider: null, context: { emailItems: emailContext, calendarItems: calendarContext, @@ -555,27 +741,26 @@ router.post('/chat', async (req, res) => { }).join('\n\n')}` : 'Attachment references: none', '', - contextSummary.emailLines.length ? `Email context:\n${contextSummary.emailLines.join('\n')}` : 'Email context: none', + contextSummary.emailLines.length + ? `Email context:\n${contextSummary.emailLines.join('\n')}` + : (allowEmailAccess + ? 'Email context: unavailable_or_empty (do not assume the inbox is empty unless explicitly confirmed).' + : 'Email context: disabled by settings.'), '', - contextSummary.calLines.length ? `Calendar context:\n${contextSummary.calLines.join('\n')}` : 'Calendar context: none', + contextSummary.calLines.length + ? `Calendar context:\n${contextSummary.calLines.join('\n')}` + : (allowCalendarAccess + ? 'Calendar context: unavailable_or_empty.' + : 'Calendar context: disabled by settings.'), ].join('\n'); - if (mode === 'byok' && !effectiveByokApiKey) { - return res.json({ - reply: fallbackLines.join('\n'), - usage: null, - context: { - emailItems: emailContext, - calendarItems: calendarContext, - }, - }); - } - try { const modelResponse = await generateRexResponse({ prompt, mode, byokApiKey: effectiveByokApiKey, + model: selectedModel, + enforceZeroDataRetention, }); const text = modelResponse.text || fallbackLines.join('\n'); return res.json({ @@ -592,6 +777,8 @@ router.post('/chat', async (req, res) => { return res.json({ reply: `${fallbackLines.join('\n')}\n\n(LLM request failed: ${String(err?.message || 'unknown error')})`, usage: null, + model: selectedModel, + provider: null, context: { emailItems: emailContext, calendarItems: calendarContext, @@ -605,6 +792,12 @@ function sendSse(res, event, payload = {}) { res.write(`data: ${JSON.stringify(payload)}\n\n`); } +function isAbortError(error = null) { + if (error?.name === 'AbortError') return true; + const message = String(error?.message || '').toLowerCase(); + return message.includes('aborted') || message.includes('aborterror'); +} + router.post('/chat/stream', async (req, res) => { const accountId = resolveAccountId(req); if (!accountId) return res.status(400).json({ error: 'Missing account id' }); @@ -620,121 +813,162 @@ router.post('/chat/stream', async (req, res) => { const mode = normalizeProviderMode(req.body?.mode || settings.providerMode); const allowEmailAccess = normalizeBoolean(req.body?.allowEmailAccess, settings.allowEmailAccess); const allowCalendarAccess = normalizeBoolean(req.body?.allowCalendarAccess, settings.allowCalendarAccess); + const effectiveByokApiKey = String(req.body?.byokApiKey || settings.apiKey || '').trim(); + const selectedModel = mode === 'platform' + ? normalizeModel(req.body?.model, settings.model, mode) + : mode === 'ollama' + ? normalizeModel(req.body?.model, settings.model, mode) + : String(process.env.REX_MODEL || DEFAULT_REX_MODEL).trim() || DEFAULT_REX_MODEL; + const enforceZeroDataRetention = mode === 'platform' + ? normalizeBoolean(req.body?.enforceZeroDataRetention, settings.enforceZeroDataRetention) + : false; const effectiveSystemPrompt = normalizeSystemPrompt( req.body?.systemPrompt, settings.systemPrompt ); const requestIntent = analyzeRequestIntent(input); - let emailContext = []; - let calendarContext = []; - if (allowEmailAccess) { - try { - emailContext = await fetchEmailContext(accountId, { - maxThreads: requestIntent.unreadOnly ? 12 : 8, - unreadOnly: requestIntent.unreadOnly, - }); - } catch { - emailContext = []; - } - } - if (allowCalendarAccess) { - calendarContext = await fetchCalendarContext(accountId); - } - - const contextSummary = summarizeContext(emailContext, calendarContext); - const fallbackLines = []; - fallbackLines.push('BRAIN BOX can help with inbox and calendar tasks for this account.'); - if (contextSummary.emailCount > 0) { - fallbackLines.push( - `Loaded ${contextSummary.emailCount} email thread(s)${ - contextSummary.unreadCount ? `, including ${contextSummary.unreadCount} unread` : '' - }.` - ); - } - if (contextSummary.emailLines.length > 0) { - fallbackLines.push(requestIntent.unreadOnly ? 'Unread email context:' : 'Recent email context:'); - fallbackLines.push(...contextSummary.emailLines.map((line) => `- ${line}`)); - } - if (contextSummary.calLines.length > 0) { - fallbackLines.push('Upcoming calendar context:'); - fallbackLines.push(...contextSummary.calLines.map((line) => `- ${line}`)); - } - fallbackLines.push(`Detected intent: ${requestIntent.primary}`); - fallbackLines.push(`Request: ${input}`); - if (attachments.length > 0) { - fallbackLines.push('Reference attachments:'); - fallbackLines.push(...attachments.map((item, idx) => { - const summary = item.textExcerpt - ? `${item.textExcerpt.slice(0, 220)}${item.textExcerpt.length > 220 ? '…' : ''}` - : (item.note || 'No text extracted'); - return `- ${idx + 1}. ${item.name} (${item.mimeType || 'file'}) :: ${summary}`; - })); - } - fallbackLines.push('Try follow-ups like: prioritize, categorize, suggest replies, or build an action plan.'); - const fallbackText = fallbackLines.join('\n'); - - const prompt = [ - effectiveSystemPrompt, - `Detected intent (heuristic): ${requestIntent.primary}.`, - 'Use conversation history for continuity when it is relevant.', - '', - history.length - ? `Conversation history:\n${history.map((item) => `${item.role}: ${item.content}`).join('\n')}` - : 'Conversation history: none', - '', - `User request: ${input}`, - '', - attachments.length - ? `Attachment references:\n${attachments.map((item, idx) => { - const lines = [ - `${idx + 1}. ${item.name} (${item.mimeType || 'file'}, ${item.size} bytes)`, - ]; - if (item.textExcerpt) lines.push(item.textExcerpt); - if (item.note) lines.push(`Note: ${item.note}`); - return lines.join('\n'); - }).join('\n\n')}` - : 'Attachment references: none', - '', - contextSummary.emailLines.length ? `Email context:\n${contextSummary.emailLines.join('\n')}` : 'Email context: none', - '', - contextSummary.calLines.length ? `Calendar context:\n${contextSummary.calLines.join('\n')}` : 'Calendar context: none', - ].join('\n'); - res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders?.(); + const requestAbortController = new AbortController(); + let streamClosed = false; + const onClientClose = () => { + streamClosed = true; + requestAbortController.abort(); + }; + req.on('close', onClientClose); + const heartbeat = setInterval(() => { + if (streamClosed || res.writableEnded) return; + try { + res.write(': ping\n\n'); + } catch { + // socket is already closing + } + }, 10_000); - const effectiveByokApiKey = String(req.body?.byokApiKey || settings.apiKey || '').trim(); - if ((mode === 'platform' && !resolvePlatformApiKey()) || (mode === 'byok' && !effectiveByokApiKey)) { - sendSse(res, 'delta', { delta: fallbackText }); - sendSse(res, 'done', { - usage: null, - model: null, - provider: null, - context: { - emailItems: emailContext, - calendarItems: calendarContext, - }, - fallback: true, - }); - return res.end(); - } + const finishStream = () => { + clearInterval(heartbeat); + req.off?.('close', onClientClose); + if (!res.writableEnded) res.end(); + }; try { + sendSse(res, 'status', { phase: 'collecting_context' }); + const { emailContext, calendarContext } = await loadContextForChat({ + accountId, + mode, + allowEmailAccess, + allowCalendarAccess, + requestIntent, + }); + if (streamClosed || requestAbortController.signal.aborted) return finishStream(); + + const contextSummary = summarizeContext(emailContext, calendarContext); + const fallbackLines = []; + fallbackLines.push('BRAIN BOX can help with inbox and calendar tasks for this account.'); + if (contextSummary.emailCount > 0) { + fallbackLines.push( + `Loaded ${contextSummary.emailCount} email thread(s)${ + contextSummary.unreadCount ? `, including ${contextSummary.unreadCount} unread` : '' + }.` + ); + } + if (contextSummary.emailLines.length > 0) { + fallbackLines.push(requestIntent.unreadOnly ? 'Unread email context:' : 'Recent email context:'); + fallbackLines.push(...contextSummary.emailLines.map((line) => `- ${line}`)); + } else if (allowEmailAccess) { + fallbackLines.push('Email context was unavailable quickly; do not assume inbox is empty.'); + } + if (contextSummary.calLines.length > 0) { + fallbackLines.push('Upcoming calendar context:'); + fallbackLines.push(...contextSummary.calLines.map((line) => `- ${line}`)); + } else if (allowCalendarAccess) { + fallbackLines.push('Calendar context was unavailable quickly.'); + } + fallbackLines.push(`Detected intent: ${requestIntent.primary}`); + fallbackLines.push(`Request: ${input}`); + if (attachments.length > 0) { + fallbackLines.push('Reference attachments:'); + fallbackLines.push(...attachments.map((item, idx) => { + const summary = item.textExcerpt + ? `${item.textExcerpt.slice(0, 220)}${item.textExcerpt.length > 220 ? '…' : ''}` + : (item.note || 'No text extracted'); + return `- ${idx + 1}. ${item.name} (${item.mimeType || 'file'}) :: ${summary}`; + })); + } + fallbackLines.push('Try follow-ups like: prioritize, categorize, suggest replies, or build an action plan.'); + const fallbackText = fallbackLines.join('\n'); + + if ((mode === 'platform' && !resolvePlatformApiKey()) || (mode === 'byok' && !effectiveByokApiKey)) { + sendSse(res, 'delta', { delta: fallbackText }); + sendSse(res, 'done', { + usage: null, + model: selectedModel, + provider: null, + context: { + emailItems: emailContext, + calendarItems: calendarContext, + }, + fallback: true, + }); + return finishStream(); + } + + const prompt = [ + effectiveSystemPrompt, + `Detected intent (heuristic): ${requestIntent.primary}.`, + 'Use conversation history for continuity when it is relevant.', + '', + history.length + ? `Conversation history:\n${history.map((item) => `${item.role}: ${item.content}`).join('\n')}` + : 'Conversation history: none', + '', + `User request: ${input}`, + '', + attachments.length + ? `Attachment references:\n${attachments.map((item, idx) => { + const lines = [ + `${idx + 1}. ${item.name} (${item.mimeType || 'file'}, ${item.size} bytes)`, + ]; + if (item.textExcerpt) lines.push(item.textExcerpt); + if (item.note) lines.push(`Note: ${item.note}`); + return lines.join('\n'); + }).join('\n\n')}` + : 'Attachment references: none', + '', + contextSummary.emailLines.length + ? `Email context:\n${contextSummary.emailLines.join('\n')}` + : (allowEmailAccess + ? 'Email context: unavailable_or_empty (do not assume the inbox is empty unless explicitly confirmed).' + : 'Email context: disabled by settings.'), + '', + contextSummary.calLines.length + ? `Calendar context:\n${contextSummary.calLines.join('\n')}` + : (allowCalendarAccess + ? 'Calendar context: unavailable_or_empty.' + : 'Calendar context: disabled by settings.'), + ].join('\n'); + + sendSse(res, 'status', { phase: 'querying_model', mode, model: selectedModel }); let streamedText = ''; const modelResponse = await streamRexResponse({ prompt, mode, byokApiKey: effectiveByokApiKey, + model: selectedModel, + enforceZeroDataRetention, + signal: requestAbortController.signal, onToken: (delta) => { + if (streamClosed || requestAbortController.signal.aborted) return; const chunk = String(delta || ''); if (!chunk) return; streamedText += chunk; sendSse(res, 'delta', { delta: chunk }); }, }); + if (streamClosed || requestAbortController.signal.aborted) return finishStream(); if (!streamedText.trim()) { sendSse(res, 'delta', { delta: fallbackText }); @@ -750,21 +984,25 @@ router.post('/chat/stream', async (req, res) => { }, fallback: !streamedText.trim(), }); - return res.end(); + return finishStream(); } catch (err) { - sendSse(res, 'error', { error: String(err?.message || 'unknown error') }); - sendSse(res, 'delta', { delta: `${fallbackText}\n\n(LLM request failed: ${String(err?.message || 'unknown error')})` }); + if (streamClosed || requestAbortController.signal.aborted || isAbortError(err)) { + return finishStream(); + } + const errorText = String(err?.message || 'unknown error'); + sendSse(res, 'error', { error: errorText }); + sendSse(res, 'delta', { delta: `BRAIN BOX request failed: ${errorText}` }); sendSse(res, 'done', { usage: null, - model: null, + model: selectedModel, provider: null, context: { - emailItems: emailContext, - calendarItems: calendarContext, + emailItems: [], + calendarItems: [], }, fallback: true, }); - return res.end(); + return finishStream(); } });