\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
+
+
+ A local-first, keyboard-driven email client for people who live in their inbox.
+
+
+
+
+
+
+
+
+
+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