CleanMail – Agent Guide
CleanMail is a desktop webmail client cleaner built with Electrobun, React, and the TanStack stack.
Tech 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 |
Project Structure
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
Commands
# 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 formatting
There are no test scripts. The project has no test framework configured. Do not add test dependencies without explicit instruction.
Linter & Formatter (Biome)
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
TypeScript
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 ?? []
Naming Conventions
| 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 |
Code Style Guidelines
Imports
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)
Components
- 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
Contexts
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>;
}
TanStack Query
- 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/
RPC Between Main and Renderer
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.
Error Handling
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
Electrobun Process Boundaries
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
- 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