Two channels are published from this repository, and they differ only in which commit you get.
| Channel | Flake ref | What it is |
|---|---|---|
| release | github:lokeshmohanty/ecr/release | the newest tagged release. CI fast-forwards this branch to each tag once every artifact for it has built |
| main | github:lokeshmohanty/ecr | whatever main is at. Everything on it has passed just check in CI, and nothing else is promised |
Neither is special-cased in the code: release is a branch that points at a
tag, and both build the same derivations from the same flake.nix.
🔗Telling them apart
Both channels can sit at the same Cargo version for weeks, so the version alone
cannot answer which one is installed. Every Nix build stamps the revision into
the version string, and ecr --version reports it:
$ ecr --version
ecr 0.1.1+20260803.a1b2c3d
The same string is what nix profile list shows. 0.1.1 is the release the
tree is at or after; the date and the short revision say exactly which commit.
A build from a source tarball or plain cargo install has no revision to stamp
and reports 0.1.1.
🔗Home Manager
The recommended route, and the one that tracks a channel properly. Point the input at whichever channel you want:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager.url = "github:nix-community/home-manager";
# Track main. For the stable channel:
# ecr.url = "github:lokeshmohanty/ecr/release";
ecr.url = "github:lokeshmohanty/ecr";
ecr.inputs.nixpkgs.follows = "nixpkgs";
};
}{ inputs, ... }:
{
imports = [ inputs.ecr.homeManagerModules.default ];
programs.ecr = {
enable = true;
desktop = true; # also install ecr-desktop, the Tauri client
server = {
enable = true; # run `ecr serve` as a systemd user service
bind = "127.0.0.1:8383";
};
};
}
Switching channel is one line, and nix flake update ecr moves whichever one
you are on. On the release channel that means the newest release, with no edit
per release; on main it means the newest commit.
🔗Why a user service
The maildir, the notmuch database, the mbsync and msmtp configs and the token
store all live in $HOME, and Xapian has to be able to write there. A system
service would have to be told to run as you anyway — which is exactly what the
NixOS module's services.ecr.user option does, and why it cannot enable
ProtectHome.
Use the NixOS module instead when the server should come up at boot without anyone logging in; use this one otherwise.
🔗Options
| Option | Default | Meaning |
|---|---|---|
programs.ecr.enable | false | install ecr |
programs.ecr.package | the flake's ecr | the server and CLI |
programs.ecr.desktop | false | also install ecr-desktop. Links WebKitGTK; leave off on a headless machine |
programs.ecr.desktopPackage | the flake's ecr-desktop | which desktop client |
programs.ecr.server.enable | false | run ecr serve as a systemd user service |
programs.ecr.server.bind | 127.0.0.1:8383 | address to listen on |
programs.ecr.server.readOnly | false | refuse every write |
programs.ecr.server.extraArgs | [ ] | further arguments for ecr serve |
programs.ecr.server.environment | { } | extra environment for the service |
ecr doctor has to pass before the service will start. Check it after the first
switch:
systemctl --user status ecr
journalctl --user -u ecr -e🔗NixOS
For a machine that should serve mail without anyone logged in:
{
imports = [ inputs.ecr.nixosModules.default ];
services.ecr = {
enable = true;
user = "lokesh"; # whose maildir is served
bind = "127.0.0.1:8383";
};
}
The same channel choice applies — the input's ref is what decides it.
The package carries no notmuch, mbsync or msmtp of its own, and a
system unit's PATH does not reach the served user's profile, so name them:
services.ecr.path = with pkgs; [
notmuch
(isync.override { withCyrusSaslXoauth2 = true; })
msmtp
];
Name the same derivations that user's own configuration uses. A second copy is
not the same binary: XOAUTH2 is a separate SASL plugin, and a plain mbsync
fails every OAuth account with selected SASL mechanism(s) not available out of
a configuration that syncs by hand. The Home Manager module needs nothing here
— a user unit's PATH carries that user's own profiles already.
🔗nix profile
Fine for trying it; it does not track a channel on its own, and an upgrade is a command you have to remember to run.
nix profile install github:lokeshmohanty/ecr#ecr # main
nix profile install github:lokeshmohanty/ecr/release#ecr # newest release
nix profile upgrade ecr
Installing both at once does not work: each provides bin/ecr and the profile
refuses the collision. Pick a channel, or use nix run for the other:
nix run github:lokeshmohanty/ecr#ecr -- doctor🔗The binary cache
flake.nix sets lokeshmohanty.cachix.org as a substituter, and CI pushes to
it on every push to main. Without it, tracking the main channel means
compiling the Rust server and the web client on every update.
Nix will ask to trust the substituter the first time. To accept it ahead of time:
nix.settings = {
substituters = [ "https://lokeshmohanty.cachix.org" ];
trusted-public-keys = [
"lokeshmohanty.cachix.org-1:XkCPbX2XsKzlr0P/MecvqruyTeOA8SzJzwMcCOfuLuI="
];
};🔗Installing the client as an app
The browser client carries a web app manifest, so a Chromium-family browser
will offer to install it: its own window with no browser chrome, an icon and a
launcher entry, and mailto: links handed to it the way the desktop package
registers for them. It is the same bundle the server already serves, so there
is nothing extra to build or update — and on Linux it runs on Chromium rather
than the WebKitGTK the desktop package embeds, which is a different and
generally faster engine.
Firefox no longer installs web apps on the desktop, so this means a Chromium-family browser in practice.
🔗It needs an origin the browser trusts
Installing, starting without a network, notifications and copying to the system clipboard are all withheld on a plain-HTTP origin. This is the browser's policy about the address, not something ecr can opt out of, and each one fails by being absent rather than by refusing — so the effect is a switch that does nothing and no install button anywhere. The settings page says so when it applies.
http://localhost and http://127.0.0.1 are trusted by definition, so none of
it applies on the machine running the server. Reaching ecr from anywhere else
over http:// is where it bites, and there are two ways out.
Put it behind HTTPS. ecr serve speaks plain HTTP and terminates no TLS
itself, so this is a proxy in front of it. On a tailnet it is one command and
needs no certificate handling at all:
ecr serve --bind 127.0.0.1:8383
tailscale serve --bg https / http://127.0.0.1:8383
That publishes https://your-host.your-tailnet.ts.net with a real certificate,
reachable from every device on the tailnet and from none off it. Any other
reverse proxy does the same job; tailscale cert will issue the certificate if
you would rather run one yourself.
Or tell the browser to trust the address. Appropriate for a machine on a LAN you control, and it changes nothing on the server:
chromium \
--unsafely-treat-insecure-origin-as-secure=http://mail.lan:8383 \
--user-data-dir=/tmp/ecr-profile
The origin must be written exactly as you reach it, scheme and port included.
chrome://flags/#unsafely-treat-insecure-origin-as-secure is the same setting
without the command line, and OverrideSecurityRestrictionsOnInsecureOrigin is
the enterprise policy for machines you manage.
🔗Keeping up with a new build
The browser client is served off disk by ecr serve, so upgrading the server
upgrades it: the next load gets the new bundle, and an installed app is no
different from a tab. Only the content-hashed files under assets/ are cached
for any length of time, and their names change when their contents do.
Before 0.6.1 they were not marked that way, and a browser could get stuck. A
Nix store path dates every file to 1970, and a response with a validator but no
cache-control invites the browser to guess a lifetime from the age — which
worked out to about five and a half years. A browser that had loaded the client
once went on serving that copy from disk, so the desktop and Android apps
updated and the browser did not, and a copy cached before the manifest existed
had no install button either. If you are on one of those, load it once with the
cache bypassed — Ctrl+Shift+R, or Clear site
data in devtools' Application tab — and it will not happen again.
🔗What it does not replace
The Android app, which carries the QR scanner that pairing uses, the intent
filters that make it a mail handler, and the F-Droid listing. And on the
desktop it takes its token the way a browser does — from ?token= when the
server opens it for you, or from the pairing code — rather than being handed
one by the shell.
🔗Not Nix
The release artifacts are on the latest release; there is no rolling channel for them, because there is no tag to build one from.
The tarball carries the binary, the web client, a man page, shell completions for bash/zsh/fish, and a systemd user unit:
tar xzf ecr-x86_64-unknown-linux-gnu.tar.gz
cd ecr-x86_64-unknown-linux-gnu
./bin/ecr doctor
install -Dm755 bin/ecr ~/.local/bin/ecr
install -Dm644 share/systemd/user/ecr.service ~/.config/systemd/user/ecr.service
systemctl --user daemon-reload
systemctl --user enable --now ecr
The unit's ExecStart points at ~/.local/bin/ecr and its PATH has to reach
notmuch, mbsync and msmtp — plus ecr itself, if your mbsync or msmtp
config authenticates with ecr oauth token, because mbsync is a child of the
server and resolves it off PATH like anything else. A user unit inherits
nothing from the login shell.
The .deb and the AppImage carry the desktop client, not the server. They
install the desktop entry, the icons and the AppStream metadata, and drop a copy
of the unit at /usr/share/doc/ecr/ecr.service; the server still comes from the
tarball or from Nix.