πEnvironment
direnv allow # or: nix develop
The shell provides the Rust toolchain, Node and pnpm, hurl, sqlite, and the
WebKitGTK closure Tauri needs.
It deliberately does not provide notmuch, isync or msmtp, the same way
the package ships no copy of them: a shell that supplied its own would put a
plain mbsync ahead of yours, and just serve against a real inbox would fail
every OAuth account with selected SASL mechanism(s) not available β the
mechanisms of the copy this shell handed you, not of the one your config was
written for. The suites need all three, so install them on the machine you
develop on; just doctor names whichever is missing, and the shell's greeting
prints the version of each or says not installed.
πCommands
just wraps everything. just on its own lists the recipes.
just install # web dependencies
just doctor # verify the mail setup β do this first
just test # 307 Rust tests
just test-web # 534 web tests + tsc
just verify # 22 checks in a real browser
just check # fmt, lint, both suites, browser, visual and UX verification
just deny # licences, bans, sources, advisories
just licenses # re-audit every dependency licence
just serve # run the server
just dev # web client with hot reload
just token phone # issue a device token, with a pairing QR
just verify-live # read-only smoke test against your real mail
Every recipe that starts a server binds 0.0.0.0:8399, and ECR_BIND overrides
it. The port is deliberately not the installed server's 8383: on a machine that
also runs ecr serve as a service β the NixOS module, the home-manager module
or packaging/ecr.service β sharing the port means one of the two loses it, and
which one depends on the order they happened to start in. Keeping them apart
lets a working tree and an installed service run side by side, which comparing
the two needs anyway.
πThe dev token
Once you have issued yourself a device token the API is authenticated, and every request without one is a 401 β including the ones the client you are developing makes. So the recipes that launch a client issue and carry their own token rather than leaving you to paste one in.
scripts/dev-token.sh issues it, into ~/.config/ecr/dev-tokens.toml and
not the real tokens.toml. That separation is load-bearing in both
directions: the verify-* recipes drive the real config in place and rely on
auth being off, which an empty store is what gives them, and a dev token in
there would break every one of them. It also caches the plaintext in
~/.config/ecr/dev-token, because ecr token new prints a token once and a
second command would otherwise have no way to recover the one a running server
was started with. ECR_DEV_TOKENS and ECR_DEV_TOKEN_FILE move both.
Each client then gets the token by the only route open to it:
| Recipe | How the token arrives |
|---|---|
just run | ?token= on the URL the browser is opened with |
just dev | the same, on localhost:1420; vite proxies /api to the server |
just desktop | ECR_TOKEN in the environment, read by the shell's default_token |
just android | ECR_TOKEN at build time, baked into the debug APK |
The browser cases work because the client strips ?token= out of the URL as it
starts β it saves the token, rewrites the address bar with replaceState so it
does not linger in history, and never shows the connection form.
The two shell cases have no URL to carry anything: the webview is not served by
the server. The desktop reads the environment it was launched with. A phone has
no such environment β it runs an installed APK and reaches this machine back
down the USB cable β so just android sets ECR_TOKEN for the build and
option_env! compiles it in. Only that debug build carries it; a release is
compiled without the variable and default_token answers None, which is what
keeps a dev token out of a shipped artifact. shell/build.rs declares
rerun-if-env-changed=ECR_TOKEN, or rotating the token would leave a cached
binary presenting the old one and the app would look unable to reach the server.
A token stored on the device always wins over the shell's, so pairing a phone
properly with just token phone is not undone by later running just android.
That rule is also why just desktop runs the shell against its own data
directory, .dev/share, through XDG_DATA_HOME. A Tauri webview keeps its
localStorage under an app data directory derived from the bundle identifier, so
without this a dev launch and an installed ecr share one β and the connection
record is the thing they must not share. ECR_SERVER_URL is authoritative while
ECR_TOKEN is only the fallback, so a client that had been paired properly got
moved to the dev server on 8399 while keeping the token for the real one, and
said this device is not authorised about a setup in which nothing was wrong.
Separate directories give the dev launch an empty store, so it takes both halves
of the identity the shell hands it, and an installed ecr is left alone. Set
ECR_DEV_DATA to put it somewhere else; just clean removes it.
Being authoritative is also why the shell answers with a server only when
ECR_SERVER_URL names one. It used to fall back to http://localhost:8383, and
a phone has no environment for that variable to be in β so every Android launch
overwrote the address the device had been paired with, and the app had to be
paired again each time it was opened. A client with nothing stored still starts
at that address, but from defaultBaseUrl in the client, which is read only when
localStorage is empty and so cannot displace a pairing.
The underlying commands are ordinary and can still be run directly:
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all
pnpm --dir web test
./scripts/verify-web.shπTests
Rust unit tests cover pure logic: config resolution order, the isyncrc and notmuch config parsers, tag-batch construction, MIME parsing and sanitization, error mapping.
Rust integration tests (crates/ecr-store/tests, crates/ecr-server/tests)
build a throwaway notmuch database in a tempdir from fixtures/ and drive the
real notmuch binary, then a real HTTP server. They never touch your mail.
Sync and send are exercised against stub binaries: ServerSettings lets the
binary paths be overridden, so the test writes a shell script that delivers into
the maildir or captures stdin. The real notmuch new path, including the
post-new hook, is genuinely covered β with no network and no Gmail.
Web unit tests cover the keymap engine (every binding, sequence timeouts, modifier passthrough, the focus rule), the windowing arithmetic, the mark-queue to tag-operation mapping, the command grammar and part-URL absolutization.
Browser verification (scripts/verify-web.sh) builds a demo maildir, starts
the real server and the built client, and drives Chrome: rows render, cells do
not overlap, the webfont actually loaded, j moves the selection, Enter opens a
thread, the body renders sandboxed, no script survived, the inline image
actually loads, Escape closes the help, and there is no horizontal scroll at
390px. It writes screenshots/web-client.png.
On NixOS Playwright's bundled chromium fails on libnspr4.so; web/browser.mjs
picks /run/current-system/sw/bin/google-chrome-stable instead. just visual
pins its own browser through ECR_CHROME, because two browser builds rasterise
the same glyph differently.
Visual regression (just visual) photographs 33 states against the fixture
maildir and compares them pixel by pixel. Nothing in it waits out a duration:
each state waits for the client to settle β no request outstanding, no
loadingβ¦ on screen, the fonts loaded and the DOM unchanged for 250ms β and a
state that never gets there fails as never settled, naming itself. That is
the difference between a suite you can believe and one you cannot: the states
used to sleep for a number chosen against an idle machine, and under load five
of them reported diffs that were only a client caught mid-render, which is
indistinguishable in the output from a real regression. A new state should end
by waiting on the thing it is a picture of, and needs an explicit settle in the
middle only where a later keystroke depends on an earlier one having landed β
opening a thread before replying to it, for instance.
--approve re-renders and rewrites all 33, so accepting one deliberate change
means copying that file out of screenshots/visual/current/ rather than
approving the run. If the whole suite moves at once, suspect the browser or the
fonts before the UI.
End-to-end (just e2e) is the @playwright/test suite in web/e2e/. A
worker-scoped fixture builds its own demo maildir and runs a server on port
8501, so it does not collide with the verify-* recipes. Each worker starts
against a cold config directory, which is what exposed the sidebar counts
never appearing until something unrelated forced a re-render. Keep playwright
and @playwright/test on the same version: two copies in the tree and the
runner collects nothing.
Figures (just figures) regenerates the images the README shows, into
figures/. Same fixture maildir as the visual suite, with the clock pinned, so
re-running it on another day produces the same pictures. Distinct from
screenshots/visual/, which is regression output rather than documentation.
πFixtures
Every fixture is synthetic and every address is @example.com per RFC 2606. No
real message is committed, and neither is any screenshot taken against real mail
β the verifiers that drive real mail write to the gitignored screenshots/live/,
because the composer's address completion puts other people's addresses on
screen. A new verifier that touches real mail must write there too.
fixtures/maildir/cur/*.emlβ plain threading fixturesfixtures/mime/*.emlβ nested multipart with an inline PNG and a PDF, an RFC 2047 encoded subject, a latin1 body, an HTML-only message
Maildir flags are authoritative: a file named :2,S is Seen and notmuch will
strip unread from it. Fixtures use :2, so they index as unread.
scripts/demo-env.sh builds the throwaway maildir the browser suites drive, and
anything serving it must be launched with NOTMUCH_CONFIG, NOTMUCH_PROFILE and
MBSYNCRC stripped β env -u NOTMUCH_CONFIG -u NOTMUCH_PROFILE -u MBSYNCRC
β not merely with HOME and XDG_CONFIG_HOME pointed at the demo directory.
paths ranks the environment variable above the XDG
location, which is right for someone who exported it on purpose and wrong for a
test that inherited it: the dev shell exports NOTMUCH_CONFIG, so a launcher
that only overrode HOME served the real mailbox. just visual compared every
baseline against a live inbox that way, and just verify-marks writes tags. A
new suite that serves the demo directory must copy that env -u prefix.
It must also set XDG_STATE_HOME alongside XDG_CONFIG_HOME. That is the same
gap one directory over: a desktop session exports it, so dirs::state_dir()
answers the real ~/.local/state however HOME is pointed, and the mail index
lives there. A fixture server that inherited it opened the developer's own
index.sqlite3, found it built against another database and rebuilt it β tens of
thousands of messages of work against a database of eleven, with the real index
gone afterwards. Nothing fails; doctor warns and the suite passes. What it costs
is a startup slow and variable enough to make the fixed waits in the verify-*
scripts intermittently too short, which reads as the feature under test not
working.
πAdding an endpoint
- Add the wire type to
ecr-coreif it is new. - Add the method to
MailStoreand implement it inNotmuchStore. - Add the handler in
crates/ecr-server/src/routes.rsand route it inapp.rs. - Add an integration test in
crates/ecr-server/tests/api.rs. - Add the client method in
web/src/api/client.tsand the type intypes.ts.
πAdding a keybinding
- Add the
Actionvariant inweb/src/keymap/engine.ts. - Add the binding to
DEFAULT_BINDINGSβ the "every default binding is reachable" test will start covering it automatically. - Handle it in
dispatch()inweb/src/App.tsx.
πKnown gaps
- The mail index covers only part of notmuch's query syntax.
date:,folder:,attachment:,subject:,from:and any bare word still shell out to notmuch, at the original cost. Text search is the deliberate one: an FTS index does not select the same messages Xapian does. Widening the rest is a matter of proving each addition answers identically first β see the comparison tests incrates/ecr-store/tests/index.rs. - The Android APK is unsigned unless the secrets are set.
release.ymlbuilds and attaches one on every tag, but signs it only whenANDROID_KEYSTOREis present β and an unsigned build cannot be upgraded in place by a later signed one. Locally,just androidruns it on a plugged-in device from the opt-in.#androidshell. See operations.md. ts-rstype generation is not wired up;web/src/api/types.tsis maintained by hand and can drift fromecr-core.