Installing

The two published channels, Home Manager, NixOS, and what each release artifact carries.

Two channels are published from this repository, and they differ only in which commit you get.

ChannelFlake refWhat it is
releasegithub:lokeshmohanty/ecr/releasethe newest tagged release. CI fast-forwards this branch to each tag once every artifact for it has built
maingithub:lokeshmohanty/ecrwhatever 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

OptionDefaultMeaning
programs.ecr.enablefalseinstall ecr
programs.ecr.packagethe flake's ecrthe server and CLI
programs.ecr.desktopfalsealso install ecr-desktop. Links WebKitGTK; leave off on a headless machine
programs.ecr.desktopPackagethe flake's ecr-desktopwhich desktop client
programs.ecr.server.enablefalserun ecr serve as a systemd user service
programs.ecr.server.bind127.0.0.1:8383address to listen on
programs.ecr.server.readOnlyfalserefuse 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.