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 recompress Bestehende Mails nachträglich gzip-komprimieren
rethread Thread-IDs rückwirkend aus In-Reply-To/References befüllen 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) 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) update Auf neueste Version aktualisieren (führt update.sh aus)
status Healthcheck für DB, Manticore und Storage status Healthcheck für DB, Manticore und Storage
version Version anzeigen 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": case "ocr-reprocess":
runOCRReprocess(os.Args[2:]) runOCRReprocess(os.Args[2:])
return return
case "index-pending":
runIndexPending(os.Args[2:])
return
case "update": case "update":
runUpdate(os.Args[2:]) runUpdate(os.Args[2:])
return return
@@ -183,8 +186,14 @@ func main() {
asyncQueueSize = 1000 asyncQueueSize = 1000
} }
tenantWorker := index.NewTenantWorker(idxMgr, asyncQueueSize, logger) tenantWorker := index.NewTenantWorker(idxMgr, asyncQueueSize, logger)
tenantWorker.Start() // PROJ-58: in batch mode the continuous index worker is not started; the
defer tenantWorker.Stop() // `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 // 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 // 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", logger.Info("ocr worker: pause window configured",
"from_hour", cfg.OCR.PausedHours[0], "to_hour", cfg.OCR.PausedHours[1]) "from_hour", cfg.OCR.PausedHours[0], "to_hour", cfg.OCR.PausedHours[1])
} }
ocrWorker.Start(context.Background()) // PROJ-58: in batch mode the continuous OCR worker is not started; the
defer ocrWorker.Stop() // `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() { if !ocr.IsAvailable() {
ts := ocr.CheckTools() ts := ocr.CheckTools()
logger.Warn("ocr tools not fully available — install tesseract-ocr + poppler-utils for full OCR support", 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. // pending mails as currently fit, so nothing is dropped.
// The worker updates ocr_status to done/failed/skipped, so subsequent // The worker updates ocr_status to done/failed/skipped, so subsequent
// queries only return genuinely outstanding jobs. // 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() ctx := context.Background()
queueCap := 1000 // matches ocr.Options.QueueSize above queueCap := 1000 // matches ocr.Options.QueueSize above
processed := 0 processed := 0
@@ -250,8 +267,9 @@ func main() {
processed += len(pending) processed += len(pending)
logger.Info("ocr boot-resume: enqueued batch", logger.Info("ocr boot-resume: enqueued batch",
"batch", len(pending), "total_so_far", processed) "batch", len(pending), "total_so_far", processed)
} }
}() }()
}
// User store // User store
users, err := userstore.New(cfg.Database.DSN()) users, err := userstore.New(cfg.Database.DSN())
@@ -385,7 +403,7 @@ func main() {
smtpDaemon.SetIndexCallback(func(raw []byte, id string) { smtpDaemon.SetIndexCallback(func(raw []byte, id string) {
// Look up the tenant_id for this email from DB metadata. // Look up the tenant_id for this email from DB metadata.
tenantID, _ := mailStore.GetTenantForMail(context.Background(), id) 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 // Wire tenant routing into SMTP daemon
if cfg.SMTP.TenantRouting == "domain" { if cfg.SMTP.TenantRouting == "domain" {
@@ -435,7 +453,8 @@ func main() {
imapImp := imapstore.NewImporter(imapSt, mailStore, idxMgr, logger) imapImp := imapstore.NewImporter(imapSt, mailStore, idxMgr, logger)
// PROJ-44: trigger OCR for IMAP-imported mails — without this every // PROJ-44: trigger OCR for IMAP-imported mails — without this every
// IMAP delivery would remain in ocr_status='pending' forever. // 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) { imapImp.SetOCRSubmit(func(mailID string, tenantID *int64) {
ocrWorker.Submit(mailID, tenantID) ocrWorker.Submit(mailID, tenantID)
}) })
@@ -456,15 +475,26 @@ func main() {
defer pop3St.Close() defer pop3St.Close()
pop3Imp := pop3store.NewImporter(pop3St, mailStore, idxMgr, logger) pop3Imp := pop3store.NewImporter(pop3St, mailStore, idxMgr, logger)
// PROJ-44: same OCR hook as the IMAP importer above. // 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) { pop3Imp.SetOCRSubmit(func(mailID string, tenantID *int64) {
ocrWorker.Submit(mailID, tenantID) ocrWorker.Submit(mailID, tenantID)
}) })
} }
srv.SetPop3(pop3St, pop3Imp) srv.SetPop3(pop3St, pop3Imp)
// Backfill in background: migrate existing files into DB metadata + re-index // Backfill in background: migrate existing files into DB metadata + re-index.
go runBackfill(context.Background(), mailStore, idx, tenantWorker, logger, ocrWorker) // 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 // Background integrity verification — runs every 5 minutes
go runIntegrityCheck(context.Background(), mailStore, logger) 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. // tenantID may be nil for global context.
// If ocrWorker is non-nil and the mail has attachments, an OCR job is also // If ocrWorker is non-nil and the mail has attachments, an OCR job is also
// queued (non-blocking). // 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) pm, err := mailparser.Parse(raw)
if err != nil { if err != nil {
logger.Warn("index: parse failed, skipping indexing", "id", id, "err", err) 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, 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 // Mark as indexed in DB
if err := store.SetIndexedAt(context.Background(), id); err != nil { if err := store.SetIndexedAt(context.Background(), id); err != nil {
logger.Warn("index: set indexed_at failed", "id", id, "err", err) logger.Warn("index: set indexed_at failed", "id", id, "err", err)
}
} }
// PROJ-35: hand off to OCR worker for asynchronous attachment processing. // 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) ocrWorker.Submit(id, tenantID)
} }
} }
@@ -612,7 +652,9 @@ func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, w
if !alreadyIndexed { if !alreadyIndexed {
needIndex++ needIndex++
tenantID, _ := store.GetTenantForMail(ctx, id) 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 { if count%100 == 0 {
+14
View File
@@ -30,6 +30,12 @@ index:
backend: xapian backend: xapian
batch_size: 100 batch_size: 100
async_queue_size: 1000 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: api:
bind: "0.0.0.0:8080" bind: "0.0.0.0:8080"
@@ -61,6 +67,14 @@ imap_server:
# Ohne Sektion / ohne paused_hours: altes Verhalten (immer aktiv). # Ohne Sektion / ohne paused_hours: altes Verhalten (immer aktiv).
# ocr: # ocr:
# paused_hours: [8, 18] # OCR pausiert während der Geschäftszeiten # 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). # PROJ-56: IMAP-Sync-Jitter (optional).
# jitter_seconds verteilt den tatsächlichen Sync-Start jedes Accounts # 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. // Wrap-around windows are supported, e.g. [22, 6] = paused 22:0006:00.
// nil / unset = never pause (legacy behaviour: process immediately). // nil / unset = never pause (legacy behaviour: process immediately).
PausedHours *[2]int `yaml:"paused_hours,omitempty"` 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). // IMAPSchedulerConfig holds settings for the automatic IMAP sync scheduler (PROJ-56).
@@ -157,6 +164,13 @@ type IndexConfig struct {
BatchSize int `yaml:"batch_size"` BatchSize int `yaml:"batch_size"`
AsyncQueueSize int `yaml:"async_queue_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") 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 // 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. # ("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 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) ──────────────────── # ── Weitere Jobs (geplant, noch nicht implementiert) ────────────────────
# 30 2 * * * archivmail /opt/archivmail/archivmail reindex # nächtlicher Voll-Reindex # 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-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-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-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 --> <!-- 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 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 ──────────────────────────────────────────────────────────────
// Backfill walks the store directory, parses each email, inserts missing DB // Backfill walks the store directory, parses each email, inserts missing DB