From fae274f930f74cf638a803aaf348a16686eb93b2 Mon Sep 17 00:00:00 2001 From: sysops Date: Wed, 24 Jun 2026 23:07:17 +0200 Subject: [PATCH] feat(PROJ-58): Indexierung + OCR optional als Cron-Batch statt Dauerbetrieb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues config.yml-Feld batch_mode (index/ocr, Default false = unverändertes Verhalten). Bei batch_mode:true verarbeiten neue Cron-Jobs (index-pending, ocr-reprocess) die Backlogs in größeren Abständen statt sofort bei jedem Mail-Import, um Schreiblast auf der Festplatte zu glätten. Zeiten in /etc/cron.d/archivmail frei anpassbar. Co-Authored-By: Claude Sonnet 4.6 --- cmd/archivmail/cmd_import.go | 1 + cmd/archivmail/cmd_index_pending.go | 157 +++++++++++++++++++++++ cmd/archivmail/main.go | 80 +++++++++--- config/config.docker.yml.example | 14 ++ config/config.go | 14 ++ deploy/cron.d/archivmail | 24 ++++ features/INDEX.md | 4 +- features/PROJ-57-utf8-encoding-fix.md | 33 +++++ features/PROJ-58-cron-batch-index-ocr.md | 64 +++++++++ internal/storage/storage.go | 36 ++++++ 10 files changed, 407 insertions(+), 20 deletions(-) create mode 100644 cmd/archivmail/cmd_index_pending.go create mode 100644 features/PROJ-57-utf8-encoding-fix.md create mode 100644 features/PROJ-58-cron-batch-index-ocr.md diff --git a/cmd/archivmail/cmd_import.go b/cmd/archivmail/cmd_import.go index ae79697..ca20161 100644 --- a/cmd/archivmail/cmd_import.go +++ b/cmd/archivmail/cmd_import.go @@ -300,6 +300,7 @@ Commands: recompress Bestehende Mails nachträglich gzip-komprimieren rethread Thread-IDs rückwirkend aus In-Reply-To/References befüllen ocr-reprocess OCR für Anhänge nachholen (alle oder pro Mandant/Status) + index-pending Ungeindexte Mails nachindexieren (cron-fähig, PROJ-58 batch_mode) update Auf neueste Version aktualisieren (führt update.sh aus) status Healthcheck für DB, Manticore und Storage version Version anzeigen diff --git a/cmd/archivmail/cmd_index_pending.go b/cmd/archivmail/cmd_index_pending.go new file mode 100644 index 0000000..c083beb --- /dev/null +++ b/cmd/archivmail/cmd_index_pending.go @@ -0,0 +1,157 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "strings" + "time" + + "archivmail/config" + "archivmail/internal/index" + "archivmail/internal/storage" + "archivmail/pkg/mailparser" +) + +// runIndexPending indexes all mails that have not yet been indexed +// (indexed_at IS NULL). It loads matching IDs from the DB, builds a +// MailDocument per mail, queues them on a TenantIndexWorker, waits for the +// worker to drain, then exits. Designed to be driven by cron when +// index.batch_mode is enabled (PROJ-58). +// +// Usage: +// +// archivmail index-pending --config /etc/archivmail/config.yml +// archivmail index-pending --limit 500 +func runIndexPending(args []string) { + fs := flag.NewFlagSet("index-pending", flag.ExitOnError) + configPath := fs.String("config", "/etc/archivmail/config.yml", "path to config file") + limitFlag := fs.Int("limit", 0, "max number of mails to index (0 = no limit)") + fs.Parse(args) + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + cfg, err := config.Load(*configPath) + if err != nil { + logger.Error("failed to load config", "err", err) + os.Exit(1) + } + + storeCfg := storage.Config{ + Dir: cfg.Storage.StorePath, + Keyfile: cfg.Storage.Keyfile, + DSN: cfg.Database.DSN(), + CompressEnabled: cfg.Storage.Compress, + } + mailStore, err := storage.New(storeCfg) + if err != nil { + logger.Error("storage init failed", "err", err) + os.Exit(1) + } + defer mailStore.Close() + + indexBackend := cfg.Index.Backend + if indexBackend == "" { + indexBackend = "manticore" + } + + batchSize := cfg.Index.BatchSize + if batchSize <= 0 { + batchSize = 100 + } + + var idxMgr index.TenantIndexer + if indexBackend == "manticore" { + dsn := cfg.Index.ManticoreDSN + if dsn == "" { + dsn = "manticore@tcp(127.0.0.1:9306)/?charset=utf8mb4" + } + m, err := index.NewManticoreTenantManager(dsn) + if err != nil { + logger.Error("manticore init failed", "err", err) + os.Exit(1) + } + idxMgr = m + } else { + m, err := index.NewTenantIndexManager(cfg.Index.Path, batchSize, indexBackend) + if err != nil { + logger.Error("index manager init failed", "err", err) + os.Exit(1) + } + idxMgr = m + } + defer idxMgr.Close() + + ctx := context.Background() + + mails, err := mailStore.GetUnindexedMails(ctx, *limitFlag) + if err != nil { + logger.Error("failed to list unindexed mails", "err", err) + os.Exit(1) + } + + logger.Info("index-pending: starting", "count", len(mails), "limit", *limitFlag) + if len(mails) == 0 { + return + } + + // Queue size needs to fit the entire batch so Submit never drops. + qSize := len(mails) + 16 + worker := index.NewTenantWorker(idxMgr, qSize, logger) + worker.Start() + + // Periodic progress while waiting for the queue to drain. + tick := time.NewTicker(5 * time.Second) + defer tick.Stop() + go func() { + for range tick.C { + logger.Info("index-pending: progress", "queue_remaining", worker.QueueLen()) + } + }() + + submitted := 0 + for _, m := range mails { + raw, err := mailStore.Load(m.ID) + if err != nil { + logger.Warn("index-pending: load failed", "id", m.ID, "err", err) + continue + } + pm, err := mailparser.Parse(raw) + if err != nil { + logger.Warn("index-pending: parse failed, skipping", "id", m.ID, "err", err) + continue + } + + var attachNames []string + for _, a := range pm.Attachments { + if a.Filename != "" { + attachNames = append(attachNames, a.Filename) + } + } + + doc := index.MailDocument{ + ID: m.ID, + From: pm.From, + To: strings.Join(pm.To, ", "), + CC: strings.Join(pm.CC, ", "), + Subject: pm.Subject, + Body: pm.TextBody, + AttachNames: strings.Join(attachNames, " "), + HasAttachment: len(pm.Attachments) > 0, + Date: pm.Date, + Size: int64(len(raw)), + TenantID: m.TenantID, + } + worker.Submit(doc) + + // Mark as indexed in DB so subsequent runs skip it. + if err := mailStore.SetIndexedAt(ctx, m.ID); err != nil { + logger.Warn("index-pending: set indexed_at failed", "id", m.ID, "err", err) + } + submitted++ + } + + worker.Stop() // waits for the queue to drain + logger.Info("index-pending: complete", "submitted", submitted) +} diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index ce3ce81..67a3653 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -70,6 +70,9 @@ func main() { case "ocr-reprocess": runOCRReprocess(os.Args[2:]) return + case "index-pending": + runIndexPending(os.Args[2:]) + return case "update": runUpdate(os.Args[2:]) return @@ -183,8 +186,14 @@ func main() { asyncQueueSize = 1000 } tenantWorker := index.NewTenantWorker(idxMgr, asyncQueueSize, logger) - tenantWorker.Start() - defer tenantWorker.Stop() + // PROJ-58: in batch mode the continuous index worker is not started; the + // `index-pending` cron job indexes mails (indexed_at IS NULL) instead. + if !cfg.Index.BatchMode { + tenantWorker.Start() + defer tenantWorker.Stop() + } else { + logger.Info("index worker: batch mode enabled — continuous worker not started (use 'archivmail index-pending' via cron)") + } // PROJ-35: OCR-Worker — extracts text from PDF/image attachments and feeds // it back into the per-tenant Manticore index. Non-blocking submit so the @@ -202,8 +211,14 @@ func main() { logger.Info("ocr worker: pause window configured", "from_hour", cfg.OCR.PausedHours[0], "to_hour", cfg.OCR.PausedHours[1]) } - ocrWorker.Start(context.Background()) - defer ocrWorker.Stop() + // PROJ-58: in batch mode the continuous OCR worker is not started; the + // `ocr-reprocess --status pending` cron job processes the backlog instead. + if !cfg.OCR.BatchMode { + ocrWorker.Start(context.Background()) + defer ocrWorker.Stop() + } else { + logger.Info("ocr worker: batch mode enabled — continuous worker not started (use 'archivmail ocr-reprocess' via cron)") + } if !ocr.IsAvailable() { ts := ocr.CheckTools() logger.Warn("ocr tools not fully available — install tesseract-ocr + poppler-utils for full OCR support", @@ -216,7 +231,9 @@ func main() { // pending mails as currently fit, so nothing is dropped. // The worker updates ocr_status to done/failed/skipped, so subsequent // queries only return genuinely outstanding jobs. - go func() { + // PROJ-58: skipped in batch mode — the cron job drains the backlog instead. + if !cfg.OCR.BatchMode { + go func() { ctx := context.Background() queueCap := 1000 // matches ocr.Options.QueueSize above processed := 0 @@ -250,8 +267,9 @@ func main() { processed += len(pending) logger.Info("ocr boot-resume: enqueued batch", "batch", len(pending), "total_so_far", processed) - } - }() + } + }() + } // User store users, err := userstore.New(cfg.Database.DSN()) @@ -385,7 +403,7 @@ func main() { smtpDaemon.SetIndexCallback(func(raw []byte, id string) { // Look up the tenant_id for this email from DB metadata. tenantID, _ := mailStore.GetTenantForMail(context.Background(), id) - submitToWorker(tenantWorker, mailStore, raw, id, tenantID, logger, ocrWorker) + submitToWorker(tenantWorker, mailStore, raw, id, tenantID, logger, ocrWorker, cfg.Index.BatchMode, cfg.OCR.BatchMode) }) // Wire tenant routing into SMTP daemon if cfg.SMTP.TenantRouting == "domain" { @@ -435,7 +453,8 @@ func main() { imapImp := imapstore.NewImporter(imapSt, mailStore, idxMgr, logger) // PROJ-44: trigger OCR for IMAP-imported mails — without this every // IMAP delivery would remain in ocr_status='pending' forever. - if ocrWorker != nil { + // PROJ-58: skipped in OCR batch mode (cron job drains the backlog). + if ocrWorker != nil && !cfg.OCR.BatchMode { imapImp.SetOCRSubmit(func(mailID string, tenantID *int64) { ocrWorker.Submit(mailID, tenantID) }) @@ -456,15 +475,26 @@ func main() { defer pop3St.Close() pop3Imp := pop3store.NewImporter(pop3St, mailStore, idxMgr, logger) // PROJ-44: same OCR hook as the IMAP importer above. - if ocrWorker != nil { + // PROJ-58: skipped in OCR batch mode (cron job drains the backlog). + if ocrWorker != nil && !cfg.OCR.BatchMode { pop3Imp.SetOCRSubmit(func(mailID string, tenantID *int64) { ocrWorker.Submit(mailID, tenantID) }) } srv.SetPop3(pop3St, pop3Imp) - // Backfill in background: migrate existing files into DB metadata + re-index - go runBackfill(context.Background(), mailStore, idx, tenantWorker, logger, ocrWorker) + // Backfill in background: migrate existing files into DB metadata + re-index. + // PROJ-58: skipped when index.batch_mode is set — the `index-pending` cron + // job indexes outstanding mails (indexed_at IS NULL) instead. When OCR is + // also in batch mode, hand a nil ocrWorker to runBackfill so it does not + // submit OCR jobs to the (unstarted) in-memory queue. + if !cfg.Index.BatchMode { + backfillOCR := ocrWorker + if cfg.OCR.BatchMode { + backfillOCR = nil + } + go runBackfill(context.Background(), mailStore, idx, tenantWorker, logger, backfillOCR) + } // Background integrity verification — runs every 5 minutes go runIntegrityCheck(context.Background(), mailStore, logger) @@ -522,7 +552,11 @@ func reloadOCRPauseWindow(configPath string, ocrWorker *ocr.Worker, logger *slog // tenantID may be nil for global context. // If ocrWorker is non-nil and the mail has attachments, an OCR job is also // queued (non-blocking). -func submitToWorker(worker *index.TenantIndexWorker, store *storage.Store, raw []byte, id string, tenantID *int64, logger *slog.Logger, ocrWorker *ocr.Worker) { +// indexBatchMode / ocrBatchMode (PROJ-58): when set, the corresponding +// in-memory submit is skipped so the unstarted batch-mode worker queue does +// not fill up and log spurious "queue full" warnings. The mail still gets its +// indexed_at / ocr_status state so the cron batch jobs pick it up. +func submitToWorker(worker *index.TenantIndexWorker, store *storage.Store, raw []byte, id string, tenantID *int64, logger *slog.Logger, ocrWorker *ocr.Worker, indexBatchMode, ocrBatchMode bool) { pm, err := mailparser.Parse(raw) if err != nil { logger.Warn("index: parse failed, skipping indexing", "id", id, "err", err) @@ -550,15 +584,21 @@ func submitToWorker(worker *index.TenantIndexWorker, store *storage.Store, raw [ TenantID: tenantID, } - worker.Submit(doc) + // PROJ-58: in index batch mode the continuous worker is not running; leave + // the mail with indexed_at IS NULL so `index-pending` picks it up via cron. + if !indexBatchMode { + worker.Submit(doc) - // Mark as indexed in DB - if err := store.SetIndexedAt(context.Background(), id); err != nil { - logger.Warn("index: set indexed_at failed", "id", id, "err", err) + // Mark as indexed in DB + if err := store.SetIndexedAt(context.Background(), id); err != nil { + logger.Warn("index: set indexed_at failed", "id", id, "err", err) + } } // PROJ-35: hand off to OCR worker for asynchronous attachment processing. - if ocrWorker != nil && len(pm.Attachments) > 0 { + // PROJ-58: skipped in OCR batch mode — the mail stays ocr_status='pending' + // (set at storage time) and is processed by the ocr-reprocess cron job. + if !ocrBatchMode && ocrWorker != nil && len(pm.Attachments) > 0 { ocrWorker.Submit(id, tenantID) } } @@ -612,7 +652,9 @@ func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, w if !alreadyIndexed { needIndex++ tenantID, _ := store.GetTenantForMail(ctx, id) - submitToWorker(worker, store, raw, id, tenantID, logger, ocrWorker) + // runBackfill only runs when index.batch_mode is off; the OCR + // batch case is handled by passing a nil ocrWorker from the caller. + submitToWorker(worker, store, raw, id, tenantID, logger, ocrWorker, false, false) } if count%100 == 0 { diff --git a/config/config.docker.yml.example b/config/config.docker.yml.example index 687ac58..62f70b0 100644 --- a/config/config.docker.yml.example +++ b/config/config.docker.yml.example @@ -30,6 +30,12 @@ index: backend: xapian batch_size: 100 async_queue_size: 1000 + # PROJ-58: batch_mode (optional, default false). + # true = der Dauerbetrieb-Index-Worker wird NICHT gestartet; neue Mails + # bleiben indexed_at IS NULL bis der Cron-Job 'archivmail index-pending' + # sie verarbeitet (siehe deploy/cron.d/archivmail). Glättet Schreiblast. + # false (Default) = sofortige, fortlaufende Indexierung wie bisher. + # batch_mode: false api: bind: "0.0.0.0:8080" @@ -61,6 +67,14 @@ imap_server: # Ohne Sektion / ohne paused_hours: altes Verhalten (immer aktiv). # ocr: # paused_hours: [8, 18] # OCR pausiert während der Geschäftszeiten +# +# PROJ-58: ocr.batch_mode (optional, default false). +# true = der Dauerbetrieb-OCR-Worker wird NICHT gestartet; neue Mails bleiben +# ocr_status='pending' bis der Cron-Job +# 'archivmail ocr-reprocess --status pending' sie verarbeitet +# (siehe deploy/cron.d/archivmail). false (Default) = sofortige Verarbeitung. +# ocr: +# batch_mode: false # PROJ-56: IMAP-Sync-Jitter (optional). # jitter_seconds verteilt den tatsächlichen Sync-Start jedes Accounts diff --git a/config/config.go b/config/config.go index 35477a1..166e136 100644 --- a/config/config.go +++ b/config/config.go @@ -51,6 +51,13 @@ type OCRConfig struct { // Wrap-around windows are supported, e.g. [22, 6] = paused 22:00–06:00. // nil / unset = never pause (legacy behaviour: process immediately). PausedHours *[2]int `yaml:"paused_hours,omitempty"` + // BatchMode (PROJ-58): when true, the continuous OCR worker is NOT started + // at daemon boot and the upload path does not submit jobs to the in-memory + // queue. OCR then runs only via the cron batch command + // (`archivmail ocr-reprocess --status pending`). New mails stay + // ocr_status='pending' in the DB until the next cron run. + // false (default) = legacy behaviour (immediate, continuous processing). + BatchMode bool `yaml:"batch_mode"` } // IMAPSchedulerConfig holds settings for the automatic IMAP sync scheduler (PROJ-56). @@ -157,6 +164,13 @@ type IndexConfig struct { BatchSize int `yaml:"batch_size"` AsyncQueueSize int `yaml:"async_queue_size"` ManticoreDSN string `yaml:"manticore_dsn"` // DSN for Manticore backend (default: "manticore@tcp(127.0.0.1:9306)/?charset=utf8mb4") + // BatchMode (PROJ-58): when true, the continuous index worker is NOT started + // at daemon boot and the upload path does not submit documents to the + // in-memory queue. Indexing then runs only via the cron batch command + // (`archivmail index-pending`). New mails stay indexed_at IS NULL in the DB + // until the next cron run. + // false (default) = legacy behaviour (immediate, continuous indexing). + BatchMode bool `yaml:"batch_mode"` } // DefaultAuditLogPath is the default location of the append-only JSON-Lines diff --git a/deploy/cron.d/archivmail b/deploy/cron.d/archivmail index 3c4909c..bfba9bd 100644 --- a/deploy/cron.d/archivmail +++ b/deploy/cron.d/archivmail @@ -32,5 +32,29 @@ # ("mail_purged") — analog zu Pilers purge.sh, nachts um 03:40 Uhr. 40 3 * * * root /opt/archivmail/archivmail purge --config /etc/archivmail/config.yml >> /var/log/archivmail/purge.log 2>&1 +# ── Batch-Modus: Indexierung + OCR per Cron (PROJ-58) ─────────────────── +# NUR relevant, wenn in /etc/archivmail/config.yml index.batch_mode: true +# bzw. ocr.batch_mode: true gesetzt ist. In diesem Modus startet der Daemon +# den jeweiligen Dauerbetrieb-Worker NICHT — neue Mails bleiben mit +# indexed_at IS NULL bzw. ocr_status='pending' in der DB stehen und werden +# erst vom nächsten Cron-Lauf hier durchsuchbar / OCR-verarbeitet gemacht. +# Sinn: viele kleine Schreibzugriffe (Manticore, tesseract, DB) werden zu +# geblockten Batches gebündelt, statt sofort bei jedem Mail-Import zu laufen. +# +# Bei index.batch_mode: false / ocr.batch_mode: false (Default) sind diese +# beiden Zeilen WIRKUNGSLOS bzw. redundant — der Worker läuft dann ohnehin +# dauerhaft und arbeitet alles sofort ab. Wer batch_mode nicht nutzt, kann +# die beiden Zeilen einfach auskommentiert lassen. +# +# Anpassen: Intervall/Uhrzeiten unten nach Bedarf ändern (crontab-Syntax). +# Die beiden Jobs sind bewusst leicht versetzt, damit Index- und OCR-Lauf +# nicht exakt zeitgleich starten. + +# Index-Backlog (indexed_at IS NULL) alle 15 Minuten verarbeiten +*/15 * * * * root /opt/archivmail/archivmail index-pending --config /etc/archivmail/config.yml --limit 500 >> /var/log/archivmail/index-pending.log 2>&1 + +# OCR-Backlog (ocr_status='pending') alle 15 Minuten, um 5 Min versetzt +5,20,35,50 * * * * root /opt/archivmail/archivmail ocr-reprocess --config /etc/archivmail/config.yml --status pending --limit 500 >> /var/log/archivmail/ocr-reprocess.log 2>&1 + # ── Weitere Jobs (geplant, noch nicht implementiert) ──────────────────── # 30 2 * * * archivmail /opt/archivmail/archivmail reindex # nächtlicher Voll-Reindex diff --git a/features/INDEX.md b/features/INDEX.md index d014c1a..526b8f0 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -73,7 +73,9 @@ | PROJ-54 | Fix Listenansicht/Pagination für Rolle "user" (Nachbesserung PROJ-6/PROJ-21) | Deployed | [PROJ-54](PROJ-54-fix-listenansicht-total.md) | 2026-06-14 | | PROJ-55 | Fix Tenant-Isolation für Rolle "auditor" + Audit-Log (Sicherheitsbug, DSGVO-relevant) | Deployed | [PROJ-55](PROJ-55-fix-auditor-tenant-isolation.md) | 2026-06-21 | | PROJ-56 | Last-Entzerrung für Hintergrundjobs (OCR-Zeitfenster, IMAP-Sync-Jitter) | Deployed | [PROJ-56](PROJ-56-last-entzerrung-hintergrundjobs.md) | 2026-06-22 | +| PROJ-57 | UTF-8-Encoding-Fix für Mails mit Nicht-UTF-8-Charset | Deployed | [PROJ-57](PROJ-57-utf8-encoding-fix.md) | 2026-06-24 | +| PROJ-58 | Indexierung + OCR als Cron-Batch-Jobs (statt Dauerbetrieb) | Deployed | [PROJ-58](PROJ-58-cron-batch-index-ocr.md) | 2026-06-24 | -## Next Available ID: PROJ-57 +## Next Available ID: PROJ-59 diff --git a/features/PROJ-57-utf8-encoding-fix.md b/features/PROJ-57-utf8-encoding-fix.md new file mode 100644 index 0000000..c7122df --- /dev/null +++ b/features/PROJ-57-utf8-encoding-fix.md @@ -0,0 +1,33 @@ +# PROJ-57: UTF-8-Encoding-Fix für Mails mit Nicht-UTF-8-Charset + +## Status: Deployed +**Created:** 2026-06-24 +**Last Updated:** 2026-06-24 + +## Hintergrund (Nutzerwunsch) +Eine archivierte Mail mit Öffnungszeiten zeigte kaputte Umlaute ("f�r" statt "für") sowohl in der Mail-Ansicht als auch in der Volltextsuche. + +## Root Cause +`pkg/mailparser/parser.go` ignorierte das `charset`-Parameter aus `Content-Type` und interpretierte die rohen Bytes immer als UTF-8. Mails mit `charset=iso-8859-1`/`windows-1252` wurden dadurch zu Mojibake. Zusätzlich fehlte das Charset in der Manticore-MySQL-Verbindung (DSN) und im `Content-Type`-Header der JSON-API-Responses. + +## Acceptance Criteria +- [x] `mailparser.Parse()` konvertiert Text-/HTML-Bodies anhand des deklarierten `charset`-Parameters nach UTF-8 (Single-Part und Multipart). +- [x] Unbekannte/fehlende Charsets oder bereits UTF-8/ASCII bleiben unverändert (kein Verhaltensbruch für den Normalfall). +- [x] Manticore-Verbindung nutzt `?charset=utf8mb4`. +- [x] JSON-API-Responses setzen `Content-Type: application/json; charset=utf-8`. + +## Implementation Notes (2026-06-24) +- `pkg/mailparser/parser.go`: neue Funktion `decodeCharset()` (nutzt `golang.org/x/text/encoding/htmlindex`), aufgerufen nach `decodeBody()` in `Parse()` (Single-Part) und `parseMultipart()`. +- `cmd/archivmail/main.go`, `cmd_import.go`, `cmd_import_piler.go`, `cmd_ocr_reprocess.go`, `cmd_purge.go`, `cmd_reindex.go`, `cmd_status.go`: Default-Manticore-DSN auf `?charset=utf8mb4` erweitert (war an 7 Stellen dupliziert). +- `config/config.go`: Doku-Kommentar zum Default-DSN aktualisiert. +- `internal/api/server.go`: `writeJSON()` setzt jetzt `application/json; charset=utf-8`. +- `go.mod`: `golang.org/x/text` von indirect zu direct dependency (jetzt direkt importiert). + +## QA / Verifikation +- Build auf 192.168.1.132: `go mod tidy` + `CGO_ENABLED=0 go build -buildvcs=false` → Exit 0, keine fehlenden go.sum-Einträge. +- Funktionstest: `.eml`-Testmail mit `Content-Type: text/plain; charset=iso-8859-1` und Umlauten importiert → über `store.Load()` + `mailparser.Parse()` (identischer Pfad wie `handleGetMail`) korrektes UTF-8 ("Öffnungszeiten") bestätigt, keine Mojibake-Zeichen. +- Storage bleibt bewusst byte-genau im Original-Charset (GoBD-Originalarchiv); Konvertierung passiert erst beim Parsen für Anzeige/Index. + +## Deployment +- Test (192.168.1.132): Build + Funktionstest grün, kein Dauerbetrieb-Eingriff (Binary nach Test zurückgesetzt). 2026-06-24. +- Produktion (192.168.1.131): `update.sh` (Commit `76655f7`), Backend+Frontend aktiv, Health-Check OK, keine Fehler im Log. 2026-06-24. diff --git a/features/PROJ-58-cron-batch-index-ocr.md b/features/PROJ-58-cron-batch-index-ocr.md new file mode 100644 index 0000000..9fcb399 --- /dev/null +++ b/features/PROJ-58-cron-batch-index-ocr.md @@ -0,0 +1,64 @@ +# PROJ-58: Indexierung + OCR als Cron-Batch-Jobs (statt Dauerbetrieb) + +## Status: Deployed +**Created:** 2026-06-24 +**Last Updated:** 2026-06-24 + +## Dependencies +- PROJ-30 (Manticore-Indexierung) +- PROJ-35 (OCR & Anhang-Volltext-Indexierung) +- PROJ-56 (Last-Entzerrung für Hintergrundjobs — verwandtes Cron-Muster) + +## Hintergrund (Nutzerwunsch) +Aktuell laufen Indexierung (`internal/index/tenant_worker.go`) und OCR (`internal/ocr/worker.go`) sofort und nebenläufig bei jedem Mail-Import als Dauerbetrieb-Goroutinen. Das erzeugt viele kleine Schreibzugriffe auf die Festplatte (Manticore-Writes, OCR-Tesseract-Output, DB-Updates) statt geblockter Batches. Nutzerwunsch: Beide Prozesse sollen optional in größeren, **per `cron.d` konfigurierbaren Zeitabständen** laufen, damit die Zeiten später selbst angepasst werden können (analog zum bestehenden Purge-Cron, PROJ-56c). + +## Bestehende Bausteine (bereits vorhanden, lt. Code-Analyse) +- `indexed_at TIMESTAMPTZ` (storage.go) markiert bereits indexierte Mails — Query auf `indexed_at IS NULL` liefert die Pending-Liste ohne neue Spalte. +- `ocr_status` (pending/done/failed/skipped/disabled) ist bereits vollständig vorhanden. +- `cmd_ocr_reprocess.go` ist bereits "lade Batch → verarbeite → beenden" und damit direkt cron-fähig. +- Es fehlt ein äquivalenter Batch-Befehl für die Indexierung (aktuell nur `cmd_reindex.go`, das *immer alle* Mails neu indexiert statt nur die ungeindexten — ungeeignet für einen häufigen Cron-Lauf). + +## Entscheidung (Nutzer, 2026-06-24) +- Modus per Config umschaltbar (`index.batch_mode`, `ocr.batch_mode`), Default `false` = aktuelles Verhalten unverändert (non-breaking, analog PROJ-56). +- Bei `batch_mode: true` wird der jeweilige Dauerbetrieb-Worker im Daemon **nicht gestartet**; neue Mails bleiben bis zum nächsten Cron-Lauf mit `indexed_at IS NULL` / `ocr_status='pending'` in der DB stehen (kein Datenverlust, nur verzögerte Sichtbarkeit in Suche/OCR). +- Zeiten stehen in `/etc/cron.d/archivmail`, frei editierbar, mit demselben Kommentarstil wie der bestehende OCR-Pausen- und Purge-Cron. + +## Acceptance Criteria +- [x] Neuer CLI-Befehl `archivmail index-pending --config ... --limit N` lädt Mails mit `indexed_at IS NULL` (Query-Pattern analog `cmd_ocr_reprocess.go`), indexiert sie über den `TenantIndexWorker`, wartet auf vollständiges Drain, beendet sich danach. +- [x] `config.yml`: neue Felder `index.batch_mode` (bool, default false) und `ocr.batch_mode` (bool, default false). +- [x] Bei `batch_mode: true` wird der jeweilige Worker beim Daemon-Start nicht gestartet und der Upload-Pfad submitted nicht mehr in den In-Memory-Channel (kein sinnloses Queue-Volllaufen/Log-Spam). +- [x] Bei `batch_mode: false` (Default) bleibt das bisherige Verhalten 1:1 erhalten — keine Regression für bestehende Installationen. +- [x] `deploy/cron.d/archivmail` bekommt zwei neue, kommentierte Cron-Zeilen für `index-pending` und `ocr-reprocess --status pending`, mit Beispiel-Intervall (z.B. alle 15 Minuten), klar als "Zeiten hier anpassen" markiert — analog zum bestehenden OCR-Pausenfenster-Kommentarstil. +- [x] Boot-Resume-Goroutinen (OCR-Backfill in main.go, Index-Backfill `runBackfill`) laufen nur, wenn der jeweilige `batch_mode` **nicht** aktiv ist (sonst übernimmt der Cron-Job diese Aufgabe). +- [x] Dokumentation im Cron-File erklärt, dass bei `batch_mode: true` neue Mails erst nach dem nächsten Cron-Lauf durchsuchbar/OCR-bearbeitet sind. + +## Tech Design +Übersprungen (klar umrissene, additive Konfigurationsoption mit bestehenden Bausteinen — kein architektonischer Schnitt, analog PROJ-56). + +## Implementation Notes (2026-06-24) + +### Geänderte/neue Dateien +- `config/config.go`: neues Feld `BatchMode bool` (`yaml:"batch_mode"`, default false) in `IndexConfig` und `OCRConfig`. Additiv/non-breaking, kein Pointer nötig da `false` der gewünschte Default ist. +- `internal/storage/storage.go`: neue Funktion `GetUnindexedMails(ctx, limit)` + Typ `UnindexedMail{ID, TenantID}` — Query `WHERE indexed_at IS NULL ORDER BY received_at DESC` (analog zu `GetMailsByOCRStatus`). +- `cmd/archivmail/cmd_index_pending.go` (neu): CLI-Befehl `index-pending` (Flags `--config`, `--limit`), Vorbild `cmd_ocr_reprocess.go`. Lädt ungeindexte Mails, parst sie, baut `index.MailDocument`, queued sie auf einen frisch erstellten `TenantIndexWorker` (Queue = batch+16, kein Drop), setzt `indexed_at`, wartet via `worker.Stop()` auf vollständiges Drain, beendet sich. +- `cmd/archivmail/main.go`: Befehl im Dispatcher registriert. Daemon-Start gated: bei `cfg.Index.BatchMode` kein `tenantWorker.Start()` und kein `runBackfill`; bei `cfg.OCR.BatchMode` kein `ocrWorker.Start()`, keine OCR-Boot-Resume-Goroutine, keine IMAP/POP3-`SetOCRSubmit`-Hooks. `submitToWorker()` um zwei Flags (`indexBatchMode`, `ocrBatchMode`) erweitert → überspringt die jeweiligen In-Memory-Submits (kein Queue-Volllaufen / Log-Spam). Mails behalten dabei `indexed_at IS NULL` bzw. `ocr_status='pending'` und werden vom Cron-Job nachgezogen. +- `deploy/cron.d/archivmail`: zwei neue, kommentierte Zeilen (`index-pending` `*/15`, `ocr-reprocess --status pending` `5,20,35,50`), klar als nur-bei-`batch_mode:true`-relevant und frei editierbar markiert. +- `config/config.docker.yml.example`: auskommentierte Beispiele für `index.batch_mode` und `ocr.batch_mode`. + +### Design-Entscheidungen / Abweichungen +- `BatchMode` ist ein einfacher `bool` (kein Pointer wie bei PROJ-56 `JitterSeconds`), da hier `false` = Default = gewünschtes Alt-Verhalten; eine Unterscheidung unset/explizit-false ist nicht nötig. +- IMAP/POP3-Importer indexieren synchron direkt über `idxMgr` (nicht über den `TenantIndexWorker`) — dieser Pfad bleibt unverändert; `index.batch_mode` betrifft bewusst nur den Worker-/SMTP-Upload-Pfad (Schreiblast-Glättung des Async-Workers). OCR-Hooks der Importer werden hingegen bei `ocr.batch_mode` deaktiviert, da OCR ausschließlich über den Worker läuft. +- Lokal kein `go build` möglich (kein Toolchain) — nur statische Konsistenzprüfung; Build-Verifikation auf 192.168.1.131/132. + +## QA Test Results (192.168.1.132, 2026-06-24) +- Build: `CGO_ENABLED=0 go build -buildvcs=false -o archivmail ./cmd/archivmail/` → Exit 0. +- `go vet ./...`: nur vorbestehende, PROJ-58-unabhängige Befunde (xapian_wrapper.cpp/cgo, storage.go self-assignment, storage_test.go-Signatur). Keine neuen Befunde durch PROJ-58. +- Default-Verhalten (`batch_mode` unset/false): Live-Service unverändert weitergelaufen, kontinuierliche Indexierung/OCR + Boot-Backfill bestätigt aktiv, `/api/health` ok. +- `batch_mode: true` (isolierte Test-Config): Daemon startet, loggt "batch mode enabled — continuous worker not started" für beide Worker, kein Backfill/Boot-Resume, keine kontinuierliche Verarbeitung — wie spezifiziert. +- `index-pending --limit 5` gegen Test-DB: 5 ungeindexte Mails geladen, Worker drained, Exit 0. +- Minor-Finding behoben: `index-pending` fehlte in `printHelp()` (cmd_import.go) — ergänzt. +- Keine Critical/High-Findings. Server 192.168.1.131 nicht angefasst während der QA. + +## Out of Scope +- Kein Wechsel der bestehenden Mechanismen für Installationen, die `batch_mode` nicht setzen. +- Kein UI/Admin-Schalter im Frontend — Konfiguration ausschließlich über `config.yml` + `cron.d`. diff --git a/internal/storage/storage.go b/internal/storage/storage.go index cb1fa11..b7388d5 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -1019,6 +1019,42 @@ func (s *Store) IsIndexed(ctx context.Context, id string) (bool, error) { return indexed, err } +// UnindexedMail describes one mail awaiting full-text indexing. TenantID is +// nil when the mail has no tenant assignment (system-level / global). +type UnindexedMail struct { + ID string + TenantID *int64 +} + +// GetUnindexedMails returns up to limit mails with indexed_at IS NULL, newest +// first. limit <= 0 means no limit. Used by the `index-pending` cron batch +// command (PROJ-58). +func (s *Store) GetUnindexedMails(ctx context.Context, limit int) ([]UnindexedMail, error) { + if s.db == nil { + return nil, nil + } + q := `SELECT id, tenant_id FROM emails WHERE indexed_at IS NULL ORDER BY received_at DESC` + args := []interface{}{} + if limit > 0 { + q += " LIMIT $1" + args = append(args, limit) + } + rows, err := s.db.Query(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("storage: get unindexed mails: %w", err) + } + defer rows.Close() + var out []UnindexedMail + for rows.Next() { + var m UnindexedMail + if err := rows.Scan(&m.ID, &m.TenantID); err != nil { + continue + } + out = append(out, m) + } + return out, rows.Err() +} + // ── Backfill ────────────────────────────────────────────────────────────── // Backfill walks the store directory, parses each email, inserts missing DB