Add archive import export and dedup tools
This commit is contained in:
parent
8d2838aea7
commit
d5576ddbde
4 changed files with 528 additions and 36 deletions
|
|
@ -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>`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
447
backend/12-archive-tools.go
Normal 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)
|
||||||
|
}
|
||||||
6
frontend-js/dist/style.css
vendored
6
frontend-js/dist/style.css
vendored
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue