Operations

Running the server, device tokens, doctor, Android, and troubleshooting.

🔗Prerequisites

notmuch, mbsync (isync) and msmtp must be on PATH, plus whatever your configs invoke. Gmail and Outlook need XOAUTH2, which ecr oauth provides itself — point PassCmd and msmtp's passwordeval at ecr oauth token <profile> and there is no third tool to install. Nothing ecr ships provides the first three — not the package, not the Nix dev shell.

Your copies of those three are the ones ecr runs — there are no others. ecr never manages the binaries, and by default it does not manage their configuration either — it reads what you have. Managed mode is the opt-in exception, and it changes only who writes the config files, never which binary runs. The Nix package ships no copy of them, not even behind yours as a fallback. That is deliberate: a second copy is not the same binary. mbsync reaches XOAUTH2 only through isync.override { withCyrusSaslXoauth2 = true; }, which wraps it to put the plugin on SASL_PATH, and a plain one fails every OAuth account with selected SASL mechanism(s) not available out of a configuration that syncs perfectly by hand. A tool that is simply missing fails as ecr doctor naming it, which is a failure you can act on. ecr knows nothing about SASL plugins; whoever manages mbsync manages those.

🔗The command

Everything is one binary, ecr, built from crates/ecr-cli. just recipes run it out of the workspace; cargo run -p ecr-cli -- <args> is the long form.

ecr doctor              check the mail setup
ecr serve               run the server
ecr account add|list|remove|apply|import|test|sync-dav
ecr notmuch <args>      run notmuch against the config ecr resolved
ecr token new|list|revoke
ecr help [topic]        worked examples: start, phone, accounts, trouble

Two more exist for whoever is packaging ecr rather than reading mail, and are hidden from --help for that reason: ecr man prints the manual page in roff and ecr completions <shell> prints a completion script. Every packaging path generates both by running the binary it just built, so they cannot describe a command tree other than the one being shipped. Installing them by hand:

ecr man > ~/.local/share/man/man1/ecr.1
ecr completions fish > ~/.config/fish/completions/ecr.fish

ecr web, ecr qr, ecr logs and the background lifecycle (stop, status, restart) are declared but not yet implemented; each says so and names what to use meanwhile. The desktop client is a separate binary, ecr-desktop, built from shell/.

🔗Start here

ecr init

It adopts whatever already exists rather than replacing it: if a notmuch config resolves through the four-step order, that is the setup and init only reports on it. Otherwise it looks for a maildir — ~/Mail, ~/Maildir, ~/.mail, ~/.maildir — and offers the one it finds, falling back to ~/.local/share/mail. The path is a question, not a decision; answer it with anywhere you like.

Nothing is written without being confirmed first, and the notmuch config is printed in full before the prompt to write it. Because every step asks, init needs a terminal: with no stdin it refuses rather than hanging where nobody can see it.

It sets index.header.List=List-Id while doing so. That is free on an empty database and costs a full notmuch reindex '*' afterwards, and without it the sidebar's mailing lists cannot be searched at all.

--force replaces a config ecr generated, moving the old one aside first.

ecr serve runs this by itself when no configuration resolves, so a fresh machine is offered the setup instead of an error naming the paths it looked in. It does not make that machine servable on its own — an empty maildir has no accounts, which is a failure below — but it removes having to write a notmuch config by hand before anything can be diagnosed. ecr serve --no-init refuses instead, which is what a systemd unit or a container wants.

ecr doctor

It reports which config each tool resolved to and via which step, the maildir root, the notmuch database, the post-new hook, every discovered account with its folder count, and each account's OAuth token state. The server refuses to start unless this is healthy.

--json emits the same report for scripting.

🔗Managed mode

By default ecr reads a setup you configured. It can instead generate that configuration from accounts it holds itself:

ecr account add personal --address you@gmail.com --provider gmail

That writes ~/.config/ecr/accounts.toml and generates four files under ~/.config/ecr/managed/ — an isyncrc, an msmtp config, a notmuch config and its post-new hook — then hands those three packages to ecr. Presets exist for gmail, outlook and fastmail; anything else is generic and takes --imap and --smtp.

Already have a working setup? Import it, and look before you leap:

ecr account import           # reads it, shows exactly what ecr would generate
ecr account import --write   # saves that to accounts.toml, still switching nothing
ecr account apply            # hands the files over

The diff is the point. Import carries across what you already chose — the patterns each channel syncs, how far a deletion travels, your CA bundle, your search.exclude_tags, which address is primary — rather than replacing them with a preset. The one thing it cannot carry is your post-new hook, because ecr generates its own, and it says so.

🔗What managed mode does and does not touch

  • ecr writes only inside ~/.config/ecr/, and only for packages you set to ecr. Your own ~/.config/isyncrc and the rest stay exactly where they are, byte for byte. They are what ecr goes back to the moment you switch a package back to self — one line, nothing to undo.
  • A generated file says so at the top and carries a digest of its own body. Edit one and the next ecr account apply moves your edit aside and tells you where it put it, rather than overwriting it.
  • ecr serve brings the generated files forward by itself at startup. What they should contain is decided by ecr's code, so upgrading ecr can leave every one of them behind — and ecr account apply runs when an account changes, which an upgrade is not. Anything ecr wrote and has since moved past is rewritten when the server starts, with a line in the log naming it. A file you edited is never touched by this: it stays exactly as you left it and ecr doctor goes on reporting it, because the startup path is unattended and a backup you did not ask for is a file you will not know to look for. ecr account apply remains the deliberate version that replaces an edited file too.
  • Deletion never propagates by default. Expunge and Remove are None and Create is Near, so ecr fetches a folder that appears on the server and never creates, removes or expunges anything on it. Change that per account if you mean to.
  • The maildir is never deleted. ecr account remove forgets an account and leaves its mail exactly where it is.
  • Your own notmuch command will not see the generated config, because it lives in ecr's directory rather than ~/.config/notmuch. Use ecr notmuch <args>, which runs your notmuch against the config ecr resolved.
  • The generated config names hook_dir explicitly, and has to. notmuch does not look for hooks beside its config file: it reads database.hook_dir, whose default is <database.path>/.notmuch/hooks — inside the maildir, which is the one place managed mode will not write. Generating the hook without naming the directory it is in is therefore only half the job, and the missing half is silent: notmuch new still indexes, so mail keeps arriving and is simply never tagged. Every view that starts from tag:inbox then stops at the last message tagged before managed mode was switched on, which reads as syncing having stopped rather than as tagging having stopped. ecr doctor asks notmuch where it will run hooks from, rather than assuming, and fails when nothing is there.

🔗Contacts and calendars

ecr account sync-dav          # every account
ecr account sync-dav personal # one

Fetches CardDAV and CalDAV collections into a vdir under ~/.local/state/ecr — one file per contact or event, the same layout khard and khal read, so this replaces vdirsyncer without moving anybody's data somewhere only ecr can reach.

It needs an account to say so — [account.x.dav], an empty table being enough — and it needs more consent than mail. The token ecr holds for an account covers mail alone, deliberately: contacts and calendars are not something to ask for on behalf of somebody who will never sync them. Adding them is one trip through the browser:

ecr oauth authorize main --with-dav   # existing account
ecr oauth setup main --provider gmail --email you@gmail.com --with-dav

Without it every collection answers 403, and sync-dav says so and names that command. Google serves the two protocols from different hosts, so ecr carries a base per kind and discovers each separately; a single [dav] url is for the servers that serve both from one root, which is everybody else.

The same two things are buttons on the client's Accounts tab, beside each account's token state — but only when the client is running on the machine the server is. The flow sends the browser back to that machine's loopback address, so a phone that followed the link would consent perfectly and then wait for a callback it can never receive; rather than offer a button that cannot work, the phone is told where it can be done.

Microsoft is the exception with nothing to offer: Office 365 retired CalDAV and CardDAV in favour of Graph, so an Outlook account has no DAV and sync-dav says that rather than failing against a URL that cannot work.

Synced contacts join the composer's completion behind the addresses gathered from your mail, which are ranked by how often you have written to somebody.

It is read-only. Fetching an address book cannot lose anything; writing to one can, and an address book is not backed up the way a maildir is.

An invitation in a message is rendered where the message is — what, when, where and who from — and a cancellation says so rather than looking like an invitation. Accept, decline and tentative send a conforming METHOD:REPLY back to the organiser. One case is refused rather than half answered: a reply to a single occurrence of a repeating event needs a RECURRENCE-ID, and without one the reply answers the whole series — declining every Monday standup for the rest of the year on behalf of somebody who meant to miss one.

🔗Aliases, signatures and rules

An account can carry other addresses that deliver to it. A reply goes out as whichever of them the message was addressed to, the composer offers a picker where there is more than one, and the server refuses a From: the account does not own rather than quietly rewriting it. Signatures fall back from the alias to the account; an alias with an empty one has deliberately none.

A signature is written into the composer when a message is opened, under a -- line ecr adds — and above the quoted conversation in a reply, so it is not buried under the thread and carried down the page on every round. What is on screen is what is sent, so one message can go without it by deleting it there. Changing the From address after the composer is open does not swap it.

Rules are notmuch queries that tag new mail, edited on the Accounts tab and rendered into the post-new hook. They run top to bottom, see only mail that has just arrived, and by default take what they match out of the inbox — filing something and leaving it in the inbox is the one outcome nobody writes a rule for.

ecr account list shows every account and the state of every generated file — current, stale, edited or missing — and ecr doctor reports the same thing, so a change made on one machine and not applied on another is named rather than discovered.

The same is on the client's Accounts tab in settings, over /api/v1/managed. One thing is deliberately not reachable there: an account that authenticates with a password command can only be set at a terminal. That command is something the server would run, and the credential for reaching the API is a bearer token on a phone.

🔗While you are away

ecr account vacation on --body 'I am away until the 20th' --until 2026-08-20
ecr account vacation show      # is it on, what does it say, who has been told
ecr account vacation off       # keeps the message for next time

An autoresponder is the only thing in a mail client that sends mail nobody read first, so nearly all of it is about what it will not answer: mailing lists, bounces, other autoresponders, your own addresses, mail you were only Bcc'd on, and the same person more than once a week. That refusal list is the feature. A responder without it writes "I am on holiday" to every subscriber of every list you are on, from an address that then keeps doing it for a fortnight — and two responders without it talk to each other until a mail server starts refusing.

--until is what makes it stop by itself. Somebody who has to remember to switch it off is somebody who will, eventually, forget for a month.

Replies go through the same outbox as everything else, so one is visible before it goes and can be taken back. Who has been told is recorded in ~/.local/state/ecr/vacation-sent.json; ecr account vacation forget clears it, which answers everybody again.

🔗The outbox

Everything sent goes through a queue in ~/.local/state/ecr/outbox, held ten seconds by default — that hold is what undo is. While anything is in it, a strip above the thread list says so: what it is, when it goes, and, when a send has failed, why, with try again and discard beside it. A failure is also announced as a notification, because the reader believes the message is gone and nothing else will tell them otherwise.

That strip is the only place the queue is visible, deliberately: a message on its way is not something to go and look for. It matters most for a failure, because Sent will not show the message either — that copy comes back from the provider when the account next syncs, which is minutes later, or never for a provider that does not keep one.

Try again sends now rather than when the backoff says, and puts the attempt count back to zero: the wait doubles with each failure and eventually parks a message a day away, so a password that has just been fixed would otherwise look like a button that does nothing. A message already being sent cannot be discarded — it may have been delivered — so try again a moment later.

🔗What reaches the server when you file something

Three of the five marks cross by themselves. notmuch keeps unread, flagged and replied as maildir flags — S, F and R on the filename — and mbsync sends those as \Seen, \Flagged and \Answered.

inbox and deleted have no flag to live in, so until something moves the file they stop at a local tag: the message stays in the server's inbox and comes back on every other device. A generated pre-new hook closes that. It files deleted into Trash and spam into Junk, and moves anything no longer tagged inbox out of the inbox folder, before notmuch new scans — so the same run reindexes everything at its new path.

It is driven by tags, not by what ecr did, so a notmuch tag typed at a shell files exactly the same way the client does.

New accounts are created with expunge = "far", which is what makes the move take effect on the far side. Existing accounts are never rewritten: nobody's setup starts removing mail from a server because they upgraded. ecr doctor reports which of the two states each account is in, and ecr account file --dry-run shows what would move without moving it.

Gmail is the exception. Its archive is [Gmail]/All Mail, which holds a copy of every message in the account and which ecr deliberately does not sync — so there is nowhere to file an archived message to, and archiving on Gmail stays local. Deleting still files into [Gmail]/Trash.

🔗Signed and encrypted mail

ecr keeps no keys. Signatures are checked and encrypted mail is opened by your own gpg, using the keyring, the agent and the web of trust you already have. A second copy of a private key inside ~/.config/ecr is a worse thing to have than a missing feature, and a homegrown verifier that quietly disagrees with gpg --verify is worse than either.

So a machine without GnuPG shows signed mail unverified and ecr doctor says so. That is a state you can act on.

What a message says above the body is one of six things, not a padlock:

signed by …the signature is good
… with an expired / revoked keythe message really was signed, by a key that is no longer current. Amber, not red
this message has been alteredthe signature does not match these bytes. The only alarming state
signed, by a key you do not havethe ordinary condition of mail from a stranger. Deliberately the quietest of the six — a badge shown as broken here is one nobody reads anywhere
signed, but it could not be checkedusually no gpg on the machine
encryptedsaid separately from any of the above: encryption is who could read this, a signature is who wrote it, and a client that draws one padlock for both is wrong about half the mail it draws it on

Outgoing mail is signed or encrypted from the composer — three toggles, shown only when the server has a gpg, because offering a control that can only ever fail is worse than not offering it. It is chosen per message and never inherited, including on a reply to an encrypted message: encryption needs a public key for every recipient, and a reply silently protected that then cannot be sent fails at the moment the writer has stopped looking at it.

A key that has stopped working is the failure worth knowing about early, so ecr doctor warns when an account's own key can no longer be encrypted to or sign — an expired encryption subkey looks like a working account right up to the moment a message is sent, and gpg's own account of it (sign+encrypt failed: General error) names no address and no reason. ecr names the recipient and what is wrong with their key instead.

Encryption does not hide the subject line, or who the message is to. That is not a shortcut in ecr — it is what PGP/MIME is. Only the body and the attachments are hidden; the envelope announces who is talking to whom and what about.

🔗Tokens

ecr token new phone --qr --url http://<this machine>:8383   # token once, plus a QR
ecr token list
ecr token revoke phone

Tokens are stored as SHA-256 digests in ~/.config/ecr/tokens.toml (mode 0600). The plaintext is shown exactly once. With no tokens the API is unauthenticated.

The file is re-read when it changes, so ecr token new and ecr token revoke take effect on a server that is already running. They did not before: the store was read once at startup, and ecr token new is a different process — it writes the file and exits, and the server went on checking against the copy it had loaded. The client reported the server refused that token about a token printed a moment earlier, and nothing on either side connected the two; the fix was to restart a server nobody had any reason to suspect. Revoking had the matching failure, which is the worse one: a device the reader believed they had cut off stayed connected until the next restart.

What is checked is the file's mtime and size, so an ordinary request pays one stat and the file is read only when it has actually moved. A read that fails is kept rather than adopted — the file is truncated before it is rewritten, and taking a partial read for an empty store would switch authentication off at the exact moment someone is issuing a token. The store is re-read before the server asks whether it needs a token at all, so issuing the first one starts requiring one immediately rather than leaving the API open until a restart.

Issuing the first one turns authentication on for everything, including the web client the server itself serves: a browser opened at the server's address has the right URL and no token, so every request is refused. The client says so rather than appearing broken — this device is not authorised, over the panes, with a field to paste what ecr token new printed. The token is checked against the server before it is kept, so a mistyped one is reported in the prompt instead of being saved to leave every pane empty; once accepted it is stored on that device and the mail loads without a reload. Dismiss the prompt to go and fetch a token — the thread list keeps an enter a token button for the way back.

Where there is a camera the prompt also offers Scan a pairing code. This is the only screen that asks for a token, and on a phone the alternative is 64 hex characters on a soft keyboard; the scanner used to be offered on the address prompt alone, so a phone that could reach its server never saw it. The address is already right here — this server is the one that refused the device — so a code carrying nothing but a token is enough, which is what --qr prints without --url. One that does carry an address is still honoured, and replaces this one: the address is applied and probed first, then the token, since asking the old server whether the new one's token is good either refuses something valid or leaves the device pointed where the reader has just stopped meaning.

A refusal is not the same as silence, and the client keeps them apart. It asks /api/v1/health, the one route authentication does not cover, so it can tell a server that answered and will not talk to this device from an address where nothing is listening at all. The first gets the token prompt above; the second gets cannot reach the server, with the address in a field. That prompt is reachable everywhere — the browser takes its address from the page it was served by and the desktop and Android shells are handed one, so before it existed a client pointed at the wrong host had no way to be pointed at the right one. The address is probed before it is kept, and the token is carried across, since an address is usually changed to reach the same server by another name — localhost from the machine it runs on, an address on the network from a phone.

It raises itself only when this client has never reached the address it was given, and only once per address. A server that was reached and then lost is a laptop that slept or a phone in a tunnel; the thread list says so and offers change address, without a dialog over mail that is still on screen.

🔗What the server says about itself

/api/v1/health is the same report ecr doctor prints, so the client can show it. The server refuses to start on a failing check, which means what reaches a running client are the warnings it started anyway with — an expired OAuth token, a post-new hook that is not wired up, a notmuch without index.header.List. Each is otherwise experienced as mail that quietly does not arrive or a sidebar section that is quietly empty, so the status bar carries a ! n checks badge that opens the report, each check beside the doctor's own hint for it. They are fixed on the machine holding the mail, not in the client.

🔗OAuth

Gmail and Outlook will not accept a password. ecr oauth holds a profile per account — which provider, which address, which client — and hands out the XOAUTH2 that mbsync and msmtp ask for.

ecr oauth setup main --provider gmail --email you@gmail.com
ecr oauth setup work --provider microsoft --email you@example.com

ecr oauth status main       # provider, address, expiry, whether it can refresh
ecr oauth authorize main    # run the flow again

setup writes the profile and walks straight into the flow. --flow picks how: auto — the default — takes the device flow where the provider offers one, which is what Microsoft gets, and the browser flow otherwise.

Then point your configs at it. The token is refreshed on demand, so these are the only two lines the setup needs:

PassCmd "ecr oauth token main"      # mbsyncrc
passwordeval ecr oauth token main   # msmtp

Profiles live in ~/.config/ecr/oauth/<profile>.json; the tokens themselves are kept apart in ~/.local/state/ecr/oauth/<profile>.json, mode 0600. A profile left over from the oauthman helper ecr used to shell out to is adopted out of ~/.config/oauthman the first time it is read, so an existing setup keeps its refresh tokens rather than authorizing every account again. It is copied, not moved.

No client is registered for ecr: like every other desktop mail client, it borrows Thunderbird's, which ecr oauth client-id --provider gmail will print. Bring your own with --client-id and --client-secret.

🔗Running

ecr serve --bind 127.0.0.1:8383
FlagEffect
--bindAddress to listen on. Use the tailnet address to reach it from a phone
--read-onlyRefuse every write: no tagging, syncing or sending. Good for a first run against real mail
--no-watchDo not watch the maildir; new mail then needs an explicit sync
--web-dirWhere the built client lives. Found automatically; ECR_WEB_DIR also works
--allowed-originRestrict browser origins. Repeatable. Default allows any — see architecture.md
--tokensAlternate token store path

🔗When mail actually syncs

Three things start a sync, and they cover different ground:

WhatScopeWhen
Push — an IMAP IDLE per accountthat one accountthe moment its inbox receives something
The periodic syncevery account, every folderevery 30 minutes
Pressing sync in the clientthe account the current view nameswhen you ask

Push is what makes new mail feel instant, and it is deliberately narrow: it watches the inbox and nothing else. So everything you do in another client — archiving, deleting, relabelling, marking read — happens where nothing is listening and produces no notification at all. That is what the periodic sync is for, and it is the only thing that reconciles a folder no mail arrives in. Without it a message archived on the web stays in ecr's inbox until new mail happens to arrive.

Both are skipped under --read-only and --no-watch, which is what that flag means: do not go looking on your own.

ecr doctor's imap push line reports the connections, not the configuration. A running server writes what each watch is doing to ~/.local/state/ecr/watch.json on a one-minute heartbeat, and doctor reads it, so the line distinguishes four situations that used to look identical:

