Files
archivmail/cmd/archivmail/cmd_import.go
T
sysops 479c27e5a8 feat(PROJ-21): Phase 2+3+5+8 Multi-Tenancy + PROJ-2 EML/MBOX Upload
Phase 2a: userstore domain_admin/superadmin Rollen, User.TenantID,
          ListByTenant, UpsertLDAPUser mit tenantID
Phase 2b: storage.Save() mit tenantID *int64, email_refs Tabelle,
          GetTenantForMail, GetAllIDsByTenant, StatsByTenant
Phase 2c: JWT-Claims tenant_id/tenant_slug, Session.TenantID,
          Login Domain-Erkennung via E-Mail-Domain
Phase 3:  tenantMiddleware, Handler-Filterung (Users, Mail, Stats)
Phase 5:  SMTP Domain-Routing via DomainToTenantFunc Callback,
          config smtp.tenant_routing + default_tenant_id
Phase 8:  archivmail migrate-tenants Subkommando
PROJ-2:   Upload-Seite /admin/upload mit DropZone + Progress-Polling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 21:03:40 +01:00

306 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/archivmail/config"
"github.com/archivmail/internal/index"
"github.com/archivmail/internal/storage"
"github.com/archivmail/pkg/mailparser"
)
const version = "1.0.0"
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")
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(),
}
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 = "xapian"
}
idx, err := index.New(cfg.Index.Path, batchSize, backend)
if err != nil {
fmt.Fprintf(os.Stderr, "error: index init: %v\n", err)
os.Exit(1)
}
defer idx.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))
}
}
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, idx, msg, *dryRun)
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".
func importMessage(mailStore *storage.Store, idx index.Indexer, raw []byte, dryRun bool) 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, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: save failed: %v\n", err)
return "error"
}
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, " "),
Subject: pm.Subject,
Body: pm.TextBody + " " + pm.HTMLBody,
AttachNames: strings.Join(attachNames, " "),
HasAttachment: len(pm.Attachments) > 0,
Date: pm.Date,
Size: int64(len(raw)),
}
if err := idx.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)
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)
--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
`, version)
}