# PROJ-35: OCR & Anhang-Volltext-Indexierung ## Status: In Progress **Created:** 2026-04-04 **Last Updated:** 2026-05-08 ## Dependencies - Requires: PROJ-5 (Speicherung & Indexierung) — Mailparser + Manticore-Index - Requires: PROJ-30 (Manticore Migration) — Volltext-Index als Basis ## Motivation PDF- und Bild-Anhänge (Rechnungen, Verträge, eingescannte Dokumente) sind bisher nicht durchsuchbar — nur der Dateiname wird indexiert. OCR macht den Inhalt dieser Anhänge volltext-durchsuchbar ohne den normalen Mail-Eingang zu verlangsamen. ## User Stories - Als Nutzer möchte ich in archivierten Rechnungs-PDFs nach Beträgen oder Kundennummern suchen können. - Als Nutzer möchte ich eingescannte Dokumente (JPG/PNG/TIFF) im Volltext durchsuchen können. - Als Admin möchte ich OCR per Mandant aktivieren oder deaktivieren können. - Als Nutzer soll der Mail-Eingang nicht durch OCR verlangsamt werden (asynchrone Verarbeitung). ## Acceptance Criteria - [x] PDF-Anhänge mit eingebettetem Text: Text via `pdftotext` extrahieren (kein OCR nötig, schnell) - [x] PDF-Anhänge ohne Text (Scan): OCR via Tesseract (deutsch + englisch) - [x] Bild-Anhänge (JPG, PNG, TIFF, BMP, WEBP): OCR via Tesseract - [x] OCR läuft asynchron — Mail wird sofort gespeichert, OCR-Text nachgeliefert - [x] OCR-Text wird in Manticore-Index im Feld `attachment_text` gespeichert (neues Feld via `ALTER TABLE`) - [x] Volltext-Suche findet Mails anhand von OCR-Text in Anhängen (`MATCH(?)` ohne Field-Scoping in `manticore.go::Search` deckt das Feld automatisch ab) - [x] OCR-Status pro Mail in PostgreSQL: `ocr_status` (`pending` / `done` / `failed` / `skipped` / `disabled`) - [x] OCR kann per Mandant deaktiviert werden (`tenants.ocr_enabled BOOLEAN DEFAULT TRUE`) - [x] `archivmail ocr-reprocess` — OCR für alle oder einzelne Mandanten nachholen (Flags: `--tenant`, `--status`, `--limit`) - [x] Keine neue externe Service-Abhängigkeit bei Laufzeit — nur System-Pakete (`tesseract-ocr`, `tesseract-ocr-deu`, `poppler-utils`) ## Edge Cases - Sehr große PDFs (>50 MB): Timeout nach 60s, Status `failed`, Mail bleibt auffindbar per Metadaten - Passwortgeschützte PDFs: OCR überspringen, Status `skipped` - Anhänge ohne OCR-fähiges Format (ZIP, EXE, ...): überspringen - Tesseract nicht installiert: OCR deaktiviert, Warnung beim Start, kein Absturz ## Tech Design ### Neue Komponenten **`internal/ocr/ocr.go`** ```go // ExtractText extrahiert Text aus einem Anhang. // Für PDFs: pdftotext zuerst, dann Tesseract als Fallback. // Für Bilder: direkt Tesseract. // Gibt leeren String zurück wenn Format nicht unterstützt oder Fehler. func ExtractText(data []byte, contentType string, langs []string) (string, error) // IsAvailable prüft ob Tesseract installiert ist. func IsAvailable() bool ``` **`internal/ocr/worker.go`** — Async-Worker ```go // Worker liest aus einem Channel und verarbeitet OCR-Jobs. // Läuft als Goroutine im Hintergrund. type Worker struct { ... } func NewWorker(store *storage.Store, idxMgr index.TenantIndexer) *Worker func (w *Worker) Submit(mailID string, tenantID *int64) func (w *Worker) Start(ctx context.Context) ``` ### DB-Schema ```sql -- OCR-Status pro Mail ALTER TABLE emails ADD COLUMN IF NOT EXISTS ocr_status TEXT DEFAULT 'pending'; -- Werte: pending | done | failed | skipped | disabled -- OCR pro Tenant konfigurierbar ALTER TABLE tenants ADD COLUMN IF NOT EXISTS ocr_enabled BOOLEAN DEFAULT TRUE; -- Index für OCR-Queue CREATE INDEX IF NOT EXISTS idx_emails_ocr_status ON emails (ocr_status) WHERE ocr_status = 'pending'; ``` ### Manticore-Schema-Erweiterung ```sql -- Neues Feld im RT-Index ALTER TABLE emails_tenant_1 ADD COLUMN attachment_text text; ``` In `internal/index/manticore.go`: - `MailDocument.AttachmentText string` hinzufügen - `ensureTable()` — neues Feld im CREATE TABLE - `IndexSync()` — `attachment_text` befüllen ### Verarbeitungs-Ablauf ``` Mail eingehend (SMTP/IMAP/Import) → mailparser.Parse() → Anhänge erkannt → mailStore.Save() → Mail gespeichert, ocr_status = 'pending' → Manticore-Index ohne attachment_text → OCR-Worker.Submit(mailID) OCR-Worker (async, Goroutine): → mailStore.Load(mailID) → Rohdaten → mailparser.Parse() → Anhänge → für jeden Anhang: → ocr.ExtractText(data, contentType, ["deu","eng"]) → idxMgr.ForTenant(tenantID).UpdateAttachmentText(mailID, text) → mailStore.SetOCRStatus(mailID, "done") ``` ### Neues CLI-Subkommando ```bash archivmail ocr-reprocess --config /etc/archivmail/config.yml archivmail ocr-reprocess --config /etc/archivmail/config.yml --tenant 1 archivmail ocr-reprocess --config /etc/archivmail/config.yml --status failed ``` ### Installation auf Server ```bash apt-get install -y tesseract-ocr tesseract-ocr-deu poppler-utils # Prüfen tesseract --version pdftotext -v ``` `update.sh` — optionale Installation (kein Abbruch wenn nicht verfügbar): ```bash apt-get install -y tesseract-ocr tesseract-ocr-deu poppler-utils 2>/dev/null || true ``` ### Performance-Überlegungen | Format | Tool | Dauer (A4-Seite) | |--------|------|-----------------| | PDF mit Text | pdftotext | < 100ms | | PDF als Scan | Tesseract | 1–3s | | JPG (300 DPI) | Tesseract | 0.5–2s | OCR-Worker mit konfigurierbarer Worker-Anzahl (Standard: 2 Goroutinen). Keine Blockierung des Mail-Eingangs — Submit() ist non-blocking. ## Nicht in Scope - Microsoft Word / Excel / PowerPoint direkt (nur wenn als PDF geliefert) - Layout-Analyse oder Tabellen-Extraktion - Sprachen außer Deutsch und Englisch (erweiterbar via Config) - Cloud-OCR-Services (bewusst: nur lokale Tools) ## Tech Design _Vollständig oben beschrieben_ --- ## Implementation Notes (2026-05-08) ### Neue Dateien | Datei | Aufgabe | |---|---| | `internal/ocr/ocr.go` | `ExtractText(ctx, data, contentType, filename, langs)` mit `pdftotext` → `pdftoppm` + `tesseract` Fallback. Klassifizierung über MIME + Datei-Endung. Timeouts (60 s Default), Größenlimit (50 MiB), Sentinel-Errors `ErrUnsupported`/`ErrTooLarge`/`ErrEncrypted`/`ErrUnavailable`. Tempdir per `SetTempDir()` umlenkbar (wegen systemd-Sandbox). | | `internal/ocr/worker.go` | `Worker` mit `Submit(mailID, *tenantID)`-Queue, N Goroutinen (Default 2). Lädt Mail aus Storage, parst, ruft `ExtractText` für jeden Anhang, schreibt kombinierten Text via `AttachmentTextUpdater` in Manticore und setzt `ocr_status` final. Submit ist non-blocking — volle Queue → Warning + Drop. | | `internal/storage/ocr.go` | `SetOCRStatus`, `OCREnabled` (opt-out), `GetPendingOCRMails`, `GetMailsByOCRStatus` mit optionalem Tenant-Filter via `email_refs`/`emails.tenant_id`. `PendingOCRMail{ID, *TenantID}`-Rückgabetyp. | | `cmd/archivmail/cmd_ocr_reprocess.go` | CLI `archivmail ocr-reprocess [--config] [--tenant N] [--status pending\|done\|failed\|skipped\|disabled\|all] [--limit N]`. Setzt `failed`/`skipped`-Mails auf `pending` zurück, queued sie im Worker, blockiert bis Drain. | ### Geänderte Dateien | Datei | Änderung | |---|---| | `internal/index/index.go` | `MailDocument.AttachmentText string` ergänzt; neues optionales Interface `AttachmentTextUpdater { UpdateAttachmentText(mailID, text string) error }`. | | `internal/index/manticore.go` | `ensureTable()`: legt `attachment_text text` an + ruft `ensureColumn()` für ALTER auf bestehende Tabellen (Manticore kennt kein `ALTER … IF NOT EXISTS`, daher DESC-Probe + ALTER). `IndexSync()`: neues Feld in `REPLACE INTO`-Spaltenliste. `UpdateAttachmentText()`: lädt Row, REPLACE INTO mit überschriebenem `attachment_text`. | | `internal/storage/storage.go` | `initSchema`: `ALTER TABLE emails ADD COLUMN IF NOT EXISTS ocr_status TEXT DEFAULT 'pending'` + `CREATE INDEX … WHERE ocr_status = 'pending'`. | | `internal/tenantstore/store.go` | DDL-Block: `ALTER TABLE tenants ADD COLUMN IF NOT EXISTS ocr_enabled BOOLEAN NOT NULL DEFAULT TRUE`. | | `cmd/archivmail/main.go` | OCR-Worker beim Boot starten (`ocr.NewWorker(...).Start(ctx)`), `defer Stop()`. Boot-Resume: `GetPendingOCRMails(ctx, nil, 5000)` → Submit. Im laufenden Mail-Eingang nach `worker.Submit(doc)` zusätzlich `ocrWorker.Submit(id, tenantID)`. | | `update.sh` | `apt-get install -y tesseract-ocr tesseract-ocr-deu poppler-utils 2>/dev/null \|\| true` (idempotent, kein Abbruch). | ### Architektur-Entscheidungen - **Suche bleibt unverändert**: `manticore.go::Search` nutzt `MATCH(?)` ohne Field-Scoping → `attachment_text` wird automatisch mit durchsucht. Keine Änderung an `/api/search` oder `/api/v1/mails` nötig. - **`UpdateAttachmentText` statt voller Reindex**: Manticore RT erlaubt kein `UPDATE` auf Text-Spalten → wir lesen die Row, machen REPLACE INTO. Vermeidet Race mit dem normalen `IndexWorker`, wenn beide gleichzeitig auf dieselbe Mail arbeiten — der OCR-Worker greift erst nach erfolgter Erst-Indexierung. - **OCR ist opt-out, kein opt-in**: `OCREnabled()` defaultet auf `true` — auch bei DB-losen Modi, fehlender `tenants.ocr_enabled`-Spalte oder Lookup-Fehlern. - **Tempdir umlenkbar**: Die `archivmail.service`-systemd-Unit hatte `PrivateTmp=yes` o.ä., was `os.MkdirTemp("")` zwar erlaubt, aber das gemeinsame `/tmp` versteckt. `SetTempDir(cfg.Storage.StorePath + "/ocr-tmp")` schreibt jetzt in einen vom Daemon explizit erlaubten Pfad. - **Boot-Resume drosselt**: Falls beim Restart >Queue-Capacity Mails `pending` sind, wird stapelweise enqueued (Commit `a252ad6`). ## Bekannte Pitfalls 1. **systemd-Tempdir-Restriktion** (Commit `6d835ae`): Beim ersten Run wurden 19.043 Mails fälschlich auf `failed` markiert, weil `os.MkdirTemp("")` in einer Unit-internen Sandbox lag. Fix: `SetTempDir(/ocr-tmp)`. 2. **`hashMailID` muss konsistent zu IndexSync sein**: `UpdateAttachmentText` muss exakt dasselbe Bit-Muster nutzen wie `IndexSync`, sonst werden neue Rows angelegt statt überschrieben. Der int64-Cast wird vom MySQL-Treiber sauber serialisiert. 3. **Sehr große PDFs (>50 MiB)** werden über `MaxAttachmentSize` übersprungen → Status `skipped`, Mail bleibt per Metadaten auffindbar. 4. **Passwort-geschützte PDFs**: `pdftotext` liefert `Incorrect password` → `ErrEncrypted` → Status `skipped`. ## QA Test Results ### Test-Server 192.168.1.132 (Stand 2026-05-08) ``` ocr_status | count done | 92 failed | 96 pending | 13.603 ← reprocess läuft skipped | 1.227 ``` - `DESC emails_global` enthält `attachment_text text indexed stored` ✓ - `archivmail ocr-reprocess --status failed` reaktiviert die `failed`-Marker korrekt - Service-Stack (manticore + archivmail + archivmail-web) durchgehend `active` ### Produktions-Server 192.168.1.131 **Noch nicht deployed.** Anstehend nach Abschluss des 132-Recoverys. ## Deployment | Server | Datum | Status | |---|---|---| | 192.168.1.132 (Test) | 2026-05-08 | Deployed, Recovery läuft (~13.600 Mails im Reprocess) | | 192.168.1.131 (Prod) | _ausstehend_ | Wartet auf grünes 132 | ### Pre-Deployment-Schritte (auf Server) ```bash # Bereits in update.sh enthalten: apt-get install -y tesseract-ocr tesseract-ocr-deu poppler-utils ``` ### Verifikation nach Deploy ```bash # Schema-Check curl -s 'http://127.0.0.1:9308/sql?mode=raw' \ --data-urlencode 'query=DESC emails_global' | grep attachment_text # OCR-Worker-Start im Log journalctl -u archivmail --since '2 min ago' | grep -i 'ocr worker' # Status-Verteilung sudo -u postgres psql archivmail -c \ "SELECT ocr_status, COUNT(*) FROM emails GROUP BY ocr_status;" # Such-Test (Begriff aus bekanntem PDF) curl -s 'http://127.0.0.1:9308/sql?mode=raw' \ --data-urlencode "query=SELECT mail_id FROM emails_global WHERE MATCH('rechnungsnummer') LIMIT 5" ```