Use internal compose modal for mail actions

This commit is contained in:
DonVoo 2026-07-13 12:16:21 +02:00
parent e8029a177c
commit 29fd3f5cae
3 changed files with 229 additions and 24 deletions

View file

@ -61,6 +61,51 @@ func RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/view/target/select", targetSelectHandler) mux.HandleFunc("/view/target/select", targetSelectHandler)
mux.HandleFunc("/view/manual-copy", manualCopyHandler) mux.HandleFunc("/view/manual-copy", manualCopyHandler)
mux.HandleFunc("/view/forward", forwardHandler) mux.HandleFunc("/view/forward", forwardHandler)
mux.HandleFunc("/mail/send", mailSendHandler)
}
func mailSendHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
to := splitRecipients(r.FormValue("to"))
subject := strings.TrimSpace(r.FormValue("subject"))
body := r.FormValue("body")
if len(to) == 0 {
http.Error(w, "Bitte mindestens einen Empfaenger eintragen.", http.StatusBadRequest)
return
}
if subject == "" {
subject = "(ohne Betreff)"
}
if err := SendPlainMail(to, subject, body); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, "gesendet")
}
func splitRecipients(value string) []string {
fields := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' '
})
recipients := make([]string, 0, len(fields))
seen := make(map[string]bool, len(fields))
for _, field := range fields {
addr := strings.TrimSpace(field)
if addr == "" || seen[strings.ToLower(addr)] {
continue
}
recipients = append(recipients, addr)
seen[strings.ToLower(addr)] = true
}
return recipients
} }
func homeHandler(w http.ResponseWriter, r *http.Request) { func homeHandler(w http.ResponseWriter, r *http.Request) {
@ -73,8 +118,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=20260713-10"> <link rel="stylesheet" href="/static/style.css?v=20260713-11">
<script src="/static/app.js?v=20260713-10" defer></script></head> <script src="/static/app.js?v=20260713-11" 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,9 +166,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=20260713-10"> <link rel="stylesheet" href="/static/style.css?v=20260713-11">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260713-10" defer></script></head> <script src="/static/app.js?v=20260713-11" 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>
@ -697,9 +742,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=20260713-10"> <link rel="stylesheet" href="/static/style.css?v=20260713-11">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260713-10" defer></script></head> <script src="/static/app.js?v=20260713-11" 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>
@ -743,9 +788,9 @@ func renderArchivesPage(w http.ResponseWriter, r *http.Request, archives []strin
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=20260713-10"> <link rel="stylesheet" href="/static/style.css?v=20260713-11">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260713-10" defer></script></head> <script src="/static/app.js?v=20260713-11" 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>
@ -791,9 +836,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=20260713-10"> <link rel="stylesheet" href="/static/style.css?v=20260713-11">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260713-10" defer></script></head> <script src="/static/app.js?v=20260713-11" 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>
@ -865,9 +910,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=20260713-10"> <link rel="stylesheet" href="/static/style.css?v=20260713-11">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260713-10" defer></script></head> <script src="/static/app.js?v=20260713-11" 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>
@ -1317,9 +1362,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=20260713-10"> <link rel="stylesheet" href="/static/style.css?v=20260713-11">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260713-10" defer></script></head> <script src="/static/app.js?v=20260713-11" 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>
@ -1361,9 +1406,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=20260713-10"> <link rel="stylesheet" href="/static/style.css?v=20260713-11">
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<script src="/static/app.js?v=20260713-10" defer></script></head> <script src="/static/app.js?v=20260713-11" 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>

View file

@ -42,11 +42,49 @@
return `${prefix}: ${clean || "(ohne Betreff)"}`; return `${prefix}: ${clean || "(ohne Betreff)"}`;
} }
function openMailto(mail) { function ensureComposeModal() {
const params = new URLSearchParams(); let modal = document.getElementById("mailComposeModal");
if (mail.subject) params.set("subject", mail.subject); if (modal) return modal;
if (mail.body) params.set("body", mail.body); modal = document.createElement("div");
window.location.href = `mailto:${encodeURIComponent(mail.to || "")}?${params.toString()}`; modal.className = "mail-compose-modal";
modal.id = "mailComposeModal";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-label", "Mail verfassen");
modal.innerHTML = `
<form class="mail-compose-box">
<div class="mail-compose-head">
<h2>Mail verfassen</h2>
<button class="mail-compose-close" type="button" aria-label="Schliessen" data-compose-close>&times;</button>
</div>
<div class="mail-compose-fields">
<label>An<input name="to" type="text" autocomplete="email" required placeholder="empfaenger@example.com"></label>
<label>Betreff<input name="subject" type="text" placeholder="Betreff"></label>
<label>Nachricht<textarea name="body" rows="14"></textarea></label>
</div>
<div class="mail-compose-status" aria-live="polite"></div>
<div class="mail-compose-actions">
<button class="btn secondary" type="button" data-compose-close>Abbrechen</button>
<button class="btn" type="submit">Senden</button>
</div>
</form>
`;
document.body.appendChild(modal);
return modal;
}
function closeComposeModal() {
document.getElementById("mailComposeModal")?.classList.remove("open");
}
function openCompose(mail) {
const modal = ensureComposeModal();
modal.querySelector("[name='to']").value = mail.to || "";
modal.querySelector("[name='subject']").value = mail.subject || "";
modal.querySelector("[name='body']").value = mail.body || "";
modal.querySelector(".mail-compose-status").textContent = "";
modal.classList.add("open");
setTimeout(() => modal.querySelector("[name='to']").focus(), 0);
} }
function quoteBody(message) { function quoteBody(message) {
@ -85,7 +123,7 @@
}); });
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (event.target.closest("[data-action-new]")) { if (event.target.closest("[data-action-new]")) {
openMailto({ to: "", subject: "", body: "" }); openCompose({ to: "", subject: "", body: "" });
} }
if (event.target.closest("[data-action-refresh]")) window.location.reload(); if (event.target.closest("[data-action-refresh]")) window.location.reload();
if (event.target.closest("[data-action-reply]")) { if (event.target.closest("[data-action-reply]")) {
@ -94,7 +132,7 @@
window.alert("Bitte zuerst eine Nachricht waehlen."); window.alert("Bitte zuerst eine Nachricht waehlen.");
return; return;
} }
openMailto({ to: message.from, subject: prefixedSubject("Re", message.subject), body: quoteBody(message) }); openCompose({ to: message.from, subject: prefixedSubject("Re", message.subject), body: quoteBody(message) });
} }
if (event.target.closest("[data-action-forward]")) { if (event.target.closest("[data-action-forward]")) {
const message = selectedPreview(); const message = selectedPreview();
@ -102,7 +140,38 @@
window.alert("Bitte zuerst eine Nachricht waehlen."); window.alert("Bitte zuerst eine Nachricht waehlen.");
return; return;
} }
openMailto({ to: "", subject: prefixedSubject("Fw", message.subject), body: forwardBody(message) }); openCompose({ to: "", subject: prefixedSubject("Fw", message.subject), body: forwardBody(message) });
}
if (event.target.closest("[data-compose-close]")) closeComposeModal();
});
document.addEventListener("click", (event) => {
if (event.target.id === "mailComposeModal") closeComposeModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeComposeModal();
});
document.addEventListener("submit", async (event) => {
const form = event.target.closest(".mail-compose-box");
if (!form) return;
event.preventDefault();
const status = form.querySelector(".mail-compose-status");
const submit = form.querySelector("button[type='submit']");
status.textContent = "Sende...";
submit.disabled = true;
try {
const response = await fetch("/mail/send", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
body: new URLSearchParams(new FormData(form)).toString()
});
const text = await response.text();
if (!response.ok) throw new Error(text || "Senden fehlgeschlagen.");
status.textContent = "Gesendet.";
setTimeout(closeComposeModal, 700);
} catch (error) {
status.textContent = error.message || "Senden fehlgeschlagen.";
} finally {
submit.disabled = false;
} }
}); });
document.addEventListener("input", (event) => { document.addEventListener("input", (event) => {

View file

@ -540,6 +540,97 @@ a.tree-node { display: block; color: inherit; text-decoration: none; }
overflow: auto; overflow: auto;
padding: 20px 24px; padding: 20px 24px;
} }
.mail-compose-modal {
position: fixed;
inset: 0;
z-index: 70;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, .32);
}
.mail-compose-modal.open { display: flex; }
.mail-compose-box {
width: min(760px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid var(--ol-border);
box-shadow: 0 20px 60px rgba(0, 0, 0, .28);
}
.mail-compose-head {
height: 42px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 12px 0 16px;
color: #fff;
background: #2f343a;
}
.mail-compose-head h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.mail-compose-close {
margin-left: auto;
width: 30px;
height: 30px;
border: 0;
color: #fff;
background: transparent;
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.mail-compose-close:hover { background: #454b53; }
.mail-compose-fields {
display: grid;
gap: 10px;
padding: 14px 16px;
overflow: auto;
}
.mail-compose-fields label {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
align-items: start;
gap: 10px;
color: var(--ol-muted);
}
.mail-compose-fields input,
.mail-compose-fields textarea {
width: 100%;
border: 1px solid #b8c4d0;
padding: 7px 8px;
color: var(--ol-text);
background: #fff;
font: inherit;
}
.mail-compose-fields textarea {
min-height: 260px;
resize: vertical;
font-family: Consolas, "Courier New", monospace;
}
.mail-compose-fields input:focus,
.mail-compose-fields textarea:focus {
outline: none;
border-color: var(--ol-blue);
}
.mail-compose-status {
min-height: 20px;
padding: 0 16px 8px 98px;
color: var(--ol-muted);
}
.mail-compose-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--ol-border);
background: #f7f9fb;
}
.ef-empty { color: var(--ol-muted); padding: 24px; } .ef-empty { color: var(--ol-muted); padding: 24px; }
.audit-note { padding: 10px 16px; border-bottom: 1px solid var(--ol-border); font-size: 12px; } .audit-note { padding: 10px 16px; border-bottom: 1px solid var(--ol-border); font-size: 12px; }
.drop-zone { .drop-zone {