- Go 99.2%
- Nix 0.5%
- Shell 0.3%
| cmd/jmapsync | ||
| contrib | ||
| internal | ||
| .envrc | ||
| AGENTS.md | ||
| config.example.yaml | ||
| flake.lock | ||
| flake.nix | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
jmapsync
A JMAP sync tool that keeps a local Maildir and contacts database in sync with a JMAP server. Designed for use with NeoMutt, notmuch, and systemd.
Features
- Two-way email sync via Maildir. Uses JMAP
Email/changesfor efficient delta sync. Local flag changes are pushed to the server; remote changes update Maildir files in place. - Contacts sync (one-way, JMAP to local). Stores contacts in SQLite for fast offline search via NeoMutt's
query_command. - Sendmail-compatible send via JMAP
EmailSubmission. NeoMutt callsjmapsync senddirectly -- no SMTP needed. - Push notifications via JMAP EventSource (SSE). State changes trigger an immediate sync; a timer provides periodic fallback.
- Lockfile guard prevents concurrent sync runs from timer, push, resume, and network triggers firing simultaneously.
- Multiple accounts in a single config file.
- systemd user services for daemon, timer, resume, and network-up triggers.
- Post-sync hooks for running
notmuch new, signalling NeoMutt, or anything else.
Configuration
Default config path:
$XDG_CONFIG_HOME/jmapsync/config.yaml
(~/.config/jmapsync/config.yaml)
Default state path:
$XDG_STATE_HOME/jmapsync
(~/.local/state/jmapsync)
State is stored in SQLite files inside state_dir:
| File | Contents |
|---|---|
state.db |
Email sync state (message IDs, paths, flags, JMAP state tokens) |
<account>-contacts.db |
Contacts database for each account |
<account>.lock |
Lockfile for sync concurrency control |
See config.example.yaml for a full annotated example.
Minimal config
accounts:
- name: personal
jmap:
session_url: https://jmap.example.com/.well-known/jmap
auth:
type: bearer
token: ${JMAP_TOKEN}
destination:
type: maildir
path: ~/Maildir/personal
Sync options
sync:
interval: 30s # sync frequency (daemon/timer)
direction: both # "pull" or "both" (default: both)
conflict: union # "union" or "remote_wins" (default: union)
trash_action: move # "move" or "delete" (default: move)
folders_include: [INBOX, Archive, Sent] # optional filter
post_sync:
- notmuch new --maildir=~/Maildir/personal
- pkill -SIGUSR1 neomutt
Conflict resolution: When both sides changed flags on the same message, union merges them (you never lose a flag), remote_wins discards local changes.
Trash action: When you delete a message locally, move moves it to the server's Trash folder (recoverable), delete destroys it on the server.
Usage
Sync
# Run one sync cycle (email + contacts) for all accounts:
jmapsync sync-once
# Run as a daemon (sync loop + push notifications):
jmapsync run
Send mail
jmapsync send is a sendmail-compatible command. It reads an RFC 5322 message from stdin, uploads it to the JMAP server, and submits it via EmailSubmission/set. The server places a copy in Sent automatically.
echo "Subject: test" | jmapsync send --account personal
Contacts
# Sync contacts from JMAP server to local SQLite:
jmapsync contacts sync --account personal
# Search contacts (NeoMutt query_command format):
jmapsync contacts query alice
Other
# Validate configuration:
jmapsync check-config
NeoMutt integration
Add to your neomuttrc:
# --- Email ---
set folder = "~/Maildir/personal"
set mbox_type = Maildir
# Send via JMAP (no SMTP needed):
set sendmail = "jmapsync send --account personal"
unset record # server handles Sent folder
# --- Contacts ---
set query_command = "jmapsync contacts query --account personal %s"
# --- notmuch (optional) ---
set virtual_spoolfile = yes
set nm_default_url = "notmuch:///home/user/Maildir/personal"
notmuch integration
notmuch indexes the Maildir for full-text search and tagging. It sits on top of Maildir and does not need to know about JMAP.
The syncer runs post-sync hooks after each cycle. A typical setup:
sync:
post_sync:
- notmuch new --maildir=~/Maildir/personal
- pkill -SIGUSR1 neomutt
notmuch new indexes new/changed messages. pkill -SIGUSR1 neomutt tells NeoMutt to refresh its display.
Tag inversion note
notmuch's unread tag is the inverse of JMAP's $seen keyword. This is handled correctly through Maildir flags: the S flag (mapped to $seen) causes notmuch new to remove the unread tag automatically. No special configuration is needed.
notmuch tags beyond the standard Maildir flags are local-only -- they are not synced to the JMAP server. This is the simplest approach and avoids conflicts.
systemd setup
Copy the unit files from contrib/systemd/ to ~/.config/systemd/user/:
cp contrib/systemd/jmapsync.service ~/.config/systemd/user/
cp contrib/systemd/jmapsync.timer ~/.config/systemd/user/
cp contrib/systemd/jmapsync-push.service ~/.config/systemd/user/
cp contrib/systemd/jmapsync-resume.service ~/.config/systemd/user/
systemctl --user daemon-reload
Option A: Daemon mode (recommended)
The push service runs jmapsync run, which maintains an SSE connection for instant sync and falls back to a timer internally.
systemctl --user enable --now jmapsync-push.service
Option B: Timer-only mode
If push is not needed or the server doesn't support EventSource:
systemctl --user enable --now jmapsync.timer
Resume from suspend
systemctl --user enable jmapsync-resume.service
Network-up trigger (optional)
Install the NetworkManager dispatcher script:
sudo cp contrib/networkmanager/90-jmapsync.sh /etc/NetworkManager/dispatcher.d/
sudo chmod +x /etc/NetworkManager/dispatcher.d/90-jmapsync.sh
Architecture
resume / network up / timer / push notification
|
v
jmapsync-push.service --> SSE state change
| |
+----------------------+--> jmapsync sync (lockfile-guarded)
|
+---------+---------+
| |
Email/changes ContactCard/changes
| |
Maildir SQLite DB
|
notmuch new (post_sync hook)
|
SIGUSR1 --> NeoMutt refresh
Logs
journalctl --user -u jmapsync-push -f
journalctl --user -u jmapsync -f
Building
go build -o jmapsync ./cmd/jmapsync
# or
go install ./cmd/jmapsync
Requires Go 1.23+.
JMAP RFCs
| Feature | RFC |
|---|---|
| JMAP Core (session, sync, push) | RFC 8620 |
| JMAP for Mail (Email, Mailbox, EmailSubmission) | RFC 8621 |
| JSContact (Card format) | RFC 9553 |
| JMAP for Contacts (ContactCard methods) | RFC 9610 |