↓Zum Hauptinhalt springen
  1. Artikel/

Mailarchivierung mit mbsync und notmuch: was die Anleitungen verschweigen

<time datetime="2026-09-08 14:00:00 &#43;0200 CEST">Aktualisiert: 2026-09-08</time>

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.