Files
archivmail/features/PROJ-44-ocr-gui-integration.md
sysops 15a5da33fd feat(PROJ-13): OpenAPI 3.0 Spec + GET /api/v1/docs Endpoint
Serves the static OpenAPI YAML via go:embed. Completes the last
open acceptance criterion for PROJ-13. PROJ-44 marked Deployed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:03:58 +02:00

10 KiB

PROJ-44: OCR-GUI-Integration (Status, Download, Such-Highlight)

Status: Deployed

Created: 2026-05-08 Last Updated: 2026-05-11

Dependencies

  • Requires: PROJ-35 (OCR & Anhang-Volltext-Indexierung) — liefert ocr_status + attachment_text
  • Requires: PROJ-6 (Volltext-Suche & Filterung) — Suche-Endpoint wird erweitert
  • Requires: PROJ-7 (E-Mail-Ansicht) — Mail-Detail-Seite wird erweitert

Motivation

PROJ-35 hat den OCR-Pipeline gebaut, aber die Resultate sind bisher nur indirekt sichtbar (über Volltext-Suche, die plötzlich mehr findet). Nutzer und Auditoren sollen:

  • erkennen können, dass OCR auf einer Mail gelaufen ist und den Status sehen
  • den extrahierten Text als Reintext-Datei (.txt) herunterladen können (für GoBD-konforme Archivierung des erkannten Inhalts neben dem Original-PDF)
  • bei Suchergebnissen sehen, dass ein Treffer aus dem OCR-Text kam — und welche Stelle es war (Snippet)

User Stories

  • Als Nutzer möchte ich auf der Mail-Detail-Seite sehen, ob die Anhänge bereits OCR-verarbeitet wurden, damit ich weiß, ob die Suche im Volltext greift.
  • Als Auditor möchte ich den extrahierten Reintext einer Mail als .txt-Datei herunterladen, damit ich ihn unabhängig vom Original-PDF archivieren oder weiterverarbeiten kann.
  • Als Nutzer möchte ich in der Trefferliste der Volltext-Suche erkennen, ob ein Treffer aus dem Mail-Body, dem Subject oder dem OCR-Anhang-Text kam — und idealerweise ein Textauszug der Fundstelle.

