Add archive import export and dedup tools

This commit is contained in:
DonVoo 2026-07-13 02:39:09 +02:00
parent 8d2838aea7
commit d5576ddbde
4 changed files with 528 additions and 36 deletions

View file

@ -32,6 +32,10 @@ func RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/archives", archivesHandler) mux.HandleFunc("/archives", archivesHandler)
mux.HandleFunc("/archives/create", archiveCreateHandler) mux.HandleFunc("/archives/create", archiveCreateHandler)
mux.HandleFunc("/archives/delete", archiveDeleteHandler) mux.HandleFunc("/archives/delete", archiveDeleteHandler)
mux.HandleFunc("/archives/dedup", archiveDedupHandler)
mux.HandleFunc("/import-export", importExportHandler)
mux.HandleFunc("/import-export/upload", archiveUploadHandler)
mux.HandleFunc("/import-export/download", archiveDownloadHandler)
mux.HandleFunc("/transfer", transferHandler) mux.HandleFunc("/transfer", transferHandler)
mux.HandleFunc("/transfer/", transferHandler) mux.HandleFunc("/transfer/", transferHandler)
mux.HandleFunc("/boxes/source", emailBoxHandler) mux.HandleFunc("/boxes/source", emailBoxHandler)
@ -59,8 +63,8 @@ func renderStartPage(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8"> fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mail-Graveyard</title> <title>Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-21"> <link rel="stylesheet" href="/static/style.css?v=20260712-22">
<script src="/static/app.js?v=20260712-21" defer></script></head> <script src="/static/app.js?v=20260712-22" defer></script></head>
<body class="ol2013"> <body class="ol2013">
<header class="ribbon"> <header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button> <button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -73,7 +77,7 @@ func renderStartPage(w http.ResponseWriter, r *http.Request) {
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue"> <aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item active" href="/">Start</a> <a class="backstage-item active" href="/">Start</a>
<a class="backstage-item" href="/view">Archiv-Boxen</a> <a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a> <a class="backstage-item" href="/import-export">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a> <a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a> <a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a> <a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
@ -107,9 +111,9 @@ func renderShell(w http.ResponseWriter, r *http.Request, active, readingPane str
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8"> fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mail-Graveyard</title> <title>Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-21"> <link rel="stylesheet" href="/static/style.css?v=20260712-22">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-21" defer></script></head> <script src="/static/app.js?v=20260712-22" defer></script></head>
<body class="ol2013"> <body class="ol2013">
<header class="ribbon"> <header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button> <button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -121,7 +125,7 @@ func renderShell(w http.ResponseWriter, r *http.Request, active, readingPane str
</header> </header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue"> <aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item active" href="/view">Archiv-Boxen</a> <a class="backstage-item active" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a> <a class="backstage-item" href="/import-export">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a> <a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a> <a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a> <a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
@ -263,7 +267,7 @@ func archivesHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
renderArchivesPage(w, r, archives, accounts, r.URL.Query().Get("msg"), r.URL.Query().Get("err")) renderArchivesPage(w, r, archives, accounts, archiveStatsMap(archives), r.URL.Query().Get("msg"), r.URL.Query().Get("err"))
} }
func transferHandler(w http.ResponseWriter, r *http.Request) { func transferHandler(w http.ResponseWriter, r *http.Request) {
@ -578,9 +582,9 @@ func renderAccountsPage(w http.ResponseWriter, r *http.Request, accounts []Accou
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8"> fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Konten-Verwaltung - Mail-Graveyard</title> <title>Konten-Verwaltung - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-21"> <link rel="stylesheet" href="/static/style.css?v=20260712-22">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-21" defer></script></head> <script src="/static/app.js?v=20260712-22" defer></script></head>
<body class="ol2013"> <body class="ol2013">
<header class="ribbon"> <header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button> <button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -592,7 +596,7 @@ func renderAccountsPage(w http.ResponseWriter, r *http.Request, accounts []Accou
</header> </header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue"> <aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a> <a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a> <a class="backstage-item" href="/import-export">Import/Export</a>
<a class="backstage-item active" href="/accounts">Konten-Verwaltung</a> <a class="backstage-item active" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a> <a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a> <a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
@ -617,15 +621,15 @@ func renderAccountsPage(w http.ResponseWriter, r *http.Request, accounts []Accou
fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`) fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`)
} }
func renderArchivesPage(w http.ResponseWriter, r *http.Request, archives []string, accounts []Account, msg, errMsg string) { func renderArchivesPage(w http.ResponseWriter, r *http.Request, archives []string, accounts []Account, stats map[string]ArchiveStats, msg, errMsg string) {
user := CurrentUser(r) user := CurrentUser(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8"> fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Archiv-mbox Verwaltung - Mail-Graveyard</title> <title>Archiv-mbox Verwaltung - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-21"> <link rel="stylesheet" href="/static/style.css?v=20260712-22">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-21" defer></script></head> <script src="/static/app.js?v=20260712-22" defer></script></head>
<body class="ol2013"> <body class="ol2013">
<header class="ribbon"> <header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button> <button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -637,7 +641,7 @@ func renderArchivesPage(w http.ResponseWriter, r *http.Request, archives []strin
</header> </header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue"> <aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a> <a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a> <a class="backstage-item" href="/import-export">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a> <a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item active" href="/archives">Archiv-mbox Verwaltung</a> <a class="backstage-item active" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a> <a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
@ -656,7 +660,7 @@ func renderArchivesPage(w http.ResponseWriter, r *http.Request, archives []strin
fmt.Fprintf(w, `<div class="notice bad">%s</div>`, html.EscapeString(errMsg)) fmt.Fprintf(w, `<div class="notice bad">%s</div>`, html.EscapeString(errMsg))
} }
fmt.Fprint(w, `<section class="archive-page-layout"><div class="archive-list-panel"><div class="pane-head">Archiv-mboxen</div>`) fmt.Fprint(w, `<section class="archive-page-layout"><div class="archive-list-panel"><div class="pane-head">Archiv-mboxen</div>`)
renderArchiveList(w, archives, accounts) renderArchiveList(w, archives, accounts, stats)
fmt.Fprint(w, `</div><div class="archive-form-panel"><div class="pane-head">Archiv-mbox anlegen</div>`) fmt.Fprint(w, `</div><div class="archive-form-panel"><div class="pane-head">Archiv-mbox anlegen</div>`)
renderArchiveForm(w) renderArchiveForm(w)
fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`) fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span id="mig-status" hx-get="/migrate/status" hx-trigger="load,every 3s" hx-swap="innerHTML">bereit</span></footer></body></html>`)
@ -670,9 +674,9 @@ func renderUsersPage(w http.ResponseWriter, r *http.Request, users []AppUser, ed
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8"> fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Benutzerverwaltung - Mail-Graveyard</title> <title>Benutzerverwaltung - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-21"> <link rel="stylesheet" href="/static/style.css?v=20260712-22">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-21" defer></script></head> <script src="/static/app.js?v=20260712-22" defer></script></head>
<body class="ol2013"> <body class="ol2013">
<header class="ribbon"> <header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button> <button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -684,7 +688,7 @@ func renderUsersPage(w http.ResponseWriter, r *http.Request, users []AppUser, ed
</header> </header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue"> <aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a> <a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a> <a class="backstage-item" href="/import-export">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a> <a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a> <a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a> <a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
@ -740,9 +744,9 @@ func renderTransferPage(w http.ResponseWriter, r *http.Request, mode transferMod
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8"> fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s - Mail-Graveyard</title> <title>%s - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-21"> <link rel="stylesheet" href="/static/style.css?v=20260712-22">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-21" defer></script></head> <script src="/static/app.js?v=20260712-22" defer></script></head>
<body class="ol2013"> <body class="ol2013">
<header class="ribbon"> <header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button> <button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -754,7 +758,7 @@ func renderTransferPage(w http.ResponseWriter, r *http.Request, mode transferMod
</header> </header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue"> <aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a> <a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a> <a class="backstage-item" href="/import-export">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a> <a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a> <a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item active" href="/transfer/postfach-archiv">Mail-Transfer</a> <a class="backstage-item active" href="/transfer/postfach-archiv">Mail-Transfer</a>
@ -781,9 +785,9 @@ func renderSourceEmailBoxPage(w http.ResponseWriter, r *http.Request, accounts [
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8"> fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Quell-Postfach - Mail-Graveyard</title> <title>Quell-Postfach - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-21"> <link rel="stylesheet" href="/static/style.css?v=20260712-22">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-21" defer></script></head> <script src="/static/app.js?v=20260712-22" defer></script></head>
<body class="ol2013"> <body class="ol2013">
<header class="ribbon"> <header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button> <button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -795,7 +799,7 @@ func renderSourceEmailBoxPage(w http.ResponseWriter, r *http.Request, accounts [
</header> </header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue"> <aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a> <a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a> <a class="backstage-item" href="/import-export">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a> <a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a> <a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a> <a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
@ -823,9 +827,9 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, r *http.Request, accounts [
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8"> fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ziel-Postfach - Mail-Graveyard</title> <title>Ziel-Postfach - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-21"> <link rel="stylesheet" href="/static/style.css?v=20260712-22">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-21" defer></script></head> <script src="/static/app.js?v=20260712-22" defer></script></head>
<body class="ol2013"> <body class="ol2013">
<header class="ribbon"> <header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button> <button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
@ -837,7 +841,7 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, r *http.Request, accounts [
</header> </header>
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue"> <aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
<a class="backstage-item" href="/view">Archiv-Boxen</a> <a class="backstage-item" href="/view">Archiv-Boxen</a>
<a class="backstage-item" href="/view">Import/Export</a> <a class="backstage-item" href="/import-export">Import/Export</a>
<a class="backstage-item" href="/accounts">Konten-Verwaltung</a> <a class="backstage-item" href="/accounts">Konten-Verwaltung</a>
<a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a> <a class="backstage-item" href="/archives">Archiv-mbox Verwaltung</a>
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a> <a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
@ -1011,6 +1015,37 @@ func renderRibbonTransferMenu(active string) string {
return b.String() return b.String()
} }
func renderBackstage(active string) string {
items := []struct {
Key string
Href string
Label string
}{
{"home", "/", "Start"},
{"archive", "/view", "Archiv-Boxen"},
{"import-export", "/import-export", "Import/Export"},
{"accounts", "/accounts", "Konten-Verwaltung"},
{"archives", "/archives", "Archiv-mbox Verwaltung"},
{"transfer", "/transfer/postfach-archiv", "Mail-Transfer"},
{"users", "/users", "Benutzerverwaltung"},
{"logout", "/logout", "Logout"},
}
var b strings.Builder
b.WriteString(`<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">`)
for _, item := range items {
class := "backstage-item"
if item.Key == active {
class += " active"
}
fmt.Fprintf(&b, `<a class="%s" href="%s">%s</a>`, class, item.Href, html.EscapeString(item.Label))
if item.Key == "transfer" {
b.WriteString(transferMenuItems(""))
}
}
b.WriteString(`</aside><button class="backstage-shade" type="button" aria-label="Menue schliessen" data-backstage-close></button>`)
return b.String()
}
func renderTransferPicker(kind string, accounts []Account, archives []string) string { func renderTransferPicker(kind string, accounts []Account, archives []string) string {
var b strings.Builder var b strings.Builder
b.WriteString(`<div class="transfer-picker">`) b.WriteString(`<div class="transfer-picker">`)
@ -1185,20 +1220,26 @@ func passwordHint(username string) string {
return "Bearbeiten: Neues Passwort eintragen oder leer lassen, um das bestehende Passwort zu behalten." return "Bearbeiten: Neues Passwort eintragen oder leer lassen, um das bestehende Passwort zu behalten."
} }
func renderArchiveList(w http.ResponseWriter, archives []string, accounts []Account) { func renderArchiveList(w http.ResponseWriter, archives []string, accounts []Account, stats map[string]ArchiveStats) {
if len(archives) == 0 { if len(archives) == 0 {
fmt.Fprint(w, `<div class="ef-empty">Noch keine Archiv-Mailbox angelegt.</div>`) fmt.Fprint(w, `<div class="ef-empty">Noch keine Archiv-Mailbox angelegt.</div>`)
return return
} }
fmt.Fprint(w, `<table class="archive-table"><thead><tr><th>Archiv-Mailbox</th><th>Zuordnung</th><th></th></tr></thead><tbody>`) fmt.Fprint(w, `<table class="archive-table"><thead><tr><th>Archiv-Mailbox</th><th>Zuordnung</th><th>Speicher</th><th>Mails</th><th>Dubletten</th><th></th></tr></thead><tbody>`)
for _, archive := range archives { for _, archive := range archives {
uses := archiveUses(archive, accounts) uses := archiveUses(archive, accounts)
action := `<span class="muted">belegt</span>` stat := stats[archive]
var actions strings.Builder
fmt.Fprintf(&actions, `<a class="btn compact secondary" href="/import-export/download?archive_name=%s">Download</a>`, urlQuery(archive))
if stat.Duplicates > 0 {
fmt.Fprintf(&actions, `<form method="post" action="/archives/dedup"><input type="hidden" name="archive_name" value="%s"><button class="btn compact secondary" type="submit">Dedup</button></form>`, html.EscapeString(archive))
}
if uses == "" { if uses == "" {
action = fmt.Sprintf(`<form method="post" action="/archives/delete"><input type="hidden" name="archive_name" value="%s"><button class="btn compact secondary danger" type="submit">Entfernen</button></form>`, html.EscapeString(archive)) fmt.Fprintf(&actions, `<form method="post" action="/archives/delete"><input type="hidden" name="archive_name" value="%s"><button class="btn compact secondary danger" type="submit">Entfernen</button></form>`, html.EscapeString(archive))
uses = "frei" uses = "frei"
} }
fmt.Fprintf(w, `<tr><td>%s</td><td>%s</td><td class="archive-action">%s</td></tr>`, html.EscapeString(archive), html.EscapeString(uses), action) fmt.Fprintf(w, `<tr><td>%s</td><td>%s</td><td>%s</td><td>%d</td><td>%d</td><td class="archive-action">%s</td></tr>`,
html.EscapeString(archive), html.EscapeString(uses), html.EscapeString(formatBytes(stat.Bytes)), stat.Messages, stat.Duplicates, actions.String())
} }
fmt.Fprint(w, `</tbody></table>`) fmt.Fprint(w, `</tbody></table>`)
} }

View file

@ -239,7 +239,7 @@ func renderLoginPage(w http.ResponseWriter, errMsg string) {
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8"> fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Login - Mail-Graveyard</title> <title>Login - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-21"></head> <link rel="stylesheet" href="/static/style.css?v=20260712-22"></head>
<body class="ol2013 login-body"> <body class="ol2013 login-body">
<main class="login-card"> <main class="login-card">
<div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div> <div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div>
@ -259,7 +259,7 @@ func renderForgotPasswordPage(w http.ResponseWriter, msg string) {
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8"> fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Passwort vergessen - Mail-Graveyard</title> <title>Passwort vergessen - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-21"></head> <link rel="stylesheet" href="/static/style.css?v=20260712-22"></head>
<body class="ol2013 login-body"> <body class="ol2013 login-body">
<main class="login-card"> <main class="login-card">
<div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div> <div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div>
@ -278,7 +278,7 @@ func renderResetPasswordPage(w http.ResponseWriter, token, errMsg string) {
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8"> fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Passwort setzen - Mail-Graveyard</title> <title>Passwort setzen - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-21"></head> <link rel="stylesheet" href="/static/style.css?v=20260712-22"></head>
<body class="ol2013 login-body"> <body class="ol2013 login-body">
<main class="login-card"> <main class="login-card">
<div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div> <div class="login-brand"><span class="mail-logo" aria-hidden="true"></span><strong>Mail-Graveyard</strong></div>

447
backend/12-archive-tools.go Normal file
View file

@ -0,0 +1,447 @@
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, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Import/Export - Mail-Graveyard</title>
<link rel="stylesheet" href="/static/style.css?v=20260712-22">
<script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260712-22" defer></script></head>
<body class="ol2013">
<header class="ribbon">
<button class="app-menu-button" type="button" aria-label="Mail-Graveyard-Menue" aria-expanded="false" data-backstage-toggle><span class="mail-logo" aria-hidden="true"></span></button>
<nav class="ribbon-tabs">%s%s</nav>
%s
</header>
%s
<main class="accounts-page archive-page">`, renderRibbonEmailBoxMenu(""), renderRibbonTransferMenu(""), renderRibbonActions(false, user), renderBackstage("import-export"))
if msg != "" {
fmt.Fprintf(w, `<div class="notice ok">%s</div>`, html.EscapeString(msg))
}
if errMsg != "" {
fmt.Fprintf(w, `<div class="notice bad">%s</div>`, html.EscapeString(errMsg))
}
fmt.Fprint(w, `<section class="archive-page-layout"><div class="archive-list-panel"><div class="pane-head">Export / Speicher</div>`)
renderArchiveStatsTable(w, archives, stats)
fmt.Fprint(w, `</div><div class="archive-form-panel"><div class="pane-head">Import</div>`)
renderArchiveUploadForm(w, archives)
fmt.Fprint(w, `</div></section></main><footer class="statusbar"><span>bereit</span></footer></body></html>`)
}
func renderArchiveStatsTable(w http.ResponseWriter, archives []string, stats map[string]ArchiveStats) {
if len(archives) == 0 {
fmt.Fprint(w, `<div class="ef-empty">Noch keine Archiv-Mailbox angelegt.</div>`)
return
}
fmt.Fprint(w, `<table class="archive-table"><thead><tr><th>Archiv-Mailbox</th><th>Speicher</th><th>Mails</th><th>Dubletten</th><th></th></tr></thead><tbody>`)
for _, archive := range archives {
stat := stats[archive]
fmt.Fprintf(w, `<tr><td>%s</td><td>%s</td><td>%d</td><td>%d</td><td class="archive-action"><a class="btn compact secondary" href="/import-export/download?archive_name=%s">Download</a></td></tr>`,
html.EscapeString(archive), html.EscapeString(formatBytes(stat.Bytes)), stat.Messages, stat.Duplicates, urlQuery(archive))
}
fmt.Fprint(w, `</tbody></table>`)
}
func renderArchiveUploadForm(w http.ResponseWriter, archives []string) {
fmt.Fprint(w, `<form class="archive-create-panel" method="post" action="/import-export/upload" enctype="multipart/form-data">
<label class="label">Archiv-Mailbox`)
fmt.Fprint(w, archiveUploadSelect(archives))
fmt.Fprint(w, `</label>
<label class="label">Datei<input class="input" type="file" name="mailfile" accept=".mbox,.pst" required></label>
<button class="btn" type="submit">Upload</button>
</form>
<div class="archive-help">
<div class="archive-help-title">Import/Export</div>
<p>mbox-Dateien werden direkt als Ordnerinhalt importiert und sind im Viewer nutzbar.</p>
<p>PST-Dateien werden als Archivdatei abgelegt; eine PST-zu-mbox-Konvertierung braucht spaeter einen eigenen Parser.</p>
<p>Download exportiert die komplette Archiv-Mailbox als ZIP.</p>
</div>`)
}
func archiveUploadSelect(archives []string) string {
if len(archives) == 0 {
return `<select name="archive_name" disabled><option>erst Archiv-Mailbox erstellen</option></select>`
}
var b strings.Builder
b.WriteString(`<select name="archive_name">`)
for _, archive := range archives {
fmt.Fprintf(&b, `<option value="%s">%s</option>`, html.EscapeString(archive), html.EscapeString(archive))
}
b.WriteString(`</select>`)
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)
}

View file

@ -607,8 +607,12 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
background: #fafafa; background: #fafafa;
} }
.archive-action { .archive-action {
width: 90px; width: 246px;
text-align: right; text-align: right;
overflow: visible;
display: flex;
gap: 4px;
justify-content: flex-end;
} }
.archive-action form { margin: 0; } .archive-action form { margin: 0; }
.archive-create-panel { .archive-create-panel {