A JMAP sync tool that keeps a local Maildir and contacts database in sync with a JMAP server.
  • Go 99.2%
  • Nix 0.5%
  • Shell 0.3%
Find a file
2026-03-19 15:13:19 +01:00
cmd/jmapsync Update 2026-03-19 15:13:19 +01:00
contrib Update 2026-03-19 15:13:19 +01:00
internal Update 2026-03-19 15:13:19 +01:00
.envrc add flake 2026-03-18 21:13:35 +01:00
AGENTS.md Update 2026-03-19 15:13:19 +01:00
config.example.yaml Update 2026-03-19 15:13:19 +01:00
flake.lock add nix build 2026-03-19 15:13:19 +01:00
flake.nix add nix build 2026-03-19 15:13:19 +01:00
go.mod Update 2026-03-19 15:13:19 +01:00
go.sum Update 2026-03-19 15:13:19 +01:00
README.md Update 2026-03-19 15:13:19 +01:00

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/changes for 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 calls jmapsync send directly -- 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

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