package backend
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"html"
"io"
"net/http"
"net/mail"
"os"
"path/filepath"
"strings"
"time"
"github.com/klauspost/compress/zstd"
)
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
if strings.HasSuffix(strings.ToLower(rel), ".mbox.zst") {
header.Name = strings.TrimSuffix(rel, ".zst")
}
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()
if strings.HasSuffix(strings.ToLower(path), ".mbox.zst") {
err = exportPlainMbox(src, dst)
} else {
_, 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
`, 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, `
`)
}
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, `| Archiv-Mailbox | Speicher | Mails | Dubletten | |
`)
for _, archive := range archives {
stat := stats[archive]
fmt.Fprintf(w, `| %s | %s | %d | %d | Download |
`,
html.EscapeString(archive), html.EscapeString(formatBytes(stat.Bytes)), stat.Messages, stat.Duplicates, urlQuery(archive))
}
fmt.Fprint(w, `
`)
}
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 isMboxArchivePath(path) {
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.")
}
lower := strings.ToLower(name)
ext := strings.ToLower(filepath.Ext(name))
if ext != ".mbox" && ext != ".pst" && !strings.HasSuffix(lower, ".mbox.zst") {
return "", fmt.Errorf("Nur mbox-, mbox.zst- und PST-Dateien koennen importiert werden.")
}
clean := strings.NewReplacer("\\", "_", "/", "_", ":", "_").Replace(name)
if strings.HasSuffix(strings.ToLower(clean), ".mbox.zst") {
if strings.TrimSuffix(strings.TrimSuffix(clean, ".zst"), ".mbox") == "" {
clean = "import.mbox.zst"
}
} else if strings.TrimSuffix(clean, ext) == "" {
clean = "import" + ext
}
return clean, nil
}
func isMboxArchivePath(path string) bool {
lower := strings.ToLower(path)
return strings.HasSuffix(lower, ".mbox") || strings.HasSuffix(lower, ".mbox.zst")
}
func exportPlainMbox(src io.Reader, dst io.Writer) error {
dec, err := zstd.NewReader(src)
if err != nil {
return err
}
defer dec.Close()
_, err = io.Copy(dst, dec)
return err
}
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)
}