jma (the binary; crate name jma-mail) is JM's Mail Agent: it syncs your local Maildir mailboxes with a JMAP mail server such as Fastmail, like isync/mbsync but for JMAP instead of IMAP.
jma supports syncing a Maildir mailbox that was downloaded via mbsync or offlineimap -- point it to the directory containing your Maildir mailboxes and it should pick up where mbsync or offlineimap left off.
This project is in a very early state, though I'm using it for my own mail. Use it at your own risk.
# Pre-populate the config file (the Maildir tree is created on the first sync)
$ jma init
# Edit the config file in ~/.config/jma/config.toml
# Run a bidirectional sync (below invocation is equivalent to running `jma sync`)
$ jma
# ... Or, if you want push email plus watching for any local changes to sync to upstream
$ jma watchPre-built binaries for macOS (Apple Silicon and Intel), Linux (x86_64 and aarch64), and Windows (x86_64) are published on the GitHub Releases page for each tagged release. Three install paths, pick whichever fits:
-
Shell installer (macOS, Linux). Downloads the right binary for your platform and drops it in
$CARGO_HOME/bin(default~/.cargo/bin):curl --proto '=https' --tlsv1.2 -LsSf https://github.com/jmibanez/jma-mail/releases/latest/download/jma-mail-installer.sh | sh
-
PowerShell installer (Windows).
powershell -ExecutionPolicy Bypass -c "irm https://github.com/jmibanez/jma-mail/releases/latest/download/jma-mail-installer.ps1 | iex"
-
Direct download. Grab the archive matching your platform from the latest release and put
jmasomewhere on yourPATH.
Or build from source with a Rust toolchain:
cargo install --git https://github.com/jmibanez/jma-mailjma respects the naming of your mailboxes, with the exception of inboxes. To match the behavior of mbsync, jma will use the Maildir mailbox named INBOX against the JMAP folder with the inbox role. This means that if your folder is localized on your provider, your local Maildir inbox will always be named INBOX regardless.
Note this also applies to the names of the mailboxes in [sync].mailboxes (see below), so if your mailbox is e.g. named Posteingang it will be synced locally as INBOX.
If you want finer-grained control over how specific folders are mapped to on-disk names -- for example, to flatten Gmail's [Gmail]/Sent to a tidier GmailSent -- see Maildir rename rules.
jma tracks state between your local Maildirs and the upstream JMAP server on an SQLite database. By default the database lives at <sync.maildir_path>/.jma.db -- a hidden file at the maildir root, so state and data move together when you copy or relocate the maildir, and multiple accounts each get their own DB without sharing a path. Set [state].db_path to override (for example, to keep state on a local-only path when the maildir lives on a synced or networked volume). State is intentionally disposable: you should be able to delete its state and rerun a jma pull to reconverge.
If you delete the state DB and run jma pull, jma will re-walk the server. It parses the Message-ID of each existing local message and "adopts" messages if they exist on the server, instead of re-downloading them. If you previously killed jma in the middle of a previous initial pull and then deleted the state DB, jma does The Right Thing and continues where it left off (i.e. it doesn't redownload previously downloaded messages).
So what's stored in the state DB?
- A bidirectional
message_map, mapping between a JMAP email and its local maildir file.jmastores the name of the local Maildir file and its correspondingmessage_idas a fast lookup cache - A mapping between JMAP mailbox IDs and local Maildir folder names in
mailbox_map, including role and parent jmap_statecontaining per-entity sync cursors sojmaonly needs to ask for changes since the last sync- And
local_state, which is a snapshot of the local state (flags, size, mtime) so any local filesystem changes are quickly detected
None of the state DB's contents are required to do a sync, push, or pull.
If there are any changes that break state tracking, as mentioned above you can simply delete the state DB and re-run jma. For most cases, jma marks its SQLite state database with a schema version -- if there's a mismatch, a mutating command (sync, pull, push, watch) will automatically nuke the state DB and do a full sync to catch up. Read-only commands (status, mailboxes, auth rediscover) refuse to open a mismatched DB and tell you to run a mutating command first, so a concurrent sync/watch doesn't get the DB yanked out from under it.
Mutating commands (sync, pull, push, watch) take two exclusive OS-level advisory locks before doing any work:
<maildir_root>/.jma.lock-- guards the maildir against concurrent mutation by any other jma process pointed at the same maildir, even if their state DB paths differ.<db_path>.lock(e.g.<maildir_root>/.jma.db.lock) -- guards the state DB against the schema-mismatch unlink/recreate path racing a concurrent process that opened the same DB.
If another instance already holds either lock, the second invocation fails fast with a message naming the holder's PID. The two locks cover different topologies: same maildir / different DB (lock 1 catches it), and same DB / different maildir (lock 2 catches it). The locks are always acquired in the same order -- maildir first, then state DB -- so two contending processes can't deadlock.
Read-only commands (mailboxes) and commands that don't touch the maildir or DB (init, auth) do not take either lock and can run alongside a watch daemon.
The locks are held by the kernel, not by the contents of the files, so they're released automatically when the holding process exits -- including on crash or kill -9. The .lock files themselves may stick around on disk; that's fine, the next invocation will reuse them. You should never need to delete them by hand, but it's safe to do so when no jma process is running.
Configuration for jma lives in ~/.config/jma/config.toml -- the same path on Linux, macOS, and the BSDs (the XDG convention, not the platform-native ~/Library/Application Support on macOS). The key knobs to put in are your account details, the path to your Maildir mailboxes, and which mailboxes to sync.
jma currently only supports Bearer tokens, AKA API tokens. On Fastmail, go to Settings -> Privacy & Security (under Account) -> Manage API tokens to generate a token, then provide it to jma in one of two ways (checked in this order):
- The OS-native secret store, via
jma auth set-token --account <email>. The token goes into macOS Keychain, Linux Secret Service (D-Bus), or Windows Credential Manager -- never on disk in the clear. Per-account scoping (the keyring entry is keyed on<email>) keeps multi-account configs from sharing or overwriting each other's credentials. Pipe a token in (pbpaste | jma auth set-token --account foo@example.com) or let it prompt.jma auth clear-token --account <email>removes it. The auth subcommands don't read the config -- the email you pass is what[account].emailmust be later for sync to find the token; a typo is detected when sync runs and reports "no token for ...". - The
tokenkey in the[account]block of the config file. Plaintext fallback for headless servers (no Keychain, no D-Bus session bus) where the keychain isn't available.
Other [account] keys:
email: The email address for this account. Required. The domain part is used to discover the JMAP session URL via DNS SRV (_jmap._tcp.<domain>) and/.well-known/jmap, per RFC 8620 section 2.2.session_url: Optional explicit JMAP session URL. When unset (the default), the URL is autodiscovered from theemaildomain via DNS SRV and/.well-known/jmap, and the result is cached in the state DB. Set this only to override autodiscovery -- e.g. when your provider doesn't publish the discovery records, or to pin a specific endpoint during testing. To force a re-discovery (for example, when your provider changes their session endpoint), runjma auth rediscover.
This section configures which Maildirs jma will sync to, and how it syncs. The important knobs here are maildir_path which should point to the root of your Maildir mailboxes that you want to sync (e.g. ~/Mail/Fastmail, or ~/Mail/my-provider). By default jma init populates mailboxes with the common IMAP/JMAP mailboxes (INBOX, Archive, Sent, Drafts, Trash) and none of your custom folders/mailboxes -- if you want to populate all mailboxes from upstream, unset this key or set it to an empty list. The Maildir tree itself is created on the first sync, one folder per server-known mailbox.
-
maildir_path: The path to the Maildir root you want to sync. Required. -
mailboxes: The specific mailboxes you want to sync. Set this to[](an empty list) or leave this empty to sync all mailboxes. Entries match a mailbox by its full server path, root-first and slash-joined (e.g.[Gmail]/Sent), and a matched path pulls in its whole subtree, so naming a parent syncs everything under it. A bare leaf name therefore selects only a top-level mailbox; to pick a nested one, write out its full path. The literalINBOXis the exception: it matches whichever mailbox has the inbox role regardless of its localized name (e.g.Posteingang), which is always stored locally asINBOX. -
case_insensitive_match: When comparingmailboxesentries against the server's mailbox paths, the cases can differ (e.g. on a case-insensitive, case-preserving filesystem your mailbox is namedfoobut upstream is saved asFoo). Set this totrueto ignore case; default is to respect case. -
folder_layout: How nested mailboxes land on disk. Three values:flat(default) -- mbsync'sFlatten=<sep>convention. Nested folders collapse into a single dotted directory at the maildir root (e.g.Lists.Rust.Internals).maildir++-- Courier/Dovecot's flat-with-leading-dot convention (e.g..Lists.Rust.Internals).fs-- Dovecot'sLAYOUT=fsrecursive directory tree (e.g.Lists/Rust/Internals).
Single-level mailboxes look identical under
flatandfs; the choice only matters once a server has nested folders. -
hierarchy_separator: Single character used to join parent/child names underflatandmaildir++(ignored forfs, which always uses/). Default.matches mbsync and Dovecot/Courier's typical deployments./,\, and NUL are rejected at runtime. Pick a different character if your folder names commonly contain.. -
download_concurrency: How many blobs (messages) to download concurrently. Default 8, clamped at runtime to the server's advertisedmaxConcurrentRequests(whichever is lower wins). -
upload_concurrency: How many messages to upload concurrently. Default 8, clamped to the server's advertisedmaxConcurrentUpload. Tune this down if your upstream bandwidth is constrained. -
conflict_strategy: How to resolve conflicts:server-wins-- The state on the server wins. Any local changes are discardedlocal-wins-- Your local Maildir state wins. The server is updated to reflect your local Maildir
This section only has one key, db_path, which tells jma where to persist its state. Leave it unset to use the default (<sync.maildir_path>/.jma.db); set it to override -- e.g. when the maildir lives on a synced or networked volume and you want the SQLite files on a local-only path.
jma supports JMAP's EventSource for push emails. This section has knobs around that feature. You should probably leave the defaults in, but if you want to tweak things this is the place:
post_arrival_command: A shell command thatjmawill invoke when it observes new mail. If you use a mail indexer such asmuornotmuch, put the indexing command here -- e.g. set this tonotmuch newfornotmuch.post_arrival_command_retries: Number of times to retrypost_arrival_commandif it fails (non-zero exit, spawn failure). Default0(run once and log a warning on failure); capped at10. Retries run synchronously inside the same logical invocation, so an in-flight retry chain still coalesces follow-up triggers into one queued run.ping_interval: How often (in seconds) to request the server send heartbeat pings on the EventSource (SSE) stream. Per RFC 8620 section 7.3 the server is allowed to clamp this value; some providers (Fastmail, notably) use a longer interval than requested. The actual interval the server uses drives the daemon's stream watchdog -- seewatchbelow.
By default jma projects each JMAP folder onto disk using [sync].folder_layout and [sync].hierarchy_separator. If you want certain folders to land under different on-disk names -- to flatten Gmail's [Gmail]/Sent to a tidier GmailSent, or to reshape a whole subtree -- add one or more [[rename_rules]] tables. Two kinds:
-
Direct mapping. Match an exact folder path; replace it with a literal name.
[[rename_rules]] type = "map-directly" source_folder_path = "[Gmail]/Sent" renamed_name = "GmailSent"
-
Pattern mapping. Match a Rust
regex-syntax expression against the folder path; produce the new name via capture-group substitution. TOML literal strings (single quotes) avoid having to backslash-escape.[[rename_rules]] type = "pattern" source_folder_pattern = '\[Gmail\]/(.*)' rename_pattern = 'Gmail.\1'
A few things to know:
- Rules match against the canonical
parent/childpath with/separators, regardless of[sync].hierarchy_separator. So a parent[Gmail]with a childSentis matched as[Gmail]/Senteven if your hierarchy separator is.. - First match wins. Rules are evaluated in the order they appear in the config; the first that matches a folder path is used.
- The rule output is the final on-disk folder name. When a rule fires, the layout's separator-joining is skipped -- the rule's right-hand side is what lands on disk. By design: a single rule can expand a flat JMAP name into a subtree (e.g.
foo.bar.baztofoo/bar/baz) or collapse one the other way. - Pattern regexes are validated at config load. A typo in
source_folder_patternis reported whenjmastarts, not on the first sync.
Note: By default, invoking jma without any subcommands runs sync.
JM's Mail Agent: bidirectional JMAP-to-Maildir email sync
Usage: jma [OPTIONS] [COMMAND]
Commands:
sync Run bidirectional sync (default if no command given)
pull One-way sync: server -> local only
push One-way sync: local -> server only
watch Daemon mode: continuous sync on server + local changes
init Initialize config file
mailboxes List remote mailboxes and their local mapping
status Show sync staleness, cursor health, and maildir drift
auth Manage account credentials and the JMAP discovery cache
janitor Run maintenance tasks against the maildir tree and state DB
help Print this message or the help of the given subcommand(s)
Options:
-c, --config <CONFIG> Config file path [default: ~/.config/jma/config.toml]
-v, --verbose... Increase verbosity: -v info, -vv debug, -vvv all crates, -vvvv trace
-q, --quiet Suppress all output except errors
--profile Print profiling summary to stderr at end of run
--profile-json <PATH> Write profiling summary as JSON to PATH
-n, --dry-run Show what would be done without making changes
-h, --help Print help
-V, --version Print versioninit is what you should run first to create a default config on disk. The Maildir tree itself is created on the first sync, one folder per server-known mailbox, through plan-visible CreateLocalMailbox actions (so a jma sync --dry-run after init previews exactly which folders would land). See Configuration above for more details on the config.
pull pulls down all upstream changes into your local Maildir mailboxes. Run this subcommand if you only want to download mail (e.g. if you want to archive your Mailboxes locally). If there are any conflicts however, your config's conflict_strategy will be applied.
push pushes all local Maildir state (new messages, flag changes, etc.) to your upstream server. If there are any conflicts however, your config's conflict_strategy will be applied.
sync is generally the command you want, and is equivalent to running mbsync -a or mbsync against a specific channel. By default, if you run jma without any subcommands it is equivalent to running jma sync.
$ jma sync
Running jma version 0.9.0
Syncing 7 mailboxes
Downloading 4 messages
Sync complete (4 downloaded, 1 uploaded, 2 flag updates, 0 moved, 0 deleted)The summary line tallies what landed this cycle, summed across both directions where applicable.
watch is the daemon mode: it does an initial bidirectional sync, then stays running and re-syncs whenever it sees a change on either side. Useful as a long-running process under launchd, systemd-user, or a tmux pane. Ctrl-C (or any SIGTERM/SIGINT) exits.
Two trigger sources feed it:
- JMAP EventSource (SSE). A long-running HTTPS connection to your server's push endpoint. The server pushes a
stateevent whenever an entity (Email, Mailbox) advances;jmaruns a sync cycle in response. This is what gives you push email. - Filesystem watcher. Watches the maildir root via the OS's native filesystem-events API (
inotifyon Linux,FSEventson macOS). Local edits (a message marked read by your MUA, a move between folders, a delete) trigger a sync cycle so changes propagate upstream. Events are debounced and coalesced before triggering a cycle so a burst of edits collapses into one sync.
After every cycle that downloaded new mail, the optional [watch].post_arrival_command shell command runs (use this to kick off mu index, notmuch new, etc.). Failed invocations can be retried up to [watch].post_arrival_command_retries times before the daemon gives up on that trigger.
The daemon recovers from two classes of failure on its own:
- Persistent JMAP transport errors. If a sync cycle exhausts its in-call retry budget against a 5xx storm or sustained connectivity loss, the daemon rebuilds the JMAP session (re-resolving the bearer token from the keychain on the way) and continues. Reconnect attempts grow exponentially up to 60 seconds between tries; backoff resets on the first successful sync after recovery. Hard errors (e.g. a 401 from a rotated bearer token) propagate so the daemon dies loudly rather than spinning -- if you see this, fix the credential and restart.
- Silently-dead SSE streams. The daemon expects regular events on the EventSource: state changes when there's activity, periodic pings otherwise. If no event of any kind arrives within the negotiated ping interval plus 5 seconds of slack, the stream is treated as dead -- the usual failure mode after a laptop wakes from sleep, a NAT entry expires, or a proxy times the connection out without closing it -- and the daemon reconnects. Per RFC 8620 section 7.3 the server picks the actual ping interval (it MAY clamp our requested
[watch].ping_intervalupward) and reports it on each ping event; that reported value is what the watchdog uses. Until the first ping arrives, the watchdog budgets the spec-mandated maximum of 300 seconds, so a slow first ping won't cause a spurious reconnect.
mailboxes is a read-only subcommand that shows the mapping between your server's mailboxes and your own local Maildir mailboxes. It also displays which mailbox corresponds to which role (drafts, sent, etc):
$ jma mailboxes
On-disk name Total Unread Role
----------------------------------------------------------------------
* INBOX 556 3 inbox
* Archive 71 0 archive
* Drafts 8 0 drafts
* Misc 3 0
* Priority 0 0
* Sent 1 0 sent
* Spam 431 6 junk
* Trash 0 0 trash
* = synced
status is a read-only, offline subcommand for the questions that aren't answered by mailboxes (which goes to the server) or by counting files on disk. It opens the state DB without taking any locks, so it's safe to run alongside an in-progress sync or watch.
$ jma status
Configured account: foo@example.com
Maildir root: /Users/foo/Mail
State DB: /Users/foo/Mail/.jma.db (132.4 KB, schema v1)
JMAP cursors (account a1b2c3d4e):
Last cursor write: 2026-05-06 14:22:01 (12m ago)
Email cursor: healthy
Mailbox cursor: healthy
Maildir vs DB drift (under /Users/foo/Mail):
Folders on disk not in DB: SomeNewFolder
Folders in DB not on disk: (none)
Folders no longer in [sync].mailboxes: (none)
Local renames pending sync: (none)What each section is for:
- Last cursor write. "When did the JMAP cursor for this account last advance?" Answers the staleness question without going online. The semantic is "last cursor write," not "last sync attempt completed" -- a sync run that produces no state change is invisible here, since we don't write a row when there's nothing to write. Reasonable proxy for "is this account stale, should I run sync?" but not a sync-attempt log.
- Cursor health. Each tracked entity (
Email,Mailbox) is eitherhealthy(a state cookie is set) orFORCED RESYNC. The latter means the cursor was tripped by acannotCalculateChangeserror from the server, and the next mutating run (sync/pull/watch) will do a full re-pull instead of a delta. Surfaced here so a slow next cycle isn't a surprise. - Maildir vs DB drift. Four classes of disagreement between the maildir tree and
mailbox_map: folders on disk with no DB row ("Imkdir'd a folder, why hasn't jma noticed"), DB rows with no folder on disk ("the DB thinks I have a folder I don't"), folders still on both sides but no longer selected by[sync].mailboxes(you shrank the sync set), and local renames not yet pushed to the server.statusonly reports these;janitor pruneis the command that clears them. - DB metadata. Path, size, and schema version of the state DB. Useful for bug reports and for noticing when the DB has gotten unexpectedly large.
The JMAP account ID is shown raw rather than mapped to an email -- mapping back would need either a JMAP roundtrip (which would defeat the offline-capable design) or a new column to remember the email-to-ID binding. The configured email prints once at the top so you can match the section to the account it belongs to.
auth is a small set of administrative subcommands for the OS keychain entries and the JMAP discovery cache. All three actions take --account <email>, which scopes them to a specific account by its [account].email. The auth subcommands don't read the config file, so a typo in --account isn't caught here -- the next sync run will report "no token for ..." instead.
auth set-token --account <email>-- Read a bearer token from stdin and store it in the OS keychain (macOS Keychain, Linux Secret Service, Windows Credential Manager) under that email. See Authentication and Account[account]above for how the stored entry feeds into sync.auth clear-token --account <email>-- Remove the bearer token for that email from the OS keychain. Idempotent; clearing a non-existent entry is a no-op.auth rediscover --account <email>-- Clear the cached JMAP session URL for this account's email domain and re-run autodiscovery (DNS SRV_jmap._tcp.<domain>, then/.well-known/jmap), printing the result. Use this when your provider changes their session endpoint. If[account].session_urlis set explicitly in the config, sync bypasses the discovery cache anyway --rediscoverstill updates the cache, but the new value only takes effect once you remove the override; the command warns you about this.
auth does not take the maildir or state-DB locks, so it can run alongside a watch daemon on the same account. Token rotations land in the keychain immediately; the running daemon will pick the new token up on its next reconnect (see watch).
janitor groups offline maintenance tasks that operate on the maildir tree and state DB outside a sync cycle: dedupe (remove per-folder Message-ID duplicates), remotededupe (server-side dedupe via Email/set), rebindfolders (rebind sentinel-less maildirs by Message-ID probing), and prune (below). Each takes the same maildir and state-DB locks a sync does, so it won't run concurrently with one.
prune remediates the drift that status reports but can't fix. It is offline -- it reads the state DB, the maildir tree, and [sync].mailboxes, and never goes to the server. Default mode prints a plan and removes nothing; pass --apply to act.
It classifies each drifted folder and labels how risky removing it is:
- On disk, no DB row (an untracked stray). Removing it deletes the maildir; any local-only files are first moved to a rescue maildir, never destroyed.
- Dropped from
[sync].mailboxes(present on both sides, no longer selected). Removing it deletes the maildir and its DB rows. - DB row, no maildir (the folder was deleted on disk). Removing it is a DB-cleanup only.
- Local rename pending sync. Never pruned -- the next sync settles it; surfaced so the skip is explained.
Two force flags gate the risky removals, on orthogonal axes:
--force-non-empty-- required to remove a maildir that still contains mail. Without it, non-empty folders are listed but left in place.--force-in-config-- required to clear DB rows for a folder still selected by[sync].mailboxes(clearing them otherwise just makes the next sync re-download it).
--mailbox <FOLDER> limits the run to one folder; --only disk or --only db limits it to on-disk folders or stale DB rows respectively.
Originally, I named this project jmapsync -- short, descriptive, and evocative of the original inspiration for it, isync/mbsync. However, I found out a bit later that there is already an existing project called jmapsync that basically does the same thing, although this project does have a different featureset. So, might as well rename.
You can honestly think of jma as either meaning "JMAP Mail Agent" or "JM's Mail Agent". Either works :)
Copyright (C) JM Ibañez 2026. This project is licensed under the BSD 3-clause license.