Acceptance Criteria

  • MailDetail-Response (/api/mails/{id}) enthält neue Felder ocr_status und ocr_chars (Anzahl extrahierter Zeichen, 0 wenn nichts gefunden)
  • Mail-Detail-Seite zeigt OCR-Badge im Header neben den anderen Status-Badges (verify_ok, threading)
    • done → grünes Badge „OCR ✓"
    • failed → rotes Badge „OCR ✗"
    • skipped → graues Badge „OCR n/a"
    • pending → blaues Badge „OCR …"
    • disabled → kein Badge
  • Neuer Endpoint GET /api/mails/{id}/ocr-text liefert den extrahierten Text als text/plain; charset=utf-8 mit Content-Disposition: attachment; filename="<message-id>.ocr.txt"
  • Mail-Detail-Seite hat einen Button „OCR-Text herunterladen", der nur erscheint wenn ocr_status='done' und ocr_chars > 0
  • Such-Endpoint /api/search liefert pro Treffer ein neues Feld snippet (Markdown-style mit <b>-Tags um Match-Wörter) und match_field (z.B. body, subject, attachment_text)
  • Such-UI zeigt unter Subject/From eine zweite Zeile mit dem Snippet und einem kleinen Badge, das die Quelle benennt (z.B. „📄 aus PDF-Anhang")
  • Audit-Log: jeder OCR-Text-Download wird als mail:ocr_download erfasst
  • Externe v1-API: das gleiche ocr_status-Feld liegt im JSON von /api/v1/mails/{id}

Edge Cases

  • Mail ohne OCR-Daten (skipped, kein attachment_text) → Download-Endpoint liefert 404 mit klarer Fehlermeldung
  • Mail mit ocr_status='pending' → 202 Accepted + Hinweis „OCR läuft noch, bitte später erneut"
  • Snippet-Generierung schlägt fehl (Manticore-Highlight-Fehler) → Treffer wird ohne Snippet zurückgegeben, kein Hard-Error
  • Tenant-Isolation: OCR-Text-Endpoint prüft Mail-Zugriff genau wie /raw (PROJ-7)
  • Sehr lange OCR-Texte (>1 MB) → Endpoint streamt direkt aus Manticore, kein Buffer im Memory

Technical Requirements

Backend

Erweiterung internal/api/mails.go:

type MailDetail struct {
    // ... bestehende Felder
    OCRStatus string `json:"ocr_status"`           // pending|done|failed|skipped|disabled
    OCRChars  int    `json:"ocr_chars"`            // 0 wenn kein Text
}

Neuer Helper auf *storage.Store:

func (s *Store) GetOCRStatus(ctx context.Context, id string) (status string, chars int, err error)

Liest ocr_status aus PostgreSQL und holt die attachment_text-Länge aus Manticore (oder cached die Länge in einer neuen Spalte ocr_chars — siehe Tech Design).

Neuer Handler handleGetOCRText in internal/api/mails.go:

GET /api/mails/{id}/ocr-text
 200 OK + text/plain wenn vorhanden
 202 Accepted wenn ocr_status='pending'
 404 Not Found wenn skipped/failed/no text
 401/403 wie bei /raw

Erweiterung internal/index/index.go::Hit:

type Hit struct {
    ID         string  `json:"id"`
    Score      float64 `json:"score"`
    Snippet    string  `json:"snippet,omitempty"`     // Manticore SNIPPET() output
    MatchField string  `json:"match_field,omitempty"` // body|subject|attachment_text|...
}

Erweiterung internal/index/manticore.go::Search:

  • Pro Hit ein zweiter Query-Pass mit CALL SNIPPET(text, query, table_name) — oder direkt im SELECT mit HIGHLIGHT() (Manticore-Funktion)
  • MatchField wird heuristisch ermittelt: pro Feld separate Queries und sehen, welches Feld als erstes matcht — alternativ über WEIGHT()-Inspection

Frontend

src/components/ocr-badge.tsx (neu):

  • Kleine wiederverwendbare Badge-Komponente, die anhand des ocr_status-Werts die richtige Farbe + den richtigen Text rendert

src/app/mail/[id]/page.tsx:

  • Badge in den Header (neben Verifikation)
  • Download-Button „OCR-Text" wenn ocr_chars > 0

src/app/search/page.tsx:

  • Such-Treffer-Komponente um Snippet + Quellen-Badge erweitern
  • Snippet wird über dangerouslySetInnerHTML gerendert (nur <b>-Tags, vorher per HTML-Sanitizer wrappen — Manticore liefert nur diese eine Markup-Form)

DB-Schema (optional)

Optional kann attachment_text-Länge als emails.ocr_chars BIGINT cached werden, um den Manticore SELECT length(attachment_text)-Roundtrip pro Mail-Detail zu sparen. Tradeoff: zusätzliche Spalte vs. eine zusätzliche Manticore-Query pro Detail-Aufruf. Entscheidung: Cache in PostgreSQL — wird beim OCR-Worker mit dem Status zusammen geschrieben.

ALTER TABLE emails ADD COLUMN IF NOT EXISTS ocr_chars BIGINT DEFAULT 0;

Tech Design

Datenfluss für OCR-Text-Download

Frontend (Mail-Detail)
  ├ getMail(id) → MailDetail mit ocr_status + ocr_chars
  └ Wenn ocr_chars > 0: Button rendern
       └ Click → GET /api/mails/{id}/ocr-text
            ├ Backend: ACL-Check (requireMailAccess)
            ├ Backend: hole attachment_text aus Manticore SELECT
            ├ Backend: Audit-Log "mail:ocr_download"
            └ Response: text/plain + Content-Disposition

Datenfluss für Such-Snippet

Frontend (Suche)
  └ POST /api/search { query: "rechnungsnummer" }
       └ Backend Search:
            ├ Manticore SELECT mit MATCH() + WEIGHT() pro Treffer
            ├ Pro Treffer: CALL SNIPPET(field_value, query) für Snippet
            ├ Heuristik für match_field: prüfe welches Text-Feld den Query enthält
            └ Response: Hits mit snippet + match_field

Audit-Log-Erweiterung

Neue Aktion: mail:ocr_download mit mail_id als Subject. Bestehende Audit-Pipeline (s.audlog.Log(...)) wird weiterverwendet.

Nicht in Scope

  • OCR-Text-Bearbeitung oder -Korrektur durch Nutzer (nur Lesen + Download)
  • Inline-Anzeige des OCR-Texts auf der Mail-Detail-Seite (nur Download — der Reintext wäre oft mehrere Seiten lang)
  • Neuverarbeitung über die GUI (das geht nur per CLI archivmail ocr-reprocess)
  • Sprachen jenseits dessen, was PROJ-35 ohnehin unterstützt (deu+eng)

Implementation Notes

Implementiert (alle Akzeptanzkriterien erfüllt):

  • DB-Schema: ALTER TABLE emails ADD COLUMN IF NOT EXISTS ocr_chars BIGINT DEFAULT 0 als idempotente Migration in internal/storage/storage.go. Storage-Helper SetOCRResult / GetOCRMeta in internal/storage/ocr.go.
  • OCR-Worker schreibt ocr_chars zusammen mit ocr_status. Nutzt jetzt die kanonische emails.tenant_id aus PostgreSQL (nicht mehr Job.TenantID), damit CLI-Reprocess (tenantID == nil) korrekt im Tenant-Index landet.
  • API-Endpoint GET /api/mails/{id}/ocr-text in internal/api/ocr_handlers.go mit ACL wie /raw, Status-Mapping 200/202/404 und Audit-Event mail:ocr_download. Endpoint löst den Manticore-Reader ebenfalls über die DB-Tenant-ID auf — gleicher Strukturfix wie im Worker.
  • MailDetail (+ externes v1-API) liefert ocr_status und ocr_chars.
  • Such-Hits liefern snippet + match_field. Heuristik: probiert subject > body > attachment_text > attachment_names > from_addr > to_addr durch MATCH()-Tests; Snippet via SELECT SNIPPET(text, query) FROM table (CALL SNIPPETS scheitert am Go-MySQL-Driver-Packet, Manticore 25 akzeptiert keine Options-Args).
  • Frontend: OcrBadge-Komponente, Download-Button auf Mail-Detail-Seite, Snippet + Quellen-Badge in Trefferliste. sanitizeSnippet mit Whitelist nur für <b>-Tags.
  • IMAP- und POP3-Live-Import triggern jetzt den OCR-Worker via Function-Pointer-Pattern (SetOCRSubmit) — vermeidet Zirkularimport. Vorher blieben neu importierte Mails ewig in ocr_status='pending'.

Bugfixes während Implementierung:

  • Live-Import-Bug: IMAP/POP3 hatten keinen ocrWorker.Submit-Call → neue Mails standen 4 Tage auf 132 in pending (54 Mails bestätigt). Fix: SetOCRSubmit-Hook in cmd/archivmail/main.go verdrahtet.
  • Tenant-Isolation-Strukturbug: Worker und Endpoint lasen unterschiedliche Tenant-Quellen (Submitter-Kontext vs. Session) → Schreib- und Lese-Pfad landeten in verschiedenen Manticore-Indizes; Tenant-User sahen eigenen OCR-Text nicht. Fix: Beide Pfade lesen emails.tenant_id als Single Source of Truth.
  • Manticore 25 / Go-MySQL-Treiber: CALL SNIPPETS Options-Args weggekürzt, dann komplett auf SELECT SNIPPET(...) umgestellt — CALL-Antworten lassen sich vom Treiber nicht parsen.

QA Test Results

Verifiziert auf 192.168.1.132 mit Commit 16013e8:

Szenario Erwartung Ergebnis
Tenant-2-User, eigene Mail (Anhang mit OCR-Text) 200 + Text OK
Tenant-2-User, Mail aus Tenant-1 403/404 403 access denied — Isolation greift
Admin, Tenant-Mail 403 (SEC-29: Admin sieht keine Mail-Inhalte) 403 — korrekt
Auditor, globale Mail (tenant_id IS NULL) 200 + Text OK
Auditor, Tenant-Mail 403 OK
DB-Migration ocr_chars Spalte vorhanden Vorhanden + Index idx_emails_ocr_status
Manticore-Routing attachment_text liegt in emails_tenant_N, nicht global Bestätigt

Deployment

  • 2026-05-11 — 192.168.1.132 (Test): Commit 16013e8 aktiv, alle Dienste running, QA grün.
  • 2026-05-29 — 192.168.1.131 (Prod): Commit fa9f777 aktiv (inkl. Security-Fixes), api 1.8 / storage 1.9, alle Dienste running.