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:
keep_on_server: trueaktivieren- Python-Cron-Job läuft alle 10 Minuten
- Findet SEEN-Mails die älter als 30 Minuten sind
- Fragt Zammad: “Gibt es ein Ticket mit dieser Message-ID?”
- Nur bei Bestätigung: Mail nach
INBOX/zammadverschieben - 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.