The line saysWhat it means
… connected with IDLEthe connections are up right now
… connectingdialling; normal for a few seconds after a restart
not connected: <account> (<reason>, for <time>)the server is up and being refused. Mail still arrives on the 30-minute reconcile. Refused for hours is usually the token — ecr oauth status <account>
… configured; no running server is reportingnobody is confirming it. Either no server is running, or one is running that predates this file

The heartbeat is what makes the difference: the report outlives the process that wrote it, so a timestamp that has stopped moving reads as nobody is saying rather than as the last thing anybody said. An account added to accounts.toml after the server started is named too — the accounts file is read once, at startup, so it is watched by nothing until ecr serve is restarted.

A manual sync follows the view on screen, because syncing an account fetches every one of its folders and doing all of them to refresh the one being read is most of a minute of somebody else's mail. A view that names no account — a combined inbox, a saved query across accounts — asks for all of them, since it is wrong the moment any one of them is stale. This changes what a manual sync costs, never how fast mail arrives: every account keeps its own watch.

🔗The client is served by the server

ecr serve serves the built web client at / alongside the API. Opening http://127.0.0.1:8383 gives you the whole app: same origin, so CORS never applies and the client defaults its API base to wherever it was loaded from.

It looks for web/dist relative to the working directory and then beside the binary. If it cannot find one it serves a page that says so. Build it with just build-web.

The desktop shell embeds its own copy of the same client. A client that has never been given an address starts at http://localhost:8383, which is where a desktop install's own server is; after that the address is the client's, kept on the device and changed from the app. ECR_SERVER_URL overrides it for a single launch and is meant for development. If the server is not running the app says so in its own UI, with a retry.

The Android app starts at that same address, which is never right on a phone, so it says it cannot reach a server and offers the camera: pair it with the code from ecr token new --qr. That address is then kept, and the app opens on your mail from then on.

🔗Configuration

~/.config/ecr/server.toml, all optional:

notmuch_config = "/home/you/.config/notmuch/default/config"
mbsync_config  = "/home/you/.config/isyncrc"
msmtp_config   = "/home/you/.config/msmtp/config"
maildir_root   = "/home/you/.local/share/Mail"

# Absolute paths are worth pinning in a systemd unit, where PATH is minimal.
notmuch_bin = "/run/current-system/sw/bin/notmuch"
mbsync_bin  = "/run/current-system/sw/bin/mbsync"
msmtp_bin   = "/run/current-system/sw/bin/msmtp"

# Send every read back to notmuch instead of the SQLite mail index. Slower, and
# answers the same — the switch exists so a suspected disagreement can be
# settled without rebuilding or reinstalling anything.
index = false

🔗The mail index

ecr serve builds a SQLite mirror of what notmuch knows at ~/.local/state/ecr/index.sqlite3 and answers searches and counts from it. It is a cache: deleting the file is safe and costs one rebuild on the next start. Nothing else has to be told, and no mail state lives there.

ecr doctor reports its size and how far behind it is. A first build on a large inbox takes a few seconds and happens before the server starts listening; after that it catches up only on what changed.

🔗Reaching it from a phone

The server binds to a single address. Bind it to the tailnet address, install Tailscale on the phone, and point the client at http://<tailnet-name>:8383 with a device token. Do not expose it to the public internet without putting TLS and a reverse proxy in front — this is a mail store.

🔗Android

An APK is built by CI on every tagged release and attached to it. Sideload it; there is no Play Store listing. An AAB is attached to the same release, which is the only format Play accepts if there ever is one.

It offers itself as a mail client: a mailto: link tapped in another app opens a draft here, prefilled. That is a SENDTO/VIEW intent filter in the overlay manifest, and the same handling covers the desktop, where it is MimeType=x-scheme-handler/mailto; in the desktop entry.

New mail is announced through the system's notifications while the app is open, controlled by the device setting Notify new mail. There is no background service and the server never reaches out to a client, so nothing is announced while ecr is closed — on a phone that means notifications are worth much less than they are on a desktop you leave running.

The app talks to your server over plain HTTP unless you have gone to the trouble of giving it a certificate. I made that choice deliberately; it is configured in shell/android/overlay/.../network_security_config.xml: the server address is whatever you type in, a LAN or tailnet address can hold no public-CA certificate, and requiring HTTPS would mean requiring a private PKI before the app could fetch one message. The bearer token is what protects the API; keep it on a tailnet, which is encrypted a layer below.

