TL;DR: keep_on_server: true im Zammad Email-Channel lässt Mails im Postfach — markiert sie aber nur als \Seen. Damit man nicht versehentlich Mails verschiebt, die Zammad noch nicht importiert hat, braucht man eine sichere Verifizierung. Das Problem: /api/v1/tickets/search findet message_id ohne Elasticsearch nicht. Fix: Ticket::Article.where(message_id: ids) direkt via docker exec ... rails runner.


Das Problem

Zammad holt Mails via IMAP ab und löscht sie danach vom Server. Standardverhalten, macht Sinn — aber im Homelab will man oft eine Kopie im Postfach behalten. Gründe:

  • Backup außerhalb von Zammad
  • Nachvollziehbarkeit wenn ein Import schiefgeht
  • Auditing

Das Setup: Zammad auf srv-r2d2.deathstar.lan (Docker), Mailserver mail-chewbacca.deathstar.lan (Port 993 IMAPS), Postfach kontakt@deathstar.local.


Die erste Idee — und warum sie nicht reicht

Zammad hat die Option keep_on_server. Im Rails-Kontext:

ch = Channel.find(4)
ch.options['inbound']['options']['keep_on_server'] = true
ch.save!

Mit keep_on_server: true markiert Zammad Mails nach dem Import als \Seen statt sie zu löschen. Klingt perfekt — bis man weiterdenkt.

Das Problem: Was passiert wenn jemand das Postfach per Webmail öffnet? Mail wird als \Seen markiert. Zammad holt beim nächsten Fetch nur UNSEEN-Mails. Die Mail landet nie als Ticket. Und wenn dann ein Cron-Job sie in einen Archiv-Ordner verschiebt, ist die Kundenanfrage weg.

Im Homelab mit zuverlässigem Zammad-Betrieb kein großes Thema. Sobald aber jemand bei einem Ausfall kurz ins Postfach schaut — Falle.


Die Lösung: Vor dem Verschieben in Zammad nachfragen

Der Plan:

  1. keep_on_server: true aktivieren
  2. Python-Cron-Job läuft alle 10 Minuten
  3. Findet SEEN-Mails die älter als 30 Minuten sind
  4. Fragt Zammad: “Gibt es ein Ticket mit dieser Message-ID?”
  5. Nur bei Bestätigung: Mail nach INBOX/zammad verschieben
  6. Sonst: Warnung ins Log — Mail bleibt in INBOX

Schritt 4 ist der nicht-offensichtliche Teil.


Die Falle: Zammad API findet message_id nicht

Der naheliegende Ansatz: Die Zammad REST-API nach der Message-ID fragen.

curl "https://zammad.deathstar.local/api/v1/tickets/search?query=<unique-id@srv>&limit=1" \
  -H "Authorization: Token token=..."

Ergebnis: []. Leer. Immer.

Der generische Endpunkt /api/v1/search — ebenfalls leer.

Ursache: Zammad nutzt für die Volltextsuche normalerweise Elasticsearch. Ist Elasticsearch deaktiviert (wie bei vielen Homelab-Setups der Fall), fällt Zammad auf PostgreSQL-Suche zurück. Die sucht in Ticket-Titeln und Artikel-Bodies — nicht im message_id-Feld der ticket_articles-Tabelle.

Das steht nirgendwo explizit dokumentiert. Man findet es nur raus, wenn man sich fragt warum die API immer 0 Ergebnisse liefert, obwohl das Ticket sicher existiert.


Die Lösung: Direkter Rails-Query via docker exec

Das message_id-Feld ist in der Datenbank vorhanden und indiziert — man muss nur direkt fragen:

docker exec \
  -e DATABASE_URL='postgres://zammad:zammad@zammad-postgresql:5432/zammad_production?pool=50' \
  zammad-zammad-railsserver-1 \
  bundle exec rails runner \
  "ids = ['<msg-id-1@host>', '<msg-id-2@host>']; \
   found = Ticket::Article.where(message_id: ids).pluck(:message_id); \
   puts found.to_json"

Wichtig: Der Query verarbeitet alle Message-IDs als Batch — Rails wird nur einmal gestartet (ca. 2-3 Sekunden Overhead), egal wie viele Mails zu prüfen sind.

Wichtig 2: DATABASE_URL muss explizit übergeben werden. docker exec erbt die Umgebungsvariablen des Entrypoints nicht — nur docker compose exec tut das.


Das Python-Script

#!/usr/bin/env python3
"""
Zammad IMAP-Archivierung.
Läuft als Cron-Job alle 10 Minuten.
"""

import imaplib, ssl, email, json, subprocess, re, sys, os
from datetime import datetime, timedelta, timezone

IMAP_HOST        = "mail-chewbacca.deathstar.lan"
IMAP_PORT        = 993
IMAP_USER        = "kontakt@deathstar.local"
IMAP_PASS_FILE   = "/opt/zammad/scripts/.imap-password"
SOURCE_FOLDER    = "INBOX"
TARGET_FOLDER    = "INBOX/zammad"
MIN_AGE_MINUTES  = 30

ZAMMAD_CONTAINER = "zammad-zammad-railsserver-1"
ZAMMAD_DB_URL    = "postgres://zammad:zammad@zammad-postgresql:5432/zammad_production?pool=50"


def connect_imap(password):
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE  # internes Netz, selbstsigniertes Zertifikat
    imap = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=ctx)
    imap.login(IMAP_USER, password)
    return imap


def get_seen_candidates(imap, cutoff):
    """Findet SEEN-Mails in INBOX die älter als MIN_AGE_MINUTES sind."""
    imap.select(SOURCE_FOLDER, readonly=True)
    _, data = imap.uid("search", None, "SEEN")
    uids = data[0].split() if data[0] else []

    result = []
    for uid in uids:
        _, fetch_data = imap.uid(
            "fetch", uid,
            "(INTERNALDATE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID FROM SUBJECT)])"
        )
        if not isinstance(fetch_data[0], tuple):
            continue

        date_match = re.search(
            r'INTERNALDATE "([^"]+)"', fetch_data[0][0].decode()
        )
        if not date_match:
            continue

        internal_date = email.utils.parsedate_to_datetime(date_match.group(1))
        if internal_date > cutoff:
            continue  # zu jung

        msg = email.message_from_bytes(fetch_data[0][1])
        message_id = (msg.get("Message-ID") or "").strip()
        if not message_id:
            continue

        result.append((
            uid,
            message_id,
            (msg.get("Subject") or "").strip(),
            (msg.get("From") or "").strip(),
        ))
    return result


def check_zammad(message_ids):
    """Batch-Query: welche message_ids existieren als Ticket-Artikel in Zammad?"""
    if not message_ids:
        return set()

    ruby = (
        f"ids = {json.dumps(message_ids)}; "
        "found = Ticket::Article.where(message_id: ids).pluck(:message_id); "
        "puts found.to_json"
    )
    result = subprocess.run(
        ["docker", "exec",
         "-e", f"DATABASE_URL={ZAMMAD_DB_URL}",
         ZAMMAD_CONTAINER,
         "bundle", "exec", "rails", "runner", ruby],
        capture_output=True, text=True, timeout=60
    )
    for line in reversed(result.stdout.strip().splitlines()):
        if line.strip().startswith("["):
            return set(json.loads(line.strip()))
    return set()


def main():
    password = open(IMAP_PASS_FILE).read().strip()
    cutoff   = datetime.now(timezone.utc) - timedelta(minutes=MIN_AGE_MINUTES)
    ts       = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    imap     = connect_imap(password)
    mails    = get_seen_candidates(imap, cutoff)

    if not mails:
        print(f"[{ts}] Keine Kandidaten.")
        imap.logout()
        return

    confirmed = check_zammad([m[1] for m in mails])
    moved = warnings = 0

    for uid, message_id, subject, from_ in mails:
        if message_id in confirmed:
            imap.select(SOURCE_FOLDER)
            status, _ = imap.uid("copy", uid, TARGET_FOLDER)
            if status == "OK":
                imap.uid("store", uid, "+FLAGS", r"(\Deleted)")
                moved += 1
                print(f"[{ts}] ARCHIVIERT: {subject!r}")
            else:
                print(f"[{ts}] FEHLER copy: {subject!r}", file=sys.stderr)
        else:
            warnings += 1
            print(
                f"[{ts}] WARNING: Kein Ticket gefunden — NICHT verschoben: "
                f"{subject!r} | {from_} | {message_id}",
                file=sys.stderr
            )

    if moved > 0:
        imap.select(SOURCE_FOLDER)
        imap.expunge()

    imap.logout()
    print(f"[{ts}] Fertig: {moved} archiviert, {warnings} Warnungen.")


