Mailarchivierung mit mbsync und notmuch: was die Anleitungen verschweigen
Inhaltsverzeichnis
Mailarchivierung mit mbsync und notmuch #
“Traue weder niemandem, noch Dir selbst…”
Ein IMAP-Server ist kein Archiv. Er ist ein Postfach, und Postfächer werden geleert — von dir, vom Hoster, vom Umzug auf einen anderen Anbieter. Wer seine Mail über Jahre behalten will, braucht eine lokale Kopie, die nicht mitlöscht.
Die Werkzeuge dafür sind seit zwanzig Jahren stabil: mbsync holt, notmuch indiziert, ein Terminal-Client liest. Das Aufsetzen dauert einen Nachmittag.
Interessant wird es danach. Dieser Beitrag ist die Zusammenfassung dessen, was beim Betrieb eines solchen Archivs tatsächlich schiefgeht — und das Unangenehme daran ist: die meisten dieser Fehler melden sich nicht. Sie liefern eine Zahl, die plausibel aussieht und falsch ist.
1. Die Kette #
IMAP-Server
│
│ mbsync — holt nur, schreibt nie zurück
▼
~/mail/<konto>/<ordner>/{cur,new}
│
│ notmuch — Volltextindex + Tags
▼
~/mail/.notmuch/
│
├── neomutt / alot / aerc lesen und suchen
├── Skripte Anhänge, Rechnungen, Auswertung
└── bup / restic / borg Sicherung an einen anderen Ort
Jede Stufe ist ersetzbar. Die Reihenfolge ist es nicht.
2. Verzeichnislayout #
~/mail/
konto1@example.com/
INBOX/{cur,new,tmp}
Archive/{cur,new,tmp}
Sent/{cur,new,tmp}
konto2@example.com/
Native Maildirs, wie mbsync sie erzeugt. Ein Verzeichnis pro Konto, darunter die Ordner des Servers.
3. mbsync als Archiv konfigurieren #
Die meisten Beispielkonfigurationen im Netz beschreiben einen Spiegel — beide Seiten werden angeglichen. Für ein Archiv willst du etwas anderes: eine Einbahnstraße, die nichts vergisst.
# ~/.mbsyncrc
Create Near # fehlende Ordner nur lokal anlegen, nie auf dem Server
Remove None # serverseitig gelöschte Ordner lokal behalten
Expunge None # lokal nie endgültig löschen
SyncState * # Sync-Status im jeweiligen Maildir ablegen
IMAPAccount konto1
Host imap.example.com
User konto1@example.com
PassCmd "pass show mail/konto1@example.com"
SSLType IMAPS
IMAPStore konto1-remote
Account konto1
MaildirStore konto1-local
Path /home/USER/mail/konto1@example.com/
Inbox /home/USER/mail/konto1@example.com/INBOX/
SubFolders Verbatim
Channel konto1
Far :konto1-remote:
Near :konto1-local:
Patterns *
Sync Pull New Flags
Group alle
Channel konto1
Vier Zeilen entscheiden darüber, ob das ein Archiv ist oder nur eine Kopie:
| Option | Wirkung |
|---|---|
Sync Pull New Flags |
nur vom Server holen, nie hochladen |
Expunge None |
lokal wird nie endgültig gelöscht |
Remove None |
serverseitig gelöschte Ordner bleiben lokal |
Create Near |
fehlende Ordner nur lokal anlegen |
Damit gilt: was einmal im Archiv ist, bleibt drin — auch wenn du es auf dem Server löschst. Genau das ist der Zweck.
Falle 1: relative Pfade #
Schreibe Path absolut. Steht dort ./mail/konto1/, dann legt
mbsync bei einem Aufruf aus einem anderen Arbeitsverzeichnis heraus
klaglos ein zweites Archiv an. Das fällt monatelang nicht auf, weil
beide für sich funktionieren — bis man sich über den Plattenverbrauch
wundert und ein verwaistes Maildir mit ein paar tausend Mails findet,
das in keinem Index steht.
Passwörter nicht in die Konfiguration #
Pass "geheim" steht im Klartext in einer Datei, die gern mal in einem
Backup, einem Git-Repository oder der Datei-Historie des Editors landet.
PassCmd ruft stattdessen einen Passwortmanager auf:
sudo apt install pass
gpg --list-secret-keys --keyid-format=long # eigene Key-ID heraussuchen
pass init 1A2B3C4D5E6F7890 # diese hier einsetzen
pass insert -e mail/konto1@example.com
Für den unbeaufsichtigten Betrieb den GPG-Agent großzügig einstellen, sonst fragt jeder Timerlauf nach der Passphrase:
# ~/.gnupg/gpg-agent.conf
default-cache-ttl 28800
max-cache-ttl 86400
4. notmuch einrichten #
sudo apt install notmuch
notmuch setup
Danach die Konfiguration nachziehen — die Standardwerte taugen für ein Archiv nur bedingt:
# ~/.notmuch-config
[database]
path=/home/USER/mail
[new]
tags=new
ignore=.mbsyncstate;.mbsyncstate.new;.mbsyncstate.journal;.uidvalidity;.notmuch;tmp
[search]
exclude_tags=deleted;spam
[maildir]
synchronize_flags=true
Drei Punkte sind wichtig:
new.tags=new statt der Vorgabe unread;inbox. Frisch indizierte
Mail bekommt nur die Markierung new; ein Regelwerk arbeitet sie ab und
entfernt sie wieder. unread setzt notmuch bei
synchronize_flags=true ohnehin selbst aus dem Maildir-Flag — es gehört
deshalb nicht in new.tags.
ignore hält die Buchhaltungsdateien von mbsync aus dem Index.
exclude_tags blendet Papierkorb und Spam aus der Standardsuche
aus, ohne sie zu löschen. Dazu gleich mehr, denn hier lauert Falle 3.
Das Regelwerk #
notmuch führt nach jedem notmuch new automatisch den Hook
<mailroot>/.notmuch/hooks/post-new aus. Dort hängt man die
Verschlagwortung ein:
#!/bin/bash
# ~/mail/.notmuch/hooks/post-new → ruft das Regelwerk auf
exec "$HOME/.local/bin/notmuch-tag"
Und das Regelwerk selbst:
#!/bin/bash
# notmuch-tag nur frisch Indiziertes (tag:new)
# notmuch-tag --all komplettes Archiv neu verschlagworten
set -euo pipefail
ALL=0
[[ "${1:-}" == "--all" ]] && ALL=1
t() {
local tags=()
while [[ "$1" != "--" ]]; do tags+=("$1"); shift; done
shift
if (( ALL )); then notmuch tag "${tags[@]}" -- "( $* )"
else notmuch tag "${tags[@]}" -- "( tag:new ) and ( $* )"; fi
}
# Konto aus dem Ordnerpfad ableiten
t +acct/konto1 -- 'folder:"/^konto1@example\.com\//"'
t +acct/konto2 -- 'folder:"/^konto2@example\.com\//"'
# Ordnerklasse. Die Namen unterscheiden sich je nach Hoster —
# beide Schreibweisen abdecken.
t +deleted -inbox -- 'folder:"/\/(Trash|Deleted Messages)$/"'
t +spam -inbox -- 'folder:"/\/(Spam|Junk|Junk E-mail)$/"'
t +sent -inbox -- 'folder:"/\/Sent( Items| Messages)?$/"'
t +archived -inbox -- 'folder:"/\/(Archive|Archives)(\/|$)/"'
t +inbox -- 'folder:"/\/INBOX$/"'
# Auffangregel für Eigenordner, damit jede Mail genau eine Klasse hat
t +archived -- 'not tag:inbox and not tag:sent and not tag:archived
and not tag:deleted and not tag:spam and not tag:draft'
# Kandidaten für die Buchhaltung — bewusst großzügig
t +rechnung -- 'attachment:pdf and (subject:rechnung or subject:invoice
or subject:beleg or subject:quittung or subject:gutschrift)'
notmuch tag -new -- tag:new
Der Kniff sind die Regex-Ordnerabfragen folder:"/…/". notmuch kann
Maildir-Ordnernamen per regulärem Ausdruck matchen; das ist deutlich
robuster als Pfad-Wildcards und kommt mit Ordnernamen wie Sent Items
zurecht. Aber siehe Falle 4.
5. Ein Wrapper für die richtige Reihenfolge #
#!/bin/bash
# mail-archive holen + indizieren
# mail-archive --index nur indizieren, kein Netzwerk
set -euo pipefail
LOCK="${XDG_RUNTIME_DIR:-/tmp}/mail-archive.lock"
log() { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"; }
exec 9>"$LOCK"
flock -n 9 || { log "läuft bereits"; exit 0; }
if [[ "${1:-}" != "--index" ]]; then
# Ohne entsperrten GPG-Schlüssel kommt mbsync nicht an die
# Passwörter. Früh und verständlich abbrechen statt n Auth-Fehler.
if ! pass show mail/konto1@example.com >/dev/null 2>&1; then
log "FEHLER: pass kann nicht entschlüsseln (Schlüssel gesperrt?)"
exit 1
fi
log "mbsync ..."; mbsync alle
fi
log "notmuch ..."; notmuch new --quiet
log "fertig: $(notmuch count --exclude=false '*') Nachrichten"
Das flock ist kein Luxus: ein Timer, der startet, während der
vorherige Lauf noch indiziert, produziert Sperrkonflikte in der
Xapian-Datenbank.
6. Automatisierung: systemd statt cron #
Ein cron-Eintrag 0 */6 * * * mbsync -a ist der Klassiker, hat hier
aber zwei Nachteile: er läuft ohne Desktop-Session (der GPG-Agent ist
nicht erreichbar) und er holt verpasste Läufe nicht nach.
Ein systemd-User-Timer kann beides:
# ~/.config/systemd/user/mail-archive.service
[Unit]
Description=Mailarchiv holen und indizieren
After=network-online.target
[Service]
Type=oneshot
ExecStart=%h/.local/bin/mail-archive
Nice=10
IOSchedulingClass=idle
# ~/.config/systemd/user/mail-archive.timer
[Unit]
Description=Mailarchiv stündlich aktualisieren
[Timer]
OnCalendar=hourly
Persistent=true # verpasste Läufe nachholen
RandomizedDelaySec=5min # nicht exakt zur vollen Stunde
OnStartupSec=2min
[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now mail-archive.timer
systemctl --user list-timers 'mail-*'
journalctl --user -u mail-archive.service -n 30
7. Lesen #
Für notmuch gibt es mehrere Frontends. alot ist am engsten mit notmuch verzahnt, aerc am modernsten, neomutt am verbreitetsten und als einziges auch ein vollwertiger IMAP-Client.
Mit neomutt werden gespeicherte Suchen zu Ordnern in der Seitenleiste:
set nm_default_url = "notmuch:///home/USER/mail"
set nm_exclude_tags = "deleted spam"
set nm_record = no
set virtual_spoolfile = yes
virtual-mailboxes \
"Ungelesen" "notmuch://?query=tag:unread" \
"Rechnungen" "notmuch://?query=tag:rechnung" \
"Letzte 7 Tage" "notmuch://?query=date:7_days_ago.." \
"Konto 1" "notmuch://?query=tag:acct/konto1"
bind index,pager \Cf vfolder-from-query # freie Suche
bind index,pager \Cs sidebar-toggle-virtual
macro index,pager <F6> "<modify-labels>+rechnung\n" "als Rechnung markieren"
8. Prüfen, ob das Archiv stimmt #
Diese zwei Zahlen müssen übereinstimmen:
notmuch count --exclude=false --output=files '*'
find ~/mail -path '*/.notmuch' -prune -o \
\( -path '*/cur/*' -o -path '*/new/*' \) -type f -print | wc -l
Links der Index, rechts die Platte. Weichen sie ab, hat entweder
notmuch new seit dem letzten Sync nicht gearbeitet, oder es liegen
Dateien herum, die notmuch nicht als Mail erkennt.
Mach diesen Vergleich zur Gewohnheit. Er ist der einzige billige Test, der wirklich beweist, dass Suche und Bestand zusammenpassen.
9. Sichern — und den Restore ausprobieren #
Ein Archiv auf derselben Platte wie $HOME ist kein Backup. Ein
Maildir eignet sich hervorragend für deduplizierende Snapshot-Werkzeuge
wie bup, borg oder restic: viele kleine Dateien, die sich
zwischen zwei Läufen kaum ändern.
export BUP_DIR=/pfad/zum/nas/mail-repo
bup init
bup index --exclude-rx '/\.notmuch(/|$)' ~/mail
bup save -n mail ~/mail
Den Suchindex bewusst ausschließen. Er ist vollständig aus den Maildirs rekonstruierbar, ändert sich bei jedem Lauf komplett und dedupliziert deshalb miserabel — bei einem 15-GB-Archiv kostet er sonst jeden Snapshot mehrere hundert Megabyte umsonst.
Und dann das Wichtigste: den Restore einmal wirklich ausprobieren.
bup restore -C /tmp/probe mail/latest/home/USER/mail/konto1@example.com/
diff -r ~/mail/konto1@example.com /tmp/probe
Ein Backup, das nie zurückgespielt wurde, ist eine Vermutung.
10. Anhänge und Rechnungen herausziehen #
Der eigentliche Gewinn eines indizierten Archivs: es ist skriptbar.
notmuch search --output=files "tag:rechnung and date:2026" | while read -r m; do
ripmime -i "$m" -d "$tmpdir" --unique-names --no-nameless
done
Zwei Ratschläge aus schmerzhafter Erfahrung:
Räume niemals im Zielverzeichnis auf. Ein
find "$OUTDIR" -type f ! -iname "*.pdf" -delete, um Nicht-PDFs
loszuwerden, löscht alles andere in diesem Ordner mit — und wenn die
eigene Anleitung als Beispiel -o ~/Steuer/2024 vorschlägt, ist der
Schaden angerichtet. Arbeite in einem mktemp -d und verschiebe nur
das Gewünschte hinaus.
Schreib mit, woher jede Datei kam. Eine index.csv mit Datum,
Absender, Betreff, Message-ID und Dateiname kostet fünf Zeilen und
erspart später die Frage, zu welcher Mail die PDF eigentlich gehörte.
11. Wenn ein Ordner nur noch lokal existiert #
Irgendwann löschst du auf dem Server einen Ordner. Dein Archiv behält ihn — das ist gewollt. Beim nächsten Lauf passiert dann das:
Error: channel konto1: far side box Archive/2019 cannot be opened.
mbsync findet den Ordner lokal, sucht die Gegenstelle, findet sie nicht und bricht mit Exit-Code 1 ab. Ein einzelner verwaister Ordner lässt also den kompletten Lauf scheitern.
Die Lösung sind negative Patterns:
Patterns * !"Archive/2019" !"Archive/2020"
Und weil das mit der Zeit mehr werden, lohnt ein kleiner Detektor: die Ordnerliste des Servers per IMAP holen und mit den lokalen Maildirs vergleichen.
import imaplib, re, subprocess
from pathlib import Path
LIST_RE = re.compile(r'\((?P<flags>[^)]*)\) "(?P<sep>[^"]*)" (?P<name>.*)$')
def server_folders(host, user, passcmd):
pw = subprocess.run(passcmd, shell=True, capture_output=True,
text=True, check=True).stdout.rstrip("\n")
conn = imaplib.IMAP4_SSL(host)
try:
conn.login(user, pw)
_, data = conn.list()
finally:
conn.logout()
out = set()
for raw in data:
m = LIST_RE.match(raw.decode("utf-8", "replace"))
if not m:
continue
sep, name = m.group("sep"), m.group("name").strip('"')
# Hoster nutzen '.' oder '/' als Hierarchietrenner
out.add(name.replace(sep, "/") if sep else name)
return out
def local_folders(root: Path):
return {c.parent.relative_to(root).as_posix()
for c in root.rglob("cur") if (c.parent/"new").is_dir()}
waisen = (local_folders(Path.home()/"mail/konto1@example.com")
- server_folders("imap.example.com",
"konto1@example.com",
"pass show mail/konto1@example.com"))
print(" ".join(f'!"{w}"' for w in sorted(waisen)))
Die Ausgabe kann man direkt hinter Patterns * setzen.
12. Fünf Fallen, die keinen Fehler melden #
Das hier ist der eigentliche Kern des Beitrags. Alle fünf liefern eine Zahl oder ein Verhalten, das plausibel aussieht und falsch ist.
1. notmuch count '*' zählt Papierkorb und Spam nicht mit #
Sobald exclude_tags=deleted;spam gesetzt ist, gilt die Ausblendung
auch für count. Ein Archiv mit 14.708 Nachrichten meldet dann 11.608 —
und man sucht stundenlang nach den fehlenden dreitausend.
notmuch count '*' # gefiltert
notmuch count --exclude=false '*' # der wahre Bestand
Für Bestands- und Konsistenzprüfungen immer --exclude=false.
2. * gilt nur als alleiniger Suchbegriff #
notmuch count 'folder:"/^konto1@example\.com\//"' # 8664
notmuch count '( * ) and ( folder:"/^konto1@example\.com\//" )' # 5349
Keine Fehlermeldung, nur ein falsches Ergebnis. Wer * als Platzhalter
in eine zusammengesetzte Abfrage schreibt — etwa um in einem Skript
zwischen „alles" und „nur das Neue" umzuschalten — verschlagwortet still
einen Teil des Archivs nicht. Deshalb hat das Regelwerk oben im
--all-Modus gar kein Präfix statt eines *.
3. Nachrichten sind nicht Dateien #
notmuch zählt Nachrichten, identifiziert über die Message-ID. Liegt
dieselbe Mail in INBOX und Archive, sind das zwei Dateien und eine
Nachricht.
notmuch count --exclude=false '*' # Nachrichten
notmuch count --exclude=false --output=files '*' # Dateien
Für den Vergleich mit der Platte zählt die zweite Zahl.
4. Regex-Suchen brauchen Anführungszeichen #
notmuch count 'folder:/\/Sent( Items)?$/' # Xapian: unmatched regex delimiter
notmuch count 'folder:"/\/Sent( Items)?$/"' # funktioniert
Xapians Query-Parser bricht an Leerzeichen und Klammern ab. Sobald im regulären Ausdruck eines von beidem vorkommt, muss er in Anführungszeichen.
5. Kommandosubstitution in muttrc endet am # #
set imap_pass = `pass show mail/konto1` # "geheim" statt "geheim#pw"
set imap_pass = "`pass show mail/konto1`" # richtig
neomutt wertet das Ergebnis der Backtick-Substitution als
Konfigurationszeile aus — und dort beginnt bei # ein Kommentar. Ein
Passwort mit # wird stillschweigend abgeschnitten, der Login schlägt
mit „authentication failed" fehl, und man sucht den Fehler beim Hoster.
Bonus: Dateivergleiche scheitern an X-TUID #
mbsync schreibt in jede Mail einen eigenen Header, der bei jedem Sync
neu erzeugt wird. Zwei Kopien derselben Nachricht sind deshalb nie
byte-identisch — ein sha256sum-Vergleich meldet „alles verschieden",
obwohl der Inhalt gleich ist:
grep -av '^X-TUID:' datei | sha256sum
13. Wenn es mehr als ein Archiv gibt #
Nach einem Hosterwechsel hat man leicht zwei Bestände: den laufenden und den alten. Es gibt zwei Wege.
Ein Index über beide — database.mail_root auf das übergeordnete
Verzeichnis setzen, notmuch löst Dubletten über die Message-ID selbst
auf. Bequem für die Suche, aber die Tag-Regeln müssen die zusätzliche
Pfadebene kennen.
Getrennte Datenbanken — jeder Bestand bekommt eine eigene
notmuch-Konfiguration, angesprochen über NOTMUCH_CONFIG:
#!/bin/bash
# ~/.local/bin/notmuch-alt
exec env NOTMUCH_CONFIG="$HOME/.notmuch-config-alt" notmuch "$@"
Sauber getrennt, aber jede Suche muss zweimal laufen. Auch die
Auswertungsskripte — NOTMUCH_CONFIG davorsetzen genügt, weil sie
intern ohnehin notmuch aufrufen.
In neomutt lassen sich beide gleichzeitig einbinden, indem der Datenbankpfad direkt in die URL wandert:
virtual-mailboxes \
"Alt: Rechnungen" "notmuch:///home/USER/mail-alt?query=tag:rechnung"
14. Die wichtigste Regel: erst vergleichen, dann löschen #
Beim Aufräumen findet man alte Maildirs, vergessene Client-Profile, ausgepackte Archive neben ihren Tarballs. Die Versuchung ist groß, das als Dublette abzutun — „das ist doch längst umgezogen".
Drei Beispiele aus der Praxis, alle drei sahen nach sicherer Dublette aus:
- Ein verwaistes Maildir mit 3.604 Mails. War tatsächlich vollständig im Archiv enthalten — aber erst der Vergleich hat es bewiesen.
- Ein altes Mailclient-Profil mit 6,5 GB IMAP-Cache abgeschalteter Server. Enthielt 1.656 Nachrichten, die in keinem Archiv standen.
- Ein Tarball neben dem ausgepackten Verzeichnis, Dateizahlen fast gleich. Genau eine PDF fehlte im ausgepackten Ordner.
Der Test ist billig. Message-IDs des Kandidaten holen, gegen die Datenbank halten:
import mailbox, subprocess
out = subprocess.run(["notmuch","search","--exclude=false",
"--output=messages","*"],
capture_output=True, text=True, check=True).stdout
db = {l[3:].strip() for l in out.splitlines() if l.startswith("id:")}
fehlend = [m for m in mailbox.mbox("kandidat")
if (mid := m.get("Message-ID"))
and mid.strip().strip("<>") not in db]
print(len(fehlend), "Nachrichten sind nirgends sonst")
Ein paar Minuten Rechenzeit gegen einen unwiederbringlichen Verlust.
Fazit #
mbsync und notmuch ergeben ein Archiv, das schnell, vollständig
skriptbar und in zehn Jahren noch lesbar ist — Maildir ist nur ein
Verzeichnis mit Textdateien.
Der Aufbau ist der einfache Teil. Der schwierige ist das Misstrauen:
zwei Zahlen regelmäßig vergleichen, den Restore ausprobieren, und vor
jedem rm -rf die Message-IDs zählen statt der Dateien.