It is a client only. ecr-server shells out to notmuch, mbsync and msmtp, none of which exist on Android, so the app points at a server you run elsewhere — over Tailscale, typically. Pair it by scanning: run ecr token new phone --qr --url http://<tailnet-addr>:8383 on the server and point the phone's camera at the code. There are three ways in, for the three states a phone can be in: the cannot reach the server prompt on first launch, the this device is not authorised prompt when it can reach a server it has not been paired with, and Settings → Server → Scan a code later, to move it to another server.

The code carries the address and the token together, which is the whole point: a tailnet hostname and a 64-character hex token are the two worst things to type on a phone. --url is separate from --bind because the address a phone must reach is rarely the socket the server listens on — 0.0.0.0 is every address and 127.0.0.1 is only that machine, so neither goes in a code. Given neither --url nor a usable ECR_BIND, the code carries the token alone and says so, which is what every code printed before this did.

If the release APK is unsigned — which it is until the signing secrets are set, and the release notes say which — Android will refuse to upgrade it in place from a later signed build. Uninstall first in that case. See releasing.md.

🔗Checking for a newer version

A sideloaded APK has no idea it has been superseded, so the Updates section of the device settings asks: it fetches the newest GitHub release, compares the tag with this build's version and offers the download.

It is a button, never a timer. Nothing about this happens in the background — the unauthenticated GitHub API allows 60 requests an hour per address, which is plenty for someone pressing a button and not enough for polling, and a mail client quietly reaching a code host is a surprise to whoever is reading the network.

The section exists only on Android, and its absence elsewhere is the point. apk_version in the shell answers None on every other target, so a deb, an AppImage, the Nix package and the browser client show nothing: those are updated by whatever installed them, and offering to replace a package the system manages is not this app's business.

The download opens in the browser rather than installing in place. Fetching the APK here and handing it to the installer would mean REQUEST_INSTALL_PACKAGES and a FileProvider in the manifest; the browser reaches that same system installer with no new permission at all. An -unsigned.apk asset is never offered — v0.1.1 shipped one, and it cannot install over a signed build, so pointing anyone at it would break a working app rather than update it.

🔗Running it on a device you have plugged in

just android

That builds the web client, starts a server if one is not already listening, builds a debug APK for the ABI the phone actually reports, installs it with adb install -r, launches it and streams its logs until you interrupt it. cargo tauri android init runs the first time, if shell/gen/android is not there yet.

The phone reaches the server over the cable: adb reverse forwards localhost:8383 on the device to this machine, which is the shell's built-in default address, so nothing is baked into the APK and no device token is needed. ECR_BIND can move the host port; the device side stays 8383.

It is cargo tauri android build, not android dev. A dev build on mobile proxies every asset request through reqwest to get_app_url(), and with no devUrl that resolves to the webview's own http://tauri.localhost — the app asks itself for the page over HTTP and paints error sending request for url. --no-dev-server does not turn that off; only a build without the dev cfg does, and it then reads web/dist out of the binary the way the desktop shell does. So the UI is embedded in the APK: a web change means running the recipe again, but the phone needs nothing but the cable.

The target is chosen from ro.product.cpu.abi rather than assumed — arm64-v8a and x86_64 are the two the flake carries a Rust std for, and any other ABI stops the recipe with that fact instead of failing inside Gradle.

The device needs USB debugging on, and the authorisation prompt accepted — the recipe stops with what to do if adb devices reports nothing usable.

🔗The toolchain

The Android SDK and NDK are deliberately not in the default dev shell: it is a multi-gigabyte closure and nothing else needs it. They live in a second shell, which just android enters for you.

nix develop .#android    # SDK, NDK, JDK 17, adb, cargo-tauri, the android rust target
cargo tauri android build --apk

The SDK is unfree and its licence has to be accepted, so that shell imports its own nixpkgs rather than loosening the one every other build goes through. A Nix SDK is read-only, so whatever the Tauri template asks for has to be pinned in flake.nix in advance — platforms 34-36, the matching build tools, and the aapt2FromMavenOverride that stops Gradle running a binary that cannot execute on NixOS.

