- Rust 61.4%
- Svelte 30.5%
- TypeScript 3.3%
- Makefile 2%
- Python 1.7%
- Other 1.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
- Rules view rebuilt around sidebar: sets as submenu (+ new, - delete extras, pencil inline rename), no header card; question ids renameable with rule cascade; Triage header gets scope/ruleset/model selectors - Replay: runs snapshot fetched mail (cap 1000); History replays with current rules, no IMAP, always dry (ReplayBackend, shared classify_chunk) - English Laya bundle exported + verified live (billing @1.00 zero-shot); model picker (auto/english/multilingual) in Models + Triage header - Fix: full-run confirm could never start a run from idle; Keep-it-dry now restores dry mode |
||
| core | ||
| models | ||
| src-tauri | ||
| ui | ||
| vendor/imap-proto | ||
| .gitignore | ||
| AGENT.md | ||
| Makefile | ||
| PLAN.md | ||
| README.md | ||
| UI-DESIGN.md | ||
decletter — quiet email triage
A local-first desktop app that classifies large swaths of email with the Laya decision engine, shows its work live, and archives as it goes. Dry Run previews everything and touches nothing; Full Run labels then archives, with per-run undo.
- Gmail + generic IMAPS (OAuth2 PKCE or App Passwords; secrets encrypted in the app DB)
- Any number of folders, chunked fetch, pause/cancel/resume, checkpointed runs
- Quiet by default: the stream shows only mail under your confidence bar; everything else collapses into counters. The bar itself is draggable.
- No Python at runtime: Laya runs as local ONNX (or keyword stub without weights)
- Rules editor with live playground, per-run history, CSV-friendly sqlite log
Quickstart
make deps # rust, node, tauri-cli
make ui-install # frontend deps
make dev # full app (Tauri + backend + UI)
Frontend only (mock stream, no backend):
make ui-dev # http://localhost:5173
First real run: Accounts → Gmail address + 16-letter App Password
→ Test (expect connected ✓ + server caps) → Save → Folders → pick
INBOX → Triage → Dry run. That path needs no Google Cloud setup:
enable 2-Step Verification, then Google Account → Security → App passwords.
The OAuth client ID field is only for the alternative Google sign-in
(collapsible, under the App Password field).
Real intelligence (Laya weights, one time)
make export-deps # throwaway venv with torch(CPU)/transformers/laya (~4GB, dev only)
make export-english # laya (English 421M, triage-tuned) → models/ + verify vs torch
make export # laya-multilingual (322M, 100+ langs) → models/ + verify
make models-status # check the four files per bundle
make dev-onnx # run with the ONNX runtime (auto-selected when weights exist)
Two bundles, one switch (Models view → Deciding model, saved to prefs):
laya-english (default when present) — ModernBERT-large 421M, 512 ctx,
fine-tuned for spam/phishing/department routing; laya-multilingual —
mmBERT 322M, 1024 ctx, ~2× faster, weaker English. Runs and the playground
snapshot the choice at start, so switching never disturbs a live run.
Verified numbers (english): export drift Δlogits 0.00, Δact-probs 0.00;
Rust live inference 1215ms first CPU pass, billing sample → billing
at 1.00 confidence zero-shot. Without weights the app runs the
transparent keyword stub. Both bases are weak zero-shot by design outside
their training — fine-tune for production accuracy (the typed-decisions
checkpoint is a 4-workflow specialist, not a general upgrade).
Weights are picked up from $DECLETTER_MODELS_DIR, else <app_data>/models
(Models-view imports), else the repo's models/ (make dev-onnx finds your
export with zero setup). The startup log prints which dir won.
GPU (CUDA) — official-level speed
The published 33ms numbers are T4-GPU numbers; CPU does ~400ms/mail. On an RTX-class GPU this box hits them:
make cuda-libs # CUDA 13 .so's to ~/.local (no sudo, ~1.2GB, one time)
make dev-cuda # Tauri + Laya on the GPU (implies --features onnx)
Measured here (RTX 3070 laptop, laya-multilingual, starter 4-question pack):
| CPU | CUDA | official (T4) | |
|---|---|---|---|
| short, 4 questions | 807ms | 20ms | 40ms @5q |
| long (800 tokens), 4 questions | 13.3s | 239ms | 72ms @10q |
| long, 1 question | 3.1s | 66ms | 33ms |
Per-decision on typical mail: ~5ms — inside the 50ms budget. The run log's
routing field records laya-onnx · cuda vs · cpu per row, so you can audit
which engine decided. Without CUDA libs the session falls back to CPU loudly
(never silently — EP failures are error_on_failure). Batches size
themselves by a 16k-token budget (DECLETTER_MAX_ROWS overrides, OOM halves
and retries); title-only mail packs ~5x more rows per run than full mail.
Rules, scope, and saved preferences
- Rule sets carry questions, rules, threshold, and scope:
full(sender + subject + date + body) orsubject(headers only, no body — much shorter sequences, coarser signal). Rules run on every decision (first-match-wins, confidence-gated archive) — they're the configuration, not decoration. - Everything saves itself: every field writes through debounced (quiet "unsaved — needs a fix" hint while a row is half-typed; the Save button forces + reports validation). Scope, threshold, dark mode, model choice, and which set is active all restore on launch.
- Replay: every run snapshots the mail it fetched (up to 1000, sqlite). History rows with saved mail get a Replay button — re-classifies with the rules as they stand now, no re-download, always a dry run. Tweak, replay, compare.
- While a run is underway the activity feed narrates every stage with a 1Hz heartbeat; the dot is green <3s, amber 3–10s, red past 10s (stalled).
Verify & ship
make test-all # UI build + svelte-check + Rust tests (incl. 50k soak) + shell check
make core-onnx-test# live-weights test (skips cleanly without models/)
make build # release binary + .deb + .rpm
make build-onnx # release with Laya compiled in
make help # every command, documented
Soak: 50,000 msgs at 3,133 msgs/sec (debug build, stub classifier).
AppImage doesn't bundle on bleeding-edge hosts (linuxdeploy's strip vs
.relr.dyn) — deb/rpm carry Linux; CI covers macOS/Windows.
How it works
ui/ ──Tauri commands/events──▶ src-tauri/ ──spawns──▶ core/ run thread
Svelte 5 + Tailwind 12 commands blocking IMAP session
mock fallback offline run://stats (4Hz tiny) quiet-by-default:
run://review (review only under-bar + error rows
rows only) get_message pages the rest
sqlite is the record UI ring buffer is a view
- Gmail: labels-as-folders; archive = remove
\Inbox(X-GM-LABELS). Undo restores labels. - Generic IMAP: archive =
MOVEtoArchive/<label>(COPY-fallback); undo finds the moved mail byMessage-IDand moves it back. - Runs: fetch → decide → apply-in-verified-order → one sqlite txn per
chunk → checkpoint. Never archives unlabeled mail. Dry runs use read-only
EXAMINEand assert read-only in tests.
Layout
| Path | What's there |
|---|---|
ui/ |
Svelte 5 + Tailwind shell, mock-first, Tauri bridge with fallback |
core/ |
Engine: mail/ (Gmail/generic IMAP), classify/ (packs, stub, Laya port), runs/ (engine, sqlite log), oauth.rs, models.rs, secrets.rs |
src-tauri/ |
Tauri shell: commands, event pump, OAuth sessions, icons, bundle config |
models/ |
export.py + exports (git-ignored weights); models-status to inspect |
PLAN.md |
Architecture + build phases (all current phases marked done) |
UI-DESIGN.md |
Visual system: tokens, screens, motion, digest mode |
AGENT.md |
Instructions for AI coding agents working in this repo |
Makefile |
Every command, documented (make help) |
Configuration
- App data (
~/.local/share/us.packden.decletter/or OS equivalent):config.json(accounts — no secrets, packs, prefs),decletter.db(runs),models/(weights). - Secrets are encrypted at rest (ChaCha20-Poly1305, machine-bound key) in the app DB — no daemons, no OS keyring dependency. Honest scope: this stops DB theft/backups from leaking passwords; it doesn't stop same-user processes (nothing short of a keyring or master password can).
- Gmail OAuth needs a Desktop OAuth client ID pasted into Accounts; generic IMAP needs host/user/password with TLS on 993.
Troubleshooting
cargo taurimissing →make deps.- Backend unreachable in dev → the sidebar pill says why; the UI falls back to the mock stream automatically.
models/incomplete → Models view lists exactly which files are missing.- Slow first inference → session warmup (~600ms CPU); steady-state is faster.
- Undo gaps → rows written before v0.2 lack Message-IDs; Gmail undo always works.
License
MIT OR Apache-2.0 for decletter's own code. Laya weights follow their
upstream (Apache-2.0) terms — download them yourself via make export.