Amakuru.net

Life after Gmail: cleaning up the migration mess with a few scripts

Five small Python scripts to fix what the mailbox.org migration left behind: cross-folder duplicates, in-folder duplicates, and the Gmail filters that did not transfer.

I migrated my email off Gmail to mailbox.org last month. The migration itself was straightforward — point an IMAP client at the old account, copy everything across — but it left two messes that the mailbox.org and OX App Suite GUIs don’t fix well: every message exists twice (Gmail labels became folders, but the migration also kept a copy of every labelled message in INBOX), and Gmail filters don’t transfer (mailbox.org uses Sieve instead).

mail-scripts is five small Python scripts to deal with both. The design choices that mattered were the ones that made the scripts safe to trust against my own mailbox.

Two phases, scan then apply

Both deduplication scripts work in two passes: a scan that produces a report file (modifies nothing), then an apply that reads the report and moves UIDs to Trash. A single command running both the analysis and the destructive action would have removed the natural pause between seeing 14,000 candidates and actually moving 14,000 messages, and that pause is the whole point.

The apply step also re-validates UIDVALIDITY before acting. If INBOX changed since the scan, the report’s UIDs may now point at different messages, so the script refuses rather than trust them. That refusal has saved me from myself at least once.

No setup overhead for one-shot tooling

Each script has a PEP 723 shebang (#!/usr/bin/env -S uv run --script), so uv resolves dependencies on the fly with no venv to create or maintain. Migration tooling that gets used a handful of times and then never again should be “clone, run”. A requirements.txt plus a venv would have meant I never finished setting it up before reaching for the IMAP web GUI instead.

Translate what you can, mark what you can’t

The Gmail-to-Sieve translator handles the obvious header tests (from, to, cc, bcc, subject, hasAttachment) and the actions that map cleanly across (shouldArchive, shouldStar, shouldMarkAsRead, shouldTrash, forwardTo, label-as-fileinto). It also handles the awkward middle ground — Gmail’s hasTheWord / doesNotHaveTheWord mini-queries with operators like from:x subject:y has:attachment list:z, including quoted phrases — by parsing them into the corresponding Sieve tests instead of throwing the whole string into a body :contains and hoping. Free-text fragments that genuinely have no header analogue do fall back to body :contains, but only after the tokeniser has done its best.

Two architectural choices worth flagging, because they’re easy to get wrong:

  • Labelled mail skips INBOX. Gmail’s default is “every labelled message also lives in INBOX.” Sieve’s fileinto followed by stop files the message and does not. This is closer to what most people thought Gmail was doing all along, but it is a behaviour change — if you rely on INBOX as the place where everything queues, this will surprise you.
  • Each rule emits a # rule:[Name] marker. OX App Suite’s web GUI parses Sieve scripts into rows in the filters UI; without a name marker preceding each if-block, the row shows up as “undefined”, which is useless. The marker costs nothing and makes the GUI legible — a real example of “the GUI is the source of truth for non-technical co-readers, even if you only edit Sieve directly.”

Things the translator can’t handle get a # TODO: comment so I can audit them before deploying. A silent fallback to body :contains would produce a script that runs but quietly does the wrong thing on rules that needed attention; a visible TODO surfaces the cases that need a human decision.

Deploying and inspecting Sieve scripts

Two more scripts close the loop: sieve-deploy.py uploads a .sieve file to the server and makes it the active script; sieve-list.py reads the live script back and prints one line per if-block, marking each as either named (with a # rule:[Name] marker the GUI will pick up) or orphan. The combination means I can edit Sieve directly, deploy, and immediately see how OX App Suite will render the rules — which catches “I forgot to add a name marker” before a non-technical user does.

sieve-deploy.py talks ManageSieve (RFC 5804) directly using only Python’s stdlib — STARTTLS, AUTH PLAIN, PUTSCRIPT, SETACTIVE. The one quirk worth knowing about is that mailbox.org’s Pigeonhole server doesn’t emit a + go ahead continuation when you send a synchronising literal in PUTSCRIPT, so the script uses a non-synchronising literal ({N+}) instead. The first version sent a sync literal and hung waiting for a response that was never coming; the second worked first try. The kind of detail that’s nowhere in the user-facing docs and shows up only when something silently doesn’t work.

Credentials in the Keychain

All five scripts pull their passwords from the macOS Keychain (security add-generic-password -a … -s mailbox.org-imap -w), with an interactive prompt as a fallback. None of them accept a password as an argument or read one from .env. Secrets passed on the command line end up in ps output and shell history, and an .env file in a migration repo tends to outlive the migration; the Keychain stores them once and leaves no trace anywhere else.

Was it worth it?

The scripts ran a handful of times each over the course of the migration, did their job, and have mostly sat unused since. Migration tooling earns its keep when it’s not needed any more.

dmorel69/mail-scripts — Python, IMAP, Sieve