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
|
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
|
||||||
|
|||||||
@@ -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":
|
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ type OCRConfig struct {
|
|||||||
// Wrap-around windows are supported, e.g. [22, 6] = paused 22:00–06:00.
|
// Wrap-around windows are supported, e.g. [22, 6] = paused 22:00–06: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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user