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:
co-authored by
Claude Sonnet 4.6
parent
76655f78a2
commit
fae274f930
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user