bundle.android.minSdkVersion is set to 28 in shell/tauri.conf.json.

🔗Installing a release

installing.md is the full account — the two channels, the Home Manager module and what each artifact carries. In short:

MethodCommand
Nix, tracking mainnix profile install github:lokeshmohanty/ecr
Nix, newest releasenix profile install github:lokeshmohanty/ecr/release
Home Managerprograms.ecr.enable = true; — installs and can run the user service
NixOS moduleservices.ecr.enable = true; — a system service, for a machine nobody logs into
Debian/Ubuntusudo apt install ./ecr_amd64.deb — the desktop client, not the server
Generic Linuxuntar the release tarball; bin/ecr finds share/ecr/web beside it, and share/systemd/user/ecr.service starts it
From sourcecargo install ecr-cli — builds the binary only, not the web client

🔗Troubleshooting

SymptomCheck
Server refuses to startecr doctor — it names the failure and a fix
Client says it cannot reach the serverNothing answered at that address. Is ecr serve running, and is the address the machine's own rather than localhost from another device? The prompt takes a new one
Client says the device is not authorisedThe server answered and refused it. Paste a token from ecr token new, or scan one where there is a camera; if that keeps failing, check the address in the same prompt — another ecr would refuse it too
A token ecr token new just printed is refusedFixed: the store is re-read when the file changes, so this no longer needs a restart. On a build before that, restart ecr serve — it had loaded the token file once, at startup
A setting will not stickThe status bar says settings: not saved — …. A read-only server (--read-only) refuses the write
A message reads "could not be read"notmuch has it indexed and the file is gone or unreadable. notmuch new after fixing the maildir
Empty inbox, no errorThe query. /api/v1/threads?q=* should return everything
503 responsesA binary is missing from the service's PATH; pin absolute paths in server.toml
Sync fails with an auth errorecr oauth status <account>; the token may need reauthorizing with ecr oauth authorize <account>
An account added in the client does not syncAdding it writes the configuration; it still needs a token. ecr oauth setup <profile> --provider gmail --email …, then sync
ecr doctor says a managed file is staleaccounts.toml moved on and the generated files did not. ecr account apply — though ecr serve now does this for you at startup, so a stale line on a running server means the file was written after it started
Mail stops being filed straight after upgrading ecrIt should no longer be possible: ecr serve regenerates what it has moved past at startup. On a build before that, ecr account apply then ecr notmuch new. The symptom is ecr notmuch count tag:new above zero with a tag:inbox that has stopped advancing
ecr doctor says theme presets are older than this ecrA palette gained a colour role in a release and seeding never overwrites a file you already have, so the client falls back to a built-in colour for it. Delete the named file to get the current one, or add the listed roles to your edited copy
ecr doctor says a managed file was editedSomebody edited a generated file. Put the change in accounts.toml; the next apply backs the edit up and replaces it
notmuch in your shell disagrees with ecrManaged mode puts the notmuch config in ecr's directory. ecr notmuch <args>
Sync fails with selected SASL mechanism(s) not available, and mbsync run by hand worksecr ran a different mbsync. systemctl --user show -pEnvironment ecr and compare the first mbsync on that PATH with command -v mbsync in your shell; the XOAUTH2 plugin comes from your own wrapper, not from ecr. Pinning mbsync_bin in server.toml settles it
New mail does not appearWas the server started with --no-watch? Otherwise check the log for watcher warnings
A message is in the webmail but not in ecrAlmost never a sync problem — check ecr doctor's account tags line first. Mail indexed without the post-new hook running carries no account tag, and every account view filters on it, so the message is on disk and in notmuch while being invisible in the client. Doctor names the account and the count; the repair is by path, notmuch tag +<account> -- path:"<account>/**" and not tag:<account>, because the hook itself is keyed on tag:new and cannot reach mail whose new was already cleared. The generated hook now applies the account tag ungated, so this heals itself on the next notmuch new
Tags silently do nothingnotmuch tag --batch exits 0 on malformed input; ecr-store validates first, so a 400 here is the intended behaviour
A list looks wrong, and you suspect the indexDelete ~/.local/state/ecr/index.sqlite3 and restart, or set index = false in server.toml to take notmuch's answer directly. If both agree, the index was not it