88e9d0c08c
- 132 + 131 deployed - hashMailID-Bit-Mask-Fix dokumentiert (negative IDs Problem) - OCR-Tempo-Constraint (CPU-bound) erklärt - Boot-Resume-Loop als bekannter, unkritischer Bug vermerkt
262 lines
13 KiB
Markdown
262 lines
13 KiB
Markdown
# PROJ-35: OCR & Anhang-Volltext-Indexierung
|
||
|
||
## Status: Deployed
|
||
**Created:** 2026-04-04
|
||
**Last Updated:** 2026-05-08
|
||
**Deployed:** 2026-05-08 (192.168.1.131 + 192.168.1.132)
|
||
|
||
## 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(<storage_dir>/ocr-tmp)`.
|
||
2. **`hashMailID` darf nicht negativ sein** (Commits `7ba677e` + `d71d20d`): Manticore RT erlaubt nur positive int64-IDs. Initial-Implementierung nutzte uint64 → Treiber meldete „number out of range". Erster Fix-Versuch mit `int64(h.Sum64())` produzierte negative Werte für die obere uint64-Hälfte → Manticore wies INSERT/REPLACE ab („Negative document ids are not allowed"). Finaler Fix: Bit-Mask `0x7FFFFFFFFFFFFFFF` zwingt das Top-Bit auf 0, Result garantiert in `[0, 2^63-1]`.
|
||
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`.
|
||
5. **OCR-Tempo ist CPU-gebunden**: Auf 192.168.1.132 (2 GB RAM, 2 vCPU) schafft tesseract bei 2 Worker-Goroutinen ~3–7 OCR-Events/Min — hauptsächlich durch große Smartphone-JPGs (45+ s pro Datei) und mehrseitige PDFs. Ein vollständiger Reprocess von 15.000 Mails dauert dort ~1–2 Tage. Auf stärkeren Servern entsprechend weniger. Mehr Worker-Goroutinen bringen nichts solange CPU bei 100 % steht.
|
||
6. **Boot-Resume-Loop bei großen Backlogs**: Wenn beim Start sehr viele Mails `pending` sind, kann derselbe Batch mehrfach via `GetPendingOCRMails` geholt und re-enqueued werden, weil der Worker den Status erst nach Verarbeitung setzt. Submit ist non-blocking (Drop bei voller Queue), funktional unkritisch — aber DB-Last und Log-Rauschen. Mitigation: kleines `seen`-Set im Boot-Resume oder Status-Vorsetzung auf `processing` beim Submit. Nicht behoben in 0.x — siehe Folgearbeit.
|
||
|
||
## QA Test Results
|
||
|
||
### Test-Server 192.168.1.132
|
||
|
||
Nach Bit-Mask-Fix-Deploy + TRUNCATE + frischem Reindex:
|
||
|
||
```
|
||
reindex: complete total=15.191 indexed=15.191 errors=0
|
||
```
|
||
|
||
OCR-Suche live verifiziert (Beispiel):
|
||
```sql
|
||
SELECT mail_id, attachment_text
|
||
FROM emails_tenant_1
|
||
WHERE MATCH('@attachment_text rechnung')
|
||
LIMIT 3;
|
||
-- 3 Treffer: Cleverbridge-Rechnungen mit IBAN/Beträgen vollständig indexiert
|
||
```
|
||
|
||
OCR-Reprocess läuft im Hintergrund weiter (~5 Events/Min, ~1–2 Tage Gesamtlaufzeit).
|
||
|
||
### Produktions-Server 192.168.1.131
|
||
|
||
```
|
||
reindex: complete total=86 indexed=86 errors=0
|
||
ocr worker: started workers=2 queue=1000
|
||
```
|
||
|
||
- DESC `emails_global` enthält `attachment_text text indexed stored` ✓
|
||
- PostgreSQL `emails.ocr_status` Spalte vorhanden ✓
|
||
- 86 Mails alle `skipped` (kein Anhang) — korrektes Verhalten ✓
|
||
- Service-Stack (`manticore` + `archivmail` + `archivmail-web`) durchgehend `active`
|
||
|
||
## Deployment
|
||
|
||
| Server | Datum | Status |
|
||
|---|---|---|
|
||
| 192.168.1.132 (Test) | 2026-05-08 | Deployed — Reindex grün, OCR-Reprocess läuft im Hintergrund (~5/min, mehrere Tage Gesamtlaufzeit) |
|
||
| 192.168.1.131 (Prod) | 2026-05-08 | Deployed — 86 Mails reindexiert, alle ohne Anhang → `skipped` |
|
||
|
||
### 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"
|
||
```
|