fix: fallback uses mbsync --pull to avoid re-uploading mbrts-delivered mail

Also documents the flags/deletions-only scheduled-mbsync approach using
command-line operators instead of a restricted Sync directive.
This commit is contained in:
Adriano Caloiaro 2026-07-20 18:11:23 -06:00
parent c7c341264f
commit f11a0926e5
No known key found for this signature in database
2 changed files with 76 additions and 58 deletions

128
README.md
View file

@ -1,51 +1,16 @@
# mbrts # mbrts (mailbox real-time sync)
Real-time mail delivery for maildirs via Fastmail's JMAP push API. Real-time mail delivery for maildirs via Fastmail's JMAP push API.
Connects to Fastmail's JMAP EventSource stream and delivers new messages directly to a local Maildir the moment they arrive without polling. Connects to Fastmail's JMAP EventSource stream and delivers new messages directly to a local Maildir the moment they arrive without polling. On first run, or when saved JMAP state is stale, mbrts falls back to `mbsync --pull` to backfill anything missed, then takes over from that point.
## How it works ## Prerequisites
On first run, or when the saved JMAP state is stale, mbrts falls back to `mbsync` for a full sync, then takes over from that point. Create a Fastmail API token at **Settings → Security → API Tokens**. This is distinct from an IMAP app password — mbrts requires a proper JMAP bearer token.
## Setup ## Setup
Create a Fastmail API token at **Settings → Security → API Tokens**. ### Nix / home-manager
### Config file
By default mbrts reads `$XDG_CONFIG_HOME/mbrts/config.yaml` (e.g. `~/.config/mbrts/config.yaml`):
```yaml
accounts:
- name: Personal
maildir_path: /home/you/.mail/Personal
password_command: "pass show fastmail/api-token"
```
Each account requires exactly one token source:
| Field | Description |
|---|---|
| `password_command` | Shell command that prints the token to stdout |
| `token_file` | Path to a file containing the token |
| `token_env` | Name of an environment variable containing the token |
Optional fields: `mbsync_account` (mbsync channel/group name; defaults to `name`), `log_level` (top-level: `debug`, `info`, `warn`, `error`).
### CLI flags
Every config file option has an equivalent flag. Flags override the config file. A single account can be configured entirely on the command line without a config file:
```
mbrts --name Personal \
--maildir-path ~/.mail/Personal \
--password-command "pass show fastmail/api-token"
```
Run `mbrts --help` for the full flag list, or `mbrts --version` to print the version.
### Nix
Add mbrts as a flake input: Add mbrts as a flake input:
@ -56,21 +21,11 @@ inputs.mbrts = {
}; };
``` ```
The flake exposes: Add the overlay and module, then configure the service:
| Output | Description |
|---|---|
| `packages.*.default` | The `mbrts` binary |
| `overlays.default` | Adds `pkgs.mbrts` |
| `homeManagerModules.default` | The `services.mbrts` home-manager module |
### home-manager module
Add the overlay and module to your home-manager configuration, then configure the service:
```nix ```nix
# in your flake outputs # in your flake overlays
overlays = [ inputs.mbrts.overlays.default ]; inputs.mbrts.overlays.default
# in your home-manager module list # in your home-manager module list
inputs.mbrts.homeManagerModules.default inputs.mbrts.homeManagerModules.default
@ -88,23 +43,55 @@ services.mbrts = {
}; };
``` ```
The module generates a config file and runs mbrts as a `systemd` user service. Logs are available via `journalctl --user -u mbrts -f`. The module generates a config file and runs mbrts as a systemd user service. Logs: `journalctl --user -u mbrts -f`.
Module options: **Module options:**
| Option | Type | Default | Description | | Option | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `enable` | bool | — | Enable the service | | `enable` | bool | — | Enable the service |
| `package` | package | `pkgs.mbrts` | Package to use | | `package` | package | `pkgs.mbrts` | Package to use |
| `logLevel` | enum | `"info"` | `debug`, `info`, `warn`, or `error` | | `logLevel` | enum | `"info"` | `debug`, `info`, `warn`, or `error` |
| `accounts` | list | `[]` | List of account submodules (see config file fields above) | | `accounts` | list | `[]` | List of account submodules (see fields below) |
## Build **Account fields** (each account requires exactly one token source):
| Field | Description |
|---|---|
| `name` | Account name; used for state file and default mbsync channel |
| `maildirPath` | Path to the local Maildir root |
| `passwordCommand` | Shell command that prints the bearer token to stdout |
| `tokenFile` | Path to a file containing the bearer token |
| `tokenEnv` | Environment variable containing the bearer token |
| `mbsyncAccount` | mbsync channel/group name for fallback (defaults to `name`) |
### Standalone
Build the binary:
``` ```
CGO_ENABLED=0 go build -o mbrts . CGO_ENABLED=0 go build -o mbrts .
``` ```
By default mbrts reads `$XDG_CONFIG_HOME/mbrts/config.yaml` (e.g. `~/.config/mbrts/config.yaml`):
```yaml
accounts:
- name: Personal
maildir_path: /home/you/.mail/Personal
password_command: "pass show fastmail/api-token"
```
Every config file option has an equivalent CLI flag, and flags override the config file. A single account can be configured entirely on the command line:
```
mbrts --name Personal \
--maildir-path ~/.mail/Personal \
--password-command "pass show fastmail/api-token"
```
Run `mbrts --help` for the full flag list, or `mbrts --version` to print the version.
## aerc ## aerc
Configure the account source as `maildir://` so aerc picks up delivered messages immediately via inotify: Configure the account source as `maildir://` so aerc picks up delivered messages immediately via inotify:
@ -112,3 +99,30 @@ Configure the account source as `maildir://` so aerc picks up delivered messages
```ini ```ini
source = maildir:///home/you/.mail/Personal source = maildir:///home/you/.mail/Personal
``` ```
## Using mbrts alongside mbsync
mbrts handles inbound delivery; mbsync is still useful for pushing local changes (read flags, deletions, moves) back to Fastmail. They can coexist, but require careful configuration to avoid duplicate messages.
The problem is that mbsync tracks synced messages by IMAP UID in its own state database. It knows nothing about messages mbrts delivered directly to the Maildir. So a scheduled mbsync run that pulls new messages re-downloads mail mbrts already wrote (local duplicates), and a run that pushes new messages re-uploads that same mail to the server (remote duplicates).
The solution is to keep new-message propagation out of the scheduled run entirely, and constrain it to flag changes and deletions in both directions. mbsync's command-line operators override the channel's `Sync` directive per-invocation, so no separate config file is needed — run the scheduled sync as:
```
mbsync --gone --flags --all
```
`--gone` propagates deletions and `--flags` propagates flag changes (read/unread, etc.), each in both directions, with no `New` in either direction. Local reads and deletions flow up to Fastmail; webmail reads and deletions flow down to the Maildir; new mail is left entirely to mbrts.
mbrts's own fallback (`mbsync --pull`) covers the inbound side: on first run, or when its JMAP state is stale, it pulls any messages it missed. Point mbrts at the channel via `mbsync_account` (defaults to the account name); no manual initial sync is required.
### With home-manager
The generated systemd service runs `mbsync --all` by default, which does a full sync. Override its `ExecStart` to use the flags/deletions-only operators:
```nix
systemd.user.services.mbsync.Service.ExecStart =
lib.mkForce "${pkgs.isync}/bin/mbsync --gone --flags --all --verbose";
```
Leave the channel `Sync` directive at its default (`Full`) — both the scheduled service and mbrts's fallback override it on the command line, so it is never used as configured.

View file

@ -411,10 +411,14 @@ func buildAuth(a AccountConfig) (string, error) {
return "Bearer " + token, nil return "Bearer " + token, nil
} }
// runMbsync backfills messages that arrived while mbrts was not running. It
// pulls only: mbrts delivers to the Maildir out of band, so a full sync would
// see those messages as new local mail and push them back up to the server as
// duplicates. --pull propagates new messages far-to-near without any push.
func runMbsync(a AccountConfig) { func runMbsync(a AccountConfig) {
ch := a.mbsyncChannel() ch := a.mbsyncChannel()
slog.Info("running mbsync", "account", a.Name, "channel", ch) slog.Info("running mbsync", "account", a.Name, "channel", ch)
if out, err := exec.Command("mbsync", ch).CombinedOutput(); err != nil { if out, err := exec.Command("mbsync", "--pull", ch).CombinedOutput(); err != nil {
slog.Error("mbsync failed", "channel", ch, "err", err, "output", string(out)) slog.Error("mbsync failed", "channel", ch, "err", err, "output", string(out))
} }
} }