Preserving email dates when moving messages in Mu4e

The problem

My email setup is the usual text based machinery I keep writing about: Mu4e with mu, isync (mbsync) and msmtp. It works beautifully – until one day it did not.

When I moved emails around in Emacs – with mu4e-headers-mark-for-move, mu4e-headers-mark-for-refile or other command – the date of the moved email changed. A mail from last March would suddenly float to the top of the mailbox as if it had just arrived.

The diagnosis

The Date: header inside the message is never touched – that is why Mu4e always looks correct, it shows the date parsed from that header.

What changes is the arrival date: the IMAP “internal date” the server stores separately from the body. That is what Mail.app shows and sorts by (“Date Received”).

The culprit is this setting, which I need to avoid duplicate-UID errors with mbsync:

(setq mu4e-change-filenames-when-moving t)

With it on, Mu4e does not just move the file on refile – it rewrites the message as a brand-new Maildir file, as if freshly delivered, resetting its arrival time to now. mbsync then pushes that up and the server stamps the new internal date. Mail.app is exempt because it moves server-side via IMAP MOVE, which preserves the internal date.

First fix: CopyArrivalDate (necessary, not sufficient)

The fix lives in mbsync, not Emacs. In ~/.maildir/mbsyncrc, once at the top so it applies to every channel:

CopyArrivalDate yes

This propagates the arrival time with the message instead of letting the server stamp a fresh one. But on its own it was still wrong – because mbsync reads that arrival time from the file’s modification time (mtime), and Mu4e already reset the mtime to now when it rewrote the file. So CopyArrivalDate faithfully copies the wrong value.

The real fix: restore the mtimes before syncing

I keep mu4e-change-filenames-when-moving at t and, right before each sync, walk the Maildir, read each message’s Date: header, and set the file’s mtime to match. Then CopyArrivalDate yes carries the correct value up.

Thank god for Claude Code for the help with the Python script.

~/.maildir/fix-maildir-mtimes.py:

#!/usr/bin/env python3

"""Restore Maildir mtimes from each message's Date: header, so that
mbsync's CopyArrivalDate propagates the correct received date."""

import os
import sys
import time

from pathlib import Path
from email.parser import BytesParser
from email.utils import parsedate_to_datetime

maildir = Path(os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/.maildir"))
window  = float(sys.argv[2]) if len(sys.argv) > 2 else 2 * 86400
cutoff  = time.time() - window if window else 0

for f in maildir.rglob("cur/*"):
    try:
        st = f.stat()
        if window and st.st_mtime < cutoff:
            continue
        with open(f, "rb") as fh:
            msg = BytesParser().parse(fh, headersonly=True)
        dt = parsedate_to_datetime(msg.get("Date"))
        if dt is None:
            continue
        ts = dt.timestamp()
        if abs(st.st_mtime - ts) > 1:
            os.utime(f, (ts, ts))
    except Exception:
        continue

Wrapping it in front of mbsync

Rather than hooking “into” mbsync, I put a wrapper in front of it. This is ~/.maildir/sync-mail.py (yes, the .py on a bash script is a small lie – it started life in Python):

#!/usr/bin/env bash

~/.maildir/fix-maildir-mtimes.py "$HOME/.maildir"

exec mbsync "$@"

The fixer runs against the whole ~/.maildir root, covering every account in one pass. $@ is forwarded straight to mbsync, so I can pass a single channel name or --all. Make both files executable:

chmod +x ~/.maildir/fix-maildir-mtimes.py ~/.maildir/sync-mail.py

Hooking it into Mu4e

Mu4e simply runs whatever is in mu4e-get-mail-command, so I point that at the wrapper. By default it syncs everything:

(customize-set-variable 'mu4e-get-mail-command
                        (concat (executable-find "~/.maildir/sync-mail.py") " --all"))

And for the times I want to sync a single account, timu-mu4e-get-mail lets me pick it first:

(defun timu-mu4e-get-mail ()
  "Select the Account before syncing.
This makes the syncing of mails more flexible."
  (interactive)
  (let ((mu4e-get-mail-command
         (concat
          "~/.maildir/sync-mail.py "
          (completing-read
           "Which Account: "
           '("icloud" "aimebertrand" "moclub" "--all")))))
    (mu4e-update-mail-and-index t)))

(keymap-set mu4e-headers-mode-map "M-r" #'timu-mu4e-get-mail)
(keymap-set mu4e-main-mode-map    "M-r" #'timu-mu4e-get-mail)
(keymap-set mu4e-view-mode-map    "M-r" #'timu-mu4e-get-mail)

Because sync-mail.py runs the fixer before exec mbsync, every sync I trigger with M-r repairs the dates first – asynchronously, so Emacs never blocks. If you also sync outside Emacs (cron, launchd, a terminal), point those at sync-mail.py too, or a background sync could re-clobber a freshly moved message.

Conclusion

Three moving parts: CopyArrivalDate yes in mbsyncrc, fix-maildir-mtimes.py to restore the mtimes, and sync-mail.py wrapping both as the single entry point behind mu4e-get-mail-command.

Two honest caveats: it only fixes messages going forward – already-moved mails keep their overwritten server date – and some servers may stamp their own date on upload regardless. For my mailboxes it works wonderfully though. Yeah!!!