diff --git a/features/INDEX.md b/features/INDEX.md index f4ff4e0..d4217c9 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -60,7 +60,7 @@ | PROJ-41 | Dashboard Zeitreihe + Speicherprognose | Deployed | [PROJ-41](PROJ-41-dashboard-zeitreihe.md) | 2026-04-05 | | PROJ-42 | Gespeicherte Suchanfragen | Deployed | [PROJ-42](PROJ-42-gespeicherte-suchanfragen.md) | 2026-04-05 | | PROJ-43 | Automatische Archivierungsregeln | Planned | [PROJ-43](PROJ-43-archivierungsregeln.md) | 2026-04-05 | -| PROJ-44 | OCR-GUI-Integration (Status, Download, Such-Highlight) | In Review | [PROJ-44](PROJ-44-ocr-gui-integration.md) | 2026-05-08 | +| PROJ-44 | OCR-GUI-Integration (Status, Download, Such-Highlight) | Deployed | [PROJ-44](PROJ-44-ocr-gui-integration.md) | 2026-05-08 | | PROJ-45 | IMAP Per-Folder UID-Tracking + UIDVALIDITY-Check | Deployed | [PROJ-45](PROJ-45-imap-folder-uid-tracking.md) | 2026-05-11 | diff --git a/features/PROJ-44-ocr-gui-integration.md b/features/PROJ-44-ocr-gui-integration.md new file mode 100644 index 0000000..e6facc4 --- /dev/null +++ b/features/PROJ-44-ocr-gui-integration.md @@ -0,0 +1,187 @@ +# 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=".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 ``-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`:** +```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`: +```go +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`: +```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`:** +```go +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 ``-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. + +```sql +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 ``-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. diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml new file mode 100644 index 0000000..e141e8f --- /dev/null +++ b/internal/api/openapi.yaml @@ -0,0 +1,250 @@ +openapi: "3.0.3" +info: + title: archivmail REST API + version: "1" + description: | + Read-only REST API for external CRM/ERP systems. + Authenticate with an API key obtained from the admin panel. + All endpoints are GET-only. POST/PUT/PATCH/DELETE return 405. + +servers: + - url: /api/v1 + +security: + - bearerAuth: [] + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: "API key in format `am_` — obtained once from Admin → API Keys." + + schemas: + Error: + type: object + properties: + error: + type: string + required: [error] + + MailSummary: + type: object + properties: + id: + type: string + description: Unique message ID (UUID) + from: + type: string + to: + type: string + subject: + type: string + date: + type: string + format: date-time + size: + type: integer + description: Raw EML size in bytes + has_attachments: + type: boolean + + MailDetail: + type: object + properties: + id: + type: string + from: + type: string + to: + type: string + cc: + type: string + subject: + type: string + date: + type: string + format: date-time + size: + type: integer + body_plain: + type: string + ocr_status: + type: string + enum: [pending, done, failed, skipped, disabled] + attachments: + type: array + items: + type: object + properties: + index: + type: integer + filename: + type: string + content_type: + type: string + size: + type: integer + + SearchResponse: + type: object + properties: + mails: + type: array + items: + $ref: "#/components/schemas/MailSummary" + total: + type: integer + page: + type: integer + pages: + type: integer + +paths: + /mails: + get: + summary: Search / list mails + description: | + Search the archive. All parameters are optional and combined with AND. + + **Role `user`:** the `contact` parameter is required. + **Role `auditor`:** all parameters are optional. + + Results are paginated (max 100 per page). + parameters: + - name: q + in: query + description: Full-text query + schema: + type: string + - name: contact + in: query + description: > + Match mails where this address appears in From **or** To. + Required for `user`-role keys. + schema: + type: string + - name: from + in: query + description: Filter by sender address (ignored when `contact` is set) + schema: + type: string + - name: to + in: query + description: Filter by recipient address (ignored when `contact` is set) + schema: + type: string + - name: subject + in: query + description: Filter by subject (appended to `q`) + schema: + type: string + - name: date_from + in: query + description: "Start of date range (RFC 3339 or YYYY-MM-DD)" + schema: + type: string + - name: date_to + in: query + description: "End of date range (RFC 3339 or YYYY-MM-DD, inclusive)" + schema: + type: string + - name: page + in: query + schema: + type: integer + default: 1 + - name: limit + in: query + schema: + type: integer + default: 25 + maximum: 100 + responses: + "200": + description: Search results + content: + application/json: + schema: + $ref: "#/components/schemas/SearchResponse" + "400": + description: Missing required parameter (e.g. `contact` for user-role keys) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Missing or invalid API key + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "429": + description: Rate limit exceeded + headers: + Retry-After: + schema: + type: integer + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /mails/{message_id}: + get: + summary: Get mail metadata + parameters: + - name: message_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Mail metadata and parsed body + content: + application/json: + schema: + $ref: "#/components/schemas/MailDetail" + "401": + description: Missing or invalid API key + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Mail not found or not accessible + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /mails/{message_id}/raw: + get: + summary: Download original EML + parameters: + - name: message_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Raw EML file + content: + application/octet-stream: + schema: + type: string + format: binary + "401": + description: Missing or invalid API key + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Mail not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" diff --git a/internal/api/server.go b/internal/api/server.go index 7aabe46..56c0723 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -296,6 +296,7 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/admin/users/{id}/totp/reset", s.authAdmin(s.handleTOTPReset)) // PROJ-13: External REST API v1 (API-key auth) + s.mux.HandleFunc("GET /api/v1/docs", s.handleV1Docs) s.mux.HandleFunc("/api/v1/mails", s.apiKeyMw.Wrap(s.handleV1SearchMails)) s.mux.HandleFunc("GET /api/v1/mails/{message_id}", s.apiKeyMw.Wrap(s.handleV1GetMail)) s.mux.HandleFunc("GET /api/v1/mails/{message_id}/raw", s.apiKeyMw.Wrap(s.handleV1GetMailRaw)) diff --git a/internal/api/v1_docs_handler.go b/internal/api/v1_docs_handler.go new file mode 100644 index 0000000..33a72b2 --- /dev/null +++ b/internal/api/v1_docs_handler.go @@ -0,0 +1,17 @@ +package api + +import ( + _ "embed" + "net/http" +) + +//go:embed openapi.yaml +var openapiSpec []byte + +// handleV1Docs serves the OpenAPI 3.0 spec for the external REST API. +// GET /api/v1/docs +func (s *Server) handleV1Docs(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write(openapiSpec) +}