package main import ( "context" "encoding/json" "flag" "fmt" "os" "path/filepath" "strings" "time" "archivmail/config" "archivmail/internal/index" "archivmail/internal/storage" "archivmail/pkg/mailparser" ) type importResult struct { Status string `json:"status"` Imported int `json:"imported"` Skipped int `json:"skipped"` Errors int `json:"errors"` DurationSec float64 `json:"duration_sec"` } func runImport(args []string) { fs := flag.NewFlagSet("import", flag.ExitOnError) configPath := fs.String("config", "/etc/archivmail/config.yml", "path to config file") file := fs.String("file", "", "single EML or MBOX file to import") dir := fs.String("dir", "", "directory to import EML/MBOX files from") recursive := fs.Bool("recursive", false, "recurse into subdirectories (with --dir)") dryRun := fs.Bool("dry-run", false, "simulate import without saving") jsonOut := fs.Bool("json", false, "machine-readable JSON output") tenantFlag := fs.Int64("tenant", 0, "tenant ID to assign imported mails to (0 = none/global)") fs.Usage = func() { fmt.Fprintln(os.Stderr, "Usage: archivmail import [flags]") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Flags:") fs.PrintDefaults() } fs.Parse(args) if *file == "" && *dir == "" { fmt.Fprintln(os.Stderr, "error: --file or --dir required") fs.Usage() os.Exit(1) } start := time.Now() cfg, err := config.Load(*configPath) if err != nil { fmt.Fprintf(os.Stderr, "error: load config: %v\n", 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 { fmt.Fprintf(os.Stderr, "error: storage init: %v\n", err) os.Exit(1) } defer mailStore.Close() batchSize := cfg.Index.BatchSize if batchSize <= 0 { batchSize = 100 } backend := cfg.Index.Backend if backend == "" { backend = "manticore" } var idxMgr index.TenantIndexer if backend == "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 { fmt.Fprintf(os.Stderr, "error: manticore init: %v\n", err) os.Exit(1) } idxMgr = m } else { m, err := index.NewTenantIndexManager(cfg.Index.Path, batchSize, backend) if err != nil { fmt.Fprintf(os.Stderr, "error: index init: %v\n", err) os.Exit(1) } idxMgr = m } defer idxMgr.Close() // Collect files to process type fileEntry struct { path string isMbox bool } var files []fileEntry if *file != "" { isMbox := strings.HasSuffix(strings.ToLower(*file), ".mbox") files = append(files, fileEntry{*file, isMbox}) } if *dir != "" { info, err := os.Stat(*dir) if err != nil { fmt.Fprintf(os.Stderr, "error: cannot access dir %s: %v\n", *dir, err) os.Exit(1) } if !info.IsDir() { fmt.Fprintf(os.Stderr, "error: %s is not a directory\n", *dir) os.Exit(1) } walkFn := func(path string, d os.DirEntry, werr error) error { if werr != nil { return werr } if d.IsDir() { if !*recursive && path != *dir { return filepath.SkipDir } return nil } lower := strings.ToLower(d.Name()) if strings.HasSuffix(lower, ".eml") { files = append(files, fileEntry{path, false}) } else if strings.HasSuffix(lower, ".mbox") { files = append(files, fileEntry{path, true}) } return nil } filepath.WalkDir(*dir, walkFn) } if len(files) == 0 { if !*jsonOut { fmt.Println("No EML or MBOX files found.") } else { printImportJSON(importResult{Status: "done"}, start) } os.Exit(0) } if !*jsonOut { if *dryRun { fmt.Printf("Dry run – scanning %d file(s)...\n", len(files)) } else { fmt.Printf("Found %d file(s) to process...\n", len(files)) } } var tenantID *int64 if *tenantFlag > 0 { tid := *tenantFlag tenantID = &tid } imported := 0 skipped := 0 errors := 0 total := 0 for _, fe := range files { raw, err := os.ReadFile(fe.path) if err != nil { fmt.Fprintf(os.Stderr, "error: read %s: %v\n", fe.path, err) errors++ continue } var messages [][]byte if fe.isMbox { messages = mailparser.SplitMbox(raw) if len(messages) == 0 { continue } } else { messages = [][]byte{raw} } for _, msg := range messages { total++ result := importMessage(mailStore, idxMgr, msg, *dryRun, tenantID) switch result { case "imported": imported++ case "skipped": skipped++ case "error": errors++ } if !*jsonOut && total%100 == 0 { fmt.Printf("Progress: %d processed (imported: %d, skipped: %d, errors: %d)\n", total, imported, skipped, errors) } } } if *jsonOut { printImportJSON(importResult{ Status: "done", Imported: imported, Skipped: skipped, Errors: errors, DurationSec: time.Since(start).Seconds(), }, start) } else { fmt.Printf("\nFertig:\n") fmt.Printf(" Importiert: %d\n", imported) fmt.Printf(" Übersprungen: %d (Duplikate)\n", skipped) fmt.Printf(" Fehler: %d\n", errors) if *dryRun { fmt.Println("\n[dry-run] Keine Daten wurden gespeichert.") } } if errors > 0 { os.Exit(1) } } // importMessage stores and indexes a single raw message. Returns "imported", "skipped", or "error". // tenantID, when non-nil, assigns the imported mail to that tenant (both in // storage and in the tenant-specific search index) instead of the // global/tenant-less context. func importMessage(mailStore *storage.Store, idxMgr index.TenantIndexer, raw []byte, dryRun bool, tenantID *int64) string { pm, err := mailparser.Parse(raw) if err != nil { fmt.Fprintf(os.Stderr, "warning: parse failed: %v\n", err) return "error" } if dryRun { return "imported" } id, err := mailStore.Save(context.Background(), raw, pm.Date, tenantID) if err != nil { fmt.Fprintf(os.Stderr, "warning: save failed: %v\n", err) return "error" } // PROJ-52: mark bulk-imported mails as source 'import' for reconciliation. if err := mailStore.TagSource(context.Background(), id, "import", nil); err != nil { fmt.Fprintf(os.Stderr, "warning: tag source failed: %v\n", err) } var attachNames []string for _, a := range pm.Attachments { attachNames = append(attachNames, a.Filename) } doc := index.MailDocument{ ID: id, From: pm.From, To: strings.Join(pm.To, " "), CC: strings.Join(pm.CC, " "), Subject: pm.Subject, Body: pm.TextBody + " " + pm.HTMLBody, AttachNames: strings.Join(attachNames, " "), HasAttachment: len(pm.Attachments) > 0, Date: pm.Date, Size: int64(len(raw)), TenantID: tenantID, } if err := idxMgr.ForTenant(tenantID).IndexSync(doc); err != nil { fmt.Fprintf(os.Stderr, "warning: index failed for %s: %v\n", id, err) return "error" } return "imported" } func printImportJSON(r importResult, start time.Time) { r.DurationSec = time.Since(start).Seconds() enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") enc.Encode(r) } func printHelp() { fmt.Printf(`archivmail %s – Mail-Archiv-Daemon und CLI Commands: serve Daemon starten (Standard wenn kein Befehl angegeben) import E-Mails importieren (EML, MBOX, Verzeichnis) import-piler Aus mailpiler migrieren (pilerexport oder direkte Store-Methode) export E-Mails exportieren (EML, MBOX) reindex Index neu aufbauen (alle oder pro Mandant) purge Mails mit abgelaufener Aufbewahrungsfrist löschen (cron-fähig) 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 help Diese Hilfe anzeigen archivmail import [flags] --config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml) --file Einzelne EML- oder MBOX-Datei --dir Verzeichnis mit EML/MBOX-Dateien --recursive Unterverzeichnisse einschließen (mit --dir) --tenant Mandanten-ID für importierte Mails (0 = keine/global) --dry-run Simulation ohne Speichern --json Maschinenlesbare JSON-Ausgabe archivmail import-piler [flags] --config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml) --method auto | pilerexport | direct (Standard: auto) --pilerexport Pfad zum pilerexport Binary (auto-erkennung) --export-dir Ausgabeverzeichnis für pilerexport (temp wenn leer) --store-dir mailpiler Store-Verzeichnis (Standard: /var/piler/store) --key-file mailpiler AES-Schlüsseldatei (Standard: /var/piler/store/piler.key) --date-from Export ab Datum YYYY-MM-DD (pilerexport-Methode) --date-to Export bis Datum YYYY-MM-DD (pilerexport-Methode) --dry-run Simulation ohne Speichern --json Maschinenlesbare JSON-Ausgabe archivmail export [flags] --config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml) --out Zielverzeichnis oder Zieldatei (Pflicht) --format eml (Standard) oder mbox --from Filter nach Absender --to Filter nach Empfänger --date-from Filter ab Datum (ISO 8601: 2024-01-01) --date-to Filter bis Datum (ISO 8601: 2024-12-31) --query Volltext-Suche --force Vorhandene Dateien überschreiben --json Maschinenlesbare JSON-Ausgabe archivmail reindex [flags] --config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml) --tenant Mandanten-ID für partiellen Reindex (0 = alle) archivmail recompress [flags] --config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml) --dry-run Simulation: zeigt wie viel gespart würde, ohne Dateien zu ändern archivmail rethread [flags] --config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml) --dry-run Simulation: zeigt wie viele Mails gethreaded würden, ohne DB zu ändern archivmail status [flags] --config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml) --json Maschinenlesbare JSON-Ausgabe `, AppVersion) }