feat(PROJ-58): Indexierung + OCR optional als Cron-Batch statt Dauerbetrieb

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 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-06-24 23:07:17 +02:00
co-authored by Claude Sonnet 4.6
parent 76655f78a2
commit fae274f930
10 changed files with 407 additions and 20 deletions
+1
View File
@@ -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
+157
View File
@@ -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)
}
+61 -19
View File
@@ -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 {
+14
View File
@@ -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
+14
View File
@@ -51,6 +51,13 @@ type OCRConfig struct {
// Wrap-around windows are supported, e.g. [22, 6] = paused 22:0006: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
+24
View File
@@ -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
+3 -1
View File
@@ -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 |
<!-- Add features above this line -->
## Next Available ID: PROJ-57
## Next Available ID: PROJ-59
+33
View File
@@ -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 ("fr" 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.
+64
View File
@@ -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`.
+36
View File
@@ -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