Quiet email triage: local-first Gmail/IMAP classifier (Tauri + Rust + Svelte + Laya)
  • Rust 61.4%
  • Svelte 30.5%
  • TypeScript 3.3%
  • Makefile 2%
  • Python 1.7%
  • Other 1.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Christopher Rueber bc1028d1f3 Rules UX + replay: sidebar rulesets, autosave-everything, snapshots, english model switch
- 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
2026-09-21 07:33:55 -05:00
core Rules UX + replay: sidebar rulesets, autosave-everything, snapshots, english model switch 2026-09-21 07:33:55 -05:00
models Rules UX + replay: sidebar rulesets, autosave-everything, snapshots, english model switch 2026-09-21 07:33:55 -05:00
src-tauri Rules UX + replay: sidebar rulesets, autosave-everything, snapshots, english model switch 2026-09-21 07:33:55 -05:00
ui Rules UX + replay: sidebar rulesets, autosave-everything, snapshots, english model switch 2026-09-21 07:33:55 -05:00
vendor/imap-proto Reliability + UX pass: Gmail fetch fix, reconnects, paged tabs, full threshold range 2026-09-19 22:07:18 -05:00
.gitignore Initial decletter: quiet email triage (Tauri + Rust + Svelte) 2026-09-19 17:36:39 -05:00
AGENT.md Rules UX + replay: sidebar rulesets, autosave-everything, snapshots, english model switch 2026-09-21 07:33:55 -05:00
Makefile Speed + scope + prefs: CUDA EP, token-budget batches, titles mode, saved prefs 2026-09-20 11:12:07 -05:00
PLAN.md Speed + scope + prefs: CUDA EP, token-budget batches, titles mode, saved prefs 2026-09-20 11:12:07 -05:00
README.md Rules UX + replay: sidebar rulesets, autosave-everything, snapshots, english model switch 2026-09-21 07:33:55 -05:00
UI-DESIGN.md Initial decletter: quiet email triage (Tauri + Rust + Svelte) 2026-09-19 17:36:39 -05:00

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):

mail 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) or subject (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 = MOVE to Archive/<label> (COPY-fallback); undo finds the moved mail by Message-ID and 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 EXAMINE and 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 tauri missing → 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.