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/create", archiveCreateHandler)
|
||||
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("/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">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Mail-Graveyard</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260712-21">
|
||||
<script src="/static/app.js?v=20260712-21" defer></script></head>
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260712-22">
|
||||
<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>
|
||||
|
|
@ -73,7 +77,7 @@ func renderStartPage(w http.ResponseWriter, r *http.Request) {
|
|||
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
|
||||
<a class="backstage-item active" href="/">Start</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="/archives">Archiv-mbox Verwaltung</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">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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/app.js?v=20260712-21" defer></script></head>
|
||||
<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>
|
||||
|
|
@ -121,7 +125,7 @@ func renderShell(w http.ResponseWriter, r *http.Request, active, readingPane str
|
|||
</header>
|
||||
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
|
||||
<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="/archives">Archiv-mbox Verwaltung</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)
|
||||
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) {
|
||||
|
|
@ -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">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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/app.js?v=20260712-21" defer></script></head>
|
||||
<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>
|
||||
|
|
@ -592,7 +596,7 @@ func renderAccountsPage(w http.ResponseWriter, r *http.Request, accounts []Accou
|
|||
</header>
|
||||
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
|
||||
<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" href="/archives">Archiv-mbox Verwaltung</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>`)
|
||||
}
|
||||
|
||||
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)
|
||||
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>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/app.js?v=20260712-21" defer></script></head>
|
||||
<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>
|
||||
|
|
@ -637,7 +641,7 @@ func renderArchivesPage(w http.ResponseWriter, r *http.Request, archives []strin
|
|||
</header>
|
||||
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
|
||||
<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 active" href="/archives">Archiv-mbox Verwaltung</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.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>`)
|
||||
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>`)
|
||||
|
|
@ -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">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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/app.js?v=20260712-21" defer></script></head>
|
||||
<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>
|
||||
|
|
@ -684,7 +688,7 @@ func renderUsersPage(w http.ResponseWriter, r *http.Request, users []AppUser, ed
|
|||
</header>
|
||||
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
|
||||
<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="/archives">Archiv-mbox Verwaltung</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">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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/app.js?v=20260712-21" defer></script></head>
|
||||
<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>
|
||||
|
|
@ -754,7 +758,7 @@ func renderTransferPage(w http.ResponseWriter, r *http.Request, mode transferMod
|
|||
</header>
|
||||
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
|
||||
<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="/archives">Archiv-mbox Verwaltung</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">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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/app.js?v=20260712-21" defer></script></head>
|
||||
<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>
|
||||
|
|
@ -795,7 +799,7 @@ func renderSourceEmailBoxPage(w http.ResponseWriter, r *http.Request, accounts [
|
|||
</header>
|
||||
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
|
||||
<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="/archives">Archiv-mbox Verwaltung</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">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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/app.js?v=20260712-21" defer></script></head>
|
||||
<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>
|
||||
|
|
@ -837,7 +841,7 @@ func renderTargetEmailBoxPage(w http.ResponseWriter, r *http.Request, accounts [
|
|||
</header>
|
||||
<aside class="backstage-menu" aria-label="Mail-Graveyard-Menue">
|
||||
<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="/archives">Archiv-mbox Verwaltung</a>
|
||||
<a class="backstage-item" href="/transfer/postfach-archiv">Mail-Transfer</a>
|
||||
|
|
@ -1011,6 +1015,37 @@ func renderRibbonTransferMenu(active string) 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 {
|
||||
var b strings.Builder
|
||||
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."
|
||||
}
|
||||
|
||||
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 {
|
||||
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>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 {
|
||||
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 == "" {
|
||||
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"
|
||||
}
|
||||
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>`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ func renderLoginPage(w http.ResponseWriter, errMsg string) {
|
|||
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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">
|
||||
<main class="login-card">
|
||||
<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">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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">
|
||||
<main class="login-card">
|
||||
<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">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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">
|
||||
<main class="login-card">
|
||||
<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;
|
||||
}
|
||||
.archive-action {
|
||||
width: 90px;
|
||||
width: 246px;
|
||||
text-align: right;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.archive-action form { margin: 0; }
|
||||
.archive-create-panel {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue