CleanMail is a desktop webmail client cleaner built with Electrobun, React, and the TanStack stack.
| Layer | Technology |
|---|---|
| Package manager | Bun |
| Desktop runtime | Electrobun (not Electron) |
| Linter / Formatter | Biome |
| Bundler / HMR | Vite |
| UI framework | React |
| Component library | shadcn/ui (Tailwind-based) |
| Data fetching | TanStack Query |
| Routing | TanStack Router |
| Tables | TanStack Table |
cleanmail/
├── src/
│ ├── bun/ # Main process (Electrobun / Bun runtime)
│ │ ├── index.ts # Entry point: creates windows, starts RPC
│ │ └── rpc.ts # All RPC handler registrations
│ ├── mainview/ # Renderer / webview (React + Vite)
│ │ ├── components/ # Shared React components
│ │ │ └── ui/ # shadcn/ui generated components (do not hand-edit)
│ │ ├── contexts/ # React contexts (Actions, ApplyAction, Drag)
│ │ ├── hooks/
│ │ │ ├── queries/ # TanStack Query hooks (useEmails, useMailboxes, …)
│ │ │ └── mutations/ # TanStack Query mutation hooks
│ │ ├── pages/ # Page-level components ([Name]Page.tsx)
│ │ ├── routes/ # TanStack Router file-based routes
│ │ ├── lib/ # Utilities, helpers, query client setup
│ │ │ ├── rpc.ts # All rpc.request.* wrappers (named exports)
│ │ │ ├── query-keys.ts # Centralized query key factory
│ │ │ ├── query-client.ts # QueryClient singleton
│ │ │ └── utils.ts # cn() (clsx + tailwind-merge)
│ │ ├── App.tsx # Router outlet + providers
│ │ ├── main.tsx # React entry point
│ │ ├── index.html # HTML shell
│ │ └── index.css # Tailwind base styles
│ └── shared/
│ └── rpc-types.ts # All shared types between bun and mainview
├── biome.json # Linter + formatter config (single source of truth)
├── electrobun.config.ts # App metadata, window defaults
├── vite.config.ts # Vite + React plugin config
├── tailwind.config.js # Tailwind theme overrides
├── tsconfig.json
└── package.json
# Install
bun install
# Development
bun run start # HMR + app together (recommended for UI work)
bun run hmr # Vite HMR server only (port 5173)
bun run start:app # Electrobun dev (loads dist/, no HMR)
bun run watch # Turbo TUI: format:watch + lint:watch + HMR + app
# Build
bun run build # Production build (electrobun build)
bun run build:web # Vite-only build (outputs to dist/)
# Lint & Format (Biome is the sole toolchain — no ESLint, no Prettier)
bun run lint # Check for lint errors
bun run lint:fix # Auto-fix lint errors
bun run format # Check formatting
bun run format:fix # Auto-fix formattingThere are no test scripts. The project has no test framework configured. Do not add test dependencies without explicit instruction.
biome.json is the single source of truth for code style:
- Double quotes for all JS/TS strings (
"quoteStyle": "double") - Biome
recommendedrule set enforced (no-unused-vars, no-any, etc.) - Import organization is automatic (
organizeImports: "on") — do not manually sort imports - Auto-generated files excluded:
dist/,build/,.agents/,src/mainview/routeTree.gen.ts - Run
bun run lint:fix && bun run format:fixafter making changes
tsconfig.json settings that affect code style:
strict: true— full strict modenoUnusedLocals: trueandnoUnusedParameters: true— remove any dead codenoFallthroughCasesInSwitch: true- Path alias:
@/*→src/mainview/*
Rules to follow:
- Never use
any— useunknownand narrow, or a precise type - Use
import type { ... }for type-only imports (Biome enforces this) - Use
type(notinterface) for shared types inrpc-types.ts - No
React.FC— use explicit prop types and let TypeScript infer return types - Non-null assertions (
!) require a// biome-ignore lint/style/noNonNullAssertion: <reason>comment - Use
satisfiesfor config objects:export default { ... } satisfies ElectrobunConfig - Use optional chaining and nullish coalescing:
data?.items ?? []
| Kind | Convention | Example |
|---|---|---|
| Files / directories | kebab-case |
query-keys.ts, mailbox-utils.ts |
| React component files | PascalCase |
EmailTable.tsx, MailboxSidebar.tsx |
| Route files | TanStack Router convention | __root.tsx, $mailbox.tsx |
| React components | PascalCase |
EmailTable, ImapSetupDialog |
| Prop types | [Component]Props |
EmailTableProps |
| Context value types | [Name]ContextValue |
ActionsContextValue |
| Query hooks | use[Resource] |
useEmails, useMailboxes |
| Mutation hooks | use[Verb][Resource] |
useAddAction, useMoveEmail |
| Context accessor hooks | use[Name]Context |
useActionsContext() |
| Module-level constants | SCREAMING_SNAKE_CASE |
EMAILS_PER_PAGE, KEYTAR_SERVICE |
| Discriminant strings | SCREAMING_SNAKE_CASE |
"MOVE", "DELETE" |
| Variables / functions | camelCase |
fetchEmails, rpcGetImapConfig |
Biome auto-organizes imports into three groups (do not reorder manually):
- Third-party libraries (
react,@tanstack/*,lucide-react,sonner, …) - Internal
@/alias imports (@/components/…,@/lib/…,@/contexts/…) - Relative imports (
../../shared/rpc-types,./sibling)
- Prefer named exports everywhere except route files (TanStack Router requires default exports)
- Keep components focused — extract sub-components when a file grows large
- Use shadcn/ui primitives (Button, Dialog, Table, etc.) before writing custom markup
- Use TanStack Table for all data tables — never build custom
<table>markup from scratch - All styling via Tailwind classes — no inline
styleprops, no CSS modules
All contexts follow this exact structure:
export const MyContext = createContext<MyContextValue>(defaultValue);
export function useMyContext() { return useContext(MyContext); }
export function MyContextProvider({ children }: { children: React.ReactNode }) {
// ...
return <MyContext value={...}>{children}</MyContext>;
}- Query keys are centralized in
src/mainview/lib/query-keys.tsviacreateQueryKeys - Spread the key into
useQuery:useQuery({ ...emailKeys.byMailbox(path), queryFn: ... }) - After mutations, use
queryClient.invalidateQueries({ queryKey: keys._def })— not manual cache writes - Place query hooks in
hooks/queries/and mutation hooks inhooks/mutations/
All cross-process types live in src/shared/rpc-types.ts. Define all shared types there.
// src/bun/rpc.ts — register handlers
export const rpc = BrowserView.defineRPC<CleanMailRPC>({
handlers: { requests: { fetchEmails: rpcFetchEmails, ... } }
});
// src/mainview/lib/rpc.ts — wrap calls as named exports
import { rpc } from "electrobun/webview";
export const fetchEmails = (params: FetchEmailsParams) => rpc.request.fetchEmails(params);Push messages (bun → webview) use rpc.send.*; the renderer registers listeners in lib/rpc.ts.
Main process (src/bun/) — always return structured result objects, never throw to the caller:
// Success
return { success: true };
// Failure
return { success: false, error: err instanceof Error ? err.message : String(err) };IMAP operations must always release the mailbox lock in a finally block and attempt logout on error:
const lock = await client.getMailboxLock(path);
try { /* ... */ } finally { lock.release(); }Renderer (src/mainview/) — surface errors via:
- TanStack Query
isError/errorstate for query failures toast.error(...)(Sonner) for user-visible mutation failures- Check
data?.errorfield from RPC responses before using the result
src/bun/— Bun runtime only. File system, IMAP, keychain, native APIs. No DOM, no React.src/mainview/— WebView (WebKit/Blink). React, all UI. NoBun.*orbun:*imports.src/shared/— TypeScript types only. No runtime imports from either side.
- Do not run
npmoryarn— always usebun - Do not import
Bun.*orbun:*APIs insidesrc/mainview/ - Do not edit files in
src/mainview/components/ui/— re-generate viabunx shadcn@latest add <component> - Do not add ESLint or Prettier — Biome is the sole linter/formatter
- Do not use
React.FCtype annotation - Do not use
any— useunknownand narrow types - Do not write raw
<table>markup — use TanStack Table - Do not use inline
styleprops — use Tailwind classes - Do not edit
src/mainview/routeTree.gen.ts— it is auto-generated by TanStack Router