package backend import ( "archive/zip" "bytes" "crypto/sha256" "encoding/hex" "fmt" "html" "io" "net/http" "net/mail" "os" "path/filepath" "strings" "time" ) type ArchiveStats struct { Name string Files int Messages int Duplicates int Bytes int64 } type ArchiveDedupResult struct { Removed int Files int } func importExportHandler(w http.ResponseWriter, r *http.Request) { if !requireManager(w, r) { return } archives, err := ListArchiveMailboxes() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } stats := archiveStatsMap(archives) renderImportExportPage(w, r, archives, stats, r.URL.Query().Get("msg"), r.URL.Query().Get("err")) } func archiveUploadHandler(w http.ResponseWriter, r *http.Request) { if !requireManager(w, r) { return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } archive, err := safeArchiveName(r.FormValue("archive_name")) if err != nil { redirectImportExport(w, r, err.Error()) return } if err := SaveArchiveMailbox(archive); err != nil { redirectImportExport(w, r, err.Error()) return } dir, err := archiveDir(archive) if err != nil { redirectImportExport(w, r, err.Error()) return } if err := os.MkdirAll(dir, 0o700); err != nil { redirectImportExport(w, r, err.Error()) return } if err := r.ParseMultipartForm(512 << 20); err != nil { redirectImportExport(w, r, err.Error()) return } file, header, err := r.FormFile("mailfile") if err != nil { redirectImportExport(w, r, "Keine Import-Datei gewaehlt.") return } defer file.Close() name, err := safeArchiveFileName(header.Filename) if err != nil { redirectImportExport(w, r, err.Error()) return } dst, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { redirectImportExport(w, r, err.Error()) return } defer dst.Close() if _, err := io.Copy(dst, file); err != nil { redirectImportExport(w, r, err.Error()) return } redirectImportExport(w, r, "Import gespeichert.") } func archiveDownloadHandler(w http.ResponseWriter, r *http.Request) { if !requireManager(w, r) { return } archive, err := safeArchiveName(r.URL.Query().Get("archive_name")) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } dir, err := archiveDir(archive) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if _, err := os.Stat(dir); err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, archive)) zw := zip.NewWriter(w) defer zw.Close() _ = filepath.WalkDir(dir, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil || entry.IsDir() { return walkErr } rel, err := filepath.Rel(dir, path) if err != nil { return err } rel = filepath.ToSlash(rel) info, err := entry.Info() if err != nil { return err } header, err := zip.FileInfoHeader(info) if err != nil { return err } header.Name = rel header.Method = zip.Deflate dst, err := zw.CreateHeader(header) if err != nil { return err } src, err := os.Open(path) if err != nil { return err } defer src.Close() _, err = io.Copy(dst, src) return err }) } func archiveDedupHandler(w http.ResponseWriter, r *http.Request) { if !requireManager(w, r) { return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } archive, err := safeArchiveName(r.FormValue("archive_name")) if err != nil { redirectArchives(w, r, err.Error()) return } result, err := DeduplicateArchive(archive) if err != nil { redirectArchives(w, r, err.Error()) return } redirectArchives(w, r, fmt.Sprintf("Dedup abgeschlossen: %d Dubletten aus %d mbox-Dateien entfernt.", result.Removed, result.Files)) } func renderImportExportPage(w http.ResponseWriter, r *http.Request, archives []string, stats map[string]ArchiveStats, msg, errMsg string) { user := CurrentUser(r) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, ` Import/Export - Mail-Graveyard
%s
%s
`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""), renderRibbonActions(false, user), renderBackstage("import-export")) if msg != "" { fmt.Fprintf(w, `
%s
`, html.EscapeString(msg)) } if errMsg != "" { fmt.Fprintf(w, `
%s
`, html.EscapeString(errMsg)) } fmt.Fprint(w, `
Export / Speicher
`) renderArchiveStatsTable(w, archives, stats) fmt.Fprint(w, `
Import
`) renderArchiveUploadForm(w, archives) fmt.Fprint(w, `
`) } func renderArchiveStatsTable(w http.ResponseWriter, archives []string, stats map[string]ArchiveStats) { if len(archives) == 0 { fmt.Fprint(w, `
Noch keine Archiv-Mailbox angelegt.
`) return } fmt.Fprint(w, ``) for _, archive := range archives { stat := stats[archive] fmt.Fprintf(w, ``, html.EscapeString(archive), html.EscapeString(formatBytes(stat.Bytes)), stat.Messages, stat.Duplicates, urlQuery(archive)) } fmt.Fprint(w, `
Archiv-MailboxSpeicherMailsDubletten
%s%s%d%dDownload
`) } func renderArchiveUploadForm(w http.ResponseWriter, archives []string) { fmt.Fprint(w, `
Import/Export

mbox-Dateien werden direkt als Ordnerinhalt importiert und sind im Viewer nutzbar.

PST-Dateien werden als Archivdatei abgelegt; eine PST-zu-mbox-Konvertierung braucht spaeter einen eigenen Parser.

Download exportiert die komplette Archiv-Mailbox als ZIP.

`) } func archiveUploadSelect(archives []string) string { if len(archives) == 0 { return `` } var b strings.Builder b.WriteString(``) return b.String() } func archiveStatsMap(archives []string) map[string]ArchiveStats { out := map[string]ArchiveStats{} for _, archive := range archives { stat, err := ArchiveStatsFor(archive) if err == nil { out[archive] = stat } else { out[archive] = ArchiveStats{Name: archive} } } return out } func ArchiveStatsFor(name string) (ArchiveStats, error) { dir, err := archiveDir(name) if err != nil { return ArchiveStats{}, err } stat := ArchiveStats{Name: name} seen := map[string]int{} if err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if entry.IsDir() { return nil } info, err := entry.Info() if err != nil { return err } stat.Files++ stat.Bytes += info.Size() if strings.EqualFold(filepath.Ext(path), ".mbox") { msgs, err := readMboxMessages(path) if err != nil { return nil } stat.Messages += len(msgs) for _, msg := range msgs { key := messageDedupKey(msg) seen[key]++ if seen[key] > 1 { stat.Duplicates++ } } } return nil }); err != nil && !os.IsNotExist(err) { return stat, err } return stat, nil } func DeduplicateArchive(name string) (ArchiveDedupResult, error) { dir, err := archiveDir(name) if err != nil { return ArchiveDedupResult{}, err } seen := map[string]bool{} var result ArchiveDedupResult var mboxes []string if err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if !entry.IsDir() && strings.EqualFold(filepath.Ext(path), ".mbox") { mboxes = append(mboxes, path) } return nil }); err != nil { return result, err } sortStrings(mboxes) for _, path := range mboxes { msgs, err := readMboxMessages(path) if err != nil { return result, err } kept := make([][]byte, 0, len(msgs)) for _, msg := range msgs { key := messageDedupKey(msg) if seen[key] { result.Removed++ continue } seen[key] = true kept = append(kept, msg) } if len(kept) != len(msgs) { if err := writeMboxMessages(path, kept); err != nil { return result, err } } result.Files++ } return result, nil } func messageDedupKey(raw []byte) string { msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err == nil { id := strings.ToLower(strings.TrimSpace(msg.Header.Get("Message-ID"))) if id != "" { return "id:" + id } } sum := sha256.Sum256(bytes.TrimSpace(raw)) return "sha256:" + hex.EncodeToString(sum[:]) } func writeMboxMessages(path string, messages [][]byte) error { tmp := path + ".tmp" f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return err } for _, raw := range messages { if _, err := fmt.Fprintf(f, "From MAILER-DAEMON %s\r\n", time.Now().Format("Mon Jan _2 15:04:05 2006")); err != nil { _ = f.Close() return err } body := bytes.ReplaceAll(raw, []byte("\r\nFrom "), []byte("\r\n>From ")) body = bytes.ReplaceAll(body, []byte("\nFrom "), []byte("\n>From ")) if _, err := f.Write(body); err != nil { _ = f.Close() return err } if !bytes.HasSuffix(body, []byte("\n")) { if _, err := f.WriteString("\r\n"); err != nil { _ = f.Close() return err } } if _, err := f.WriteString("\r\n"); err != nil { _ = f.Close() return err } } if err := f.Close(); err != nil { return err } return os.Rename(tmp, path) } func archiveDir(name string) (string, error) { name, err := safeArchiveName(name) if err != nil { return "", err } root, err := filepath.Abs(Cfg.MboxRoot) if err != nil { return "", err } path, err := filepath.Abs(filepath.Join(root, name)) if err != nil { return "", err } if path != root && !strings.HasPrefix(path, root+string(os.PathSeparator)) { return "", fmt.Errorf("ungueltiger Archiv-Pfad") } return path, nil } func safeArchiveFileName(value string) (string, error) { name := filepath.Base(strings.TrimSpace(value)) if name == "" || name == "." || name == ".." { return "", fmt.Errorf("Dateiname fehlt.") } ext := strings.ToLower(filepath.Ext(name)) if ext != ".mbox" && ext != ".pst" { return "", fmt.Errorf("Nur mbox- und PST-Dateien koennen importiert werden.") } clean := strings.NewReplacer("\\", "_", "/", "_", ":", "_").Replace(name) if strings.TrimSuffix(clean, ext) == "" { clean = "import" + ext } return clean, nil } func formatBytes(n int64) string { const unit = 1024 if n < unit { return fmt.Sprintf("%d B", n) } div, exp := int64(unit), 0 for n >= div*unit && exp < 4 { div *= unit exp++ } return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) } func redirectImportExport(w http.ResponseWriter, r *http.Request, msg string) { key := "msg" if strings.Contains(strings.ToLower(msg), "fehl") || strings.Contains(strings.ToLower(msg), "ungueltig") || strings.Contains(strings.ToLower(msg), "error") { key = "err" } http.Redirect(w, r, "/import-export?"+key+"="+urlQuery(msg), http.StatusSeeOther) }