if __name__ == "__main__":
    main()

Einrichtung Schritt für Schritt

1. IMAP-Unterordner anlegen

import imaplib, ssl

ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
imap = imaplib.IMAP4_SSL("mail-chewbacca.deathstar.lan", 993, ssl_context=ctx)
imap.login("kontakt@deathstar.local", "PASSWORT")
print(imap.create("INBOX/zammad"))  # ('OK', [b'CREATED completed'])
imap.logout()

2. keep_on_server aktivieren

# Script erstellen
cat > /tmp/keep_on_server.rb << 'EOF'
ch = Channel.find(4)  # Channel-ID anpassen!
puts "VORHER: #{ch.options['inbound']['options']['keep_on_server'].inspect}"
ch.options['inbound']['options']['keep_on_server'] = true
ch.save!
ch.reload
puts "NACHHER: #{ch.options['inbound']['options']['keep_on_server'].inspect}"
EOF

# In Container kopieren und ausführen
docker cp /tmp/keep_on_server.rb zammad-zammad-railsserver-1:/tmp/
docker exec \
  -e DATABASE_URL='postgres://zammad:zammad@zammad-postgresql:5432/zammad_production?pool=50' \
  zammad-zammad-railsserver-1 \
  bundle exec rails runner /tmp/keep_on_server.rb
VORHER: false
NACHHER: true

3. Script + Passwort-Datei deployen

mkdir -p /opt/zammad/scripts
cp imap-archive.py /opt/zammad/scripts/
echo "IMAP-PASSWORT" > /opt/zammad/scripts/.imap-password
chmod 600 /opt/zammad/scripts/.imap-password
chmod 755 /opt/zammad/scripts/imap-archive.py

4. Cron-Job

(crontab -l 2>/dev/null; echo '*/10 * * * * /usr/bin/python3 /opt/zammad/scripts/imap-archive.py >> /var/log/zammad-imap-archive.log 2>&1') | crontab -

Verifikation

# Log prüfen
tail -20 /var/log/zammad-imap-archive.log
# Erwartung: "[timestamp] Fertig: 1 archiviert, 0 Warnungen."

# Ordner prüfen
python3 -c "
import imaplib, ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
imap = imaplib.IMAP4_SSL('mail-chewbacca.deathstar.lan', 993, ssl_context=ctx)
imap.login('kontakt@deathstar.local', 'PASSWORT')
print(imap.status('INBOX', '(MESSAGES)'))
print(imap.status('INBOX/zammad', '(MESSAGES)'))
imap.logout()
"
# INBOX: 0 Nachrichten
# INBOX/zammad: wächst mit jeder importierten Mail

Rollback

Falls keep_on_server wieder deaktiviert werden soll:

ch = Channel.find(4)
ch.options['inbound']['options']['keep_on_server'] = false
ch.save!

Den Cron-Job in crontab -e auskommentieren oder löschen.


Lessons Learned

1. Zammad Search API ohne Elasticsearch ist eingeschränkt

/api/v1/tickets/search und /api/v1/search durchsuchen ohne Elasticsearch nur Ticket-Titel und Artikel-Body — nicht das message_id-Feld. Das steht nicht explizit in der Dokumentation.

2. \Seen bedeutet nicht zwingend “von Zammad importiert”

Jeder IMAP-Zugriff (Webmail, Handy, Mail-Client-Vorschau) kann \Seen setzen. Bei keep_on_server: true würde Zammad diese Mail dann nie importieren — und ein einfacher SEEN-Filter im Archivierungs-Script würde sie trotzdem verschieben.

3. Rails-Batch-Query ist die einzige zuverlässige Lösung ohne Elasticsearch

Ticket::Article.where(message_id: ids).pluck(:message_id)

Einmalig pro Cron-Lauf, egal wie viele Mails — performant und präzise.

4. DATABASE_URL bei docker exec explizit übergeben

# Funktioniert NICHT (DATABASE_URL fehlt):
docker exec zammad-zammad-railsserver-1 bundle exec rails runner "..."

# Funktioniert:
docker exec -e DATABASE_URL='postgres://...' zammad-zammad-railsserver-1 bundle exec rails runner "..."

docker compose exec erbt die Umgebungsvariablen des Entrypoints. docker exec tut das nicht.