updated layout for webmail
This commit is contained in:
@@ -37,7 +37,7 @@
|
||||
<label class="form-label">Then</label>
|
||||
<select class="form-select" name="action" id="rule_action">
|
||||
<option value="move_to_folder">Move to folder</option>
|
||||
<option value="mark_as_spam">Mark as Spam</option>
|
||||
<option value="mark_as_spam">Mark as Junk</option>
|
||||
<option value="delete">Delete</option>
|
||||
<option value="mark_read">Mark as read</option>
|
||||
</select>
|
||||
@@ -93,7 +93,7 @@
|
||||
<td><code>{{ruleSummary .}}</code></td>
|
||||
<td>
|
||||
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
|
||||
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Spam</span>
|
||||
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Junk</span>
|
||||
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
|
||||
{{else}}Mark as read{{end}}
|
||||
</td>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
||||
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
|
||||
<form method="post" action="/webmail/logout" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||
</form>
|
||||
@@ -32,7 +33,7 @@
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
||||
<a href="/webmail/certs" class="btn btn-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
||||
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
|
||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
||||
<form method="post" action="/webmail/logout" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||
@@ -33,7 +34,7 @@
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
{{template "csrf_script" .}}
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
@@ -83,6 +83,13 @@
|
||||
<small class="from-email" id="autosaveStatus" style="display: none;"></small>
|
||||
<button type="button" id="attachBtn" class="btn btn-outline-light btn-sm" title="Attach files"><i class="bi bi-paperclip"></i></button>
|
||||
<input type="file" id="attachment_input" name="attachments" multiple style="display: none;">
|
||||
{{if .signatures}}
|
||||
<select class="form-select form-select-sm" id="signatureSelect" style="width: auto;" title="Signature">
|
||||
<option value="">No signature</option>
|
||||
{{range .signatures}}<option value="{{.ID}}" {{if eq $.default_signature_id .ID}}selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
{{range .signatures}}<div class="sig-data" data-sig-id="{{.ID}}" style="display: none;">{{.ContentHTML | safe}}</div>{{end}}
|
||||
{{end}}
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
{{if .send_as_options}}
|
||||
<select class="form-select form-select-sm from-select" name="from">
|
||||
@@ -175,16 +182,67 @@
|
||||
],
|
||||
},
|
||||
});
|
||||
// Signature tracking: the signature is kept as a plain-paragraph Delta range
|
||||
// (index/length) rather than a DOM marker div. A wrapping <div> set directly on
|
||||
// quill.root gets silently discarded by Quill's own MutationObserver within one
|
||||
// update cycle (it isn't a recognized block type) — confirmed live, so signature
|
||||
// content is inserted via Quill's own clipboard API instead, which turns it into
|
||||
// real paragraph blots that survive.
|
||||
var sigRange = null; // {index, length} of the signature currently in the editor, or null
|
||||
|
||||
function insertSignature(index, html) {
|
||||
if (!html) return null;
|
||||
const before = quill.getLength();
|
||||
quill.clipboard.dangerouslyPasteHTML(index, html);
|
||||
return { index: index, length: quill.getLength() - before };
|
||||
}
|
||||
|
||||
function removeSignature() {
|
||||
if (sigRange && sigRange.length > 0) quill.deleteText(sigRange.index, sigRange.length);
|
||||
sigRange = null;
|
||||
}
|
||||
|
||||
(function() {
|
||||
const seed = document.getElementById('body_html_seed');
|
||||
if (seed && seed.innerHTML.trim()) {
|
||||
quill.root.innerHTML = seed.innerHTML;
|
||||
// Reply/forward seeds start with an empty line above the quoted
|
||||
// original (see composeCursorHome, webmail_compose.go) — put the
|
||||
// cursor there so typing starts above the quote, not inside or after it.
|
||||
quill.setSelection(0, 0);
|
||||
quill.focus();
|
||||
if (!seed || !seed.innerHTML.trim()) return;
|
||||
const parsed = document.createElement('div');
|
||||
parsed.innerHTML = seed.innerHTML;
|
||||
const sigEl = parsed.querySelector('#mailSignatureBlock');
|
||||
let sigHTML = '';
|
||||
if (sigEl) {
|
||||
sigHTML = sigEl.innerHTML;
|
||||
sigEl.remove();
|
||||
}
|
||||
quill.root.innerHTML = parsed.innerHTML;
|
||||
// Reply/forward seeds start with an empty line above the quoted
|
||||
// original (see composeCursorHome, webmail_compose.go) — put the
|
||||
// cursor there so typing starts above the quote, not inside or after it.
|
||||
quill.setSelection(0, 0);
|
||||
quill.focus();
|
||||
if (sigHTML) {
|
||||
// The backend always inserts the signature immediately after that
|
||||
// leading empty paragraph (index 1), before any quoted/forwarded
|
||||
// text — see webmailComposeForm.
|
||||
sigRange = insertSignature(1, sigHTML);
|
||||
quill.setSelection(0, 0);
|
||||
}
|
||||
})();
|
||||
|
||||
// Signature picker: swaps the currently-inserted signature (tracked via
|
||||
// sigRange above) for a different one, or removes it. Picking one when none
|
||||
// exists yet appends it at the end of the body.
|
||||
(function() {
|
||||
const select = document.getElementById('signatureSelect');
|
||||
if (!select) return;
|
||||
const sigHTML = {};
|
||||
document.querySelectorAll('.sig-data').forEach(function(el) { sigHTML[el.dataset.sigId] = el.innerHTML; });
|
||||
|
||||
select.addEventListener('change', function() {
|
||||
const insertAt = sigRange ? sigRange.index : Math.max(0, quill.getLength() - 1);
|
||||
removeSignature();
|
||||
if (!select.value) return;
|
||||
sigRange = insertSignature(insertAt, sigHTML[select.value] || '');
|
||||
});
|
||||
})();
|
||||
|
||||
// Cc/Bcc start hidden (Outlook-style) unless prefilled (e.g. reply-all sets
|
||||
|
||||
@@ -38,18 +38,34 @@
|
||||
.msg-item { display: flex; align-items: flex-start; gap: .6rem; padding: .55rem .75rem; border-bottom: 1px solid #333; cursor: pointer; }
|
||||
.msg-item:hover { background-color: #262626; }
|
||||
.msg-item.active { background-color: #0d3860; }
|
||||
.msg-item:has(.msg-check:checked) { background-color: #2a2f36; }
|
||||
.msg-item.unread .msg-subject { font-weight: 700; color: #fff; }
|
||||
.msg-item.unread .msg-from { font-weight: 700; color: #fff; }
|
||||
.msg-check { margin-top: .35rem; flex: 0 0 auto; }
|
||||
.msg-avatar { width: 34px; height: 34px; border-radius: 50%; background: #495057; color: #fff; display: flex; align-items: center; justify-content: center; font-size: .85rem; font-weight: 600; flex: 0 0 auto; }
|
||||
.msg-item.unread .msg-avatar { background: #0d6efd; }
|
||||
.msg-main { min-width: 0; flex: 1 1 auto; }
|
||||
.msg-row1 { display: flex; justify-content: space-between; gap: .5rem; }
|
||||
.msg-row1 { display: flex; align-items: center; gap: .4rem; }
|
||||
.msg-from { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.msg-date { flex: 0 0 auto; font-size: .75rem; color: #8a8a8a; }
|
||||
.msg-star { flex: 0 0 auto; cursor: pointer; color: #6a6a6a; }
|
||||
.msg-star.bi-star-fill { color: #f4c150; }
|
||||
.msg-star:hover { color: #f4c150; }
|
||||
.msg-date { flex: 0 0 auto; font-size: .75rem; color: #8a8a8a; margin-left: auto; }
|
||||
.msg-row2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; color: #adb5bd; }
|
||||
.msg-subject { color: #e0e0e0; }
|
||||
|
||||
#folderContextMenu { z-index: 1085; }
|
||||
.folder-row { display: flex; align-items: center; }
|
||||
.folder-drag-handle { cursor: grab; color: #6a6a6a; padding: 0 .25rem 0 0; flex: 0 0 auto; }
|
||||
.folder-drag-handle:hover { color: #adb5bd; }
|
||||
.folder-row.folder-row-dragging { opacity: 0.4; }
|
||||
.folder-expand-toggle { flex: 0 0 auto; width: 16px; text-align: center; cursor: pointer; color: #8a8a8a; font-size: .8rem; transition: transform .1s; }
|
||||
.folder-expand-toggle:hover { color: #fff; }
|
||||
.folder-expand-toggle.collapsed { transform: rotate(-90deg); }
|
||||
.folder-expand-spacer { flex: 0 0 auto; width: 16px; }
|
||||
.folder-children { margin-left: 16px; }
|
||||
.folder-children.collapsed { display: none; }
|
||||
|
||||
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-x: auto; }
|
||||
.msg-body-text { white-space: pre-wrap; word-break: break-word; }
|
||||
#readingPaneBody .pane-toolbar { border-bottom: 1px solid #404040; padding-bottom: .75rem; }
|
||||
@@ -69,6 +85,7 @@
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-primary btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm" title="Rules"><i class="bi bi-funnel"></i></a>
|
||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm" title="Certs"><i class="bi bi-shield-lock"></i></a>
|
||||
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm" title="Signatures"><i class="bi bi-pen"></i></a>
|
||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm" title="Account"><i class="bi bi-gear"></i></a>
|
||||
<form method="post" action="/webmail/logout" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm" title="Sign out"><i class="bi bi-box-arrow-right"></i></button>
|
||||
@@ -78,7 +95,7 @@
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
@@ -94,37 +111,30 @@
|
||||
<div class="mail-sidebar" id="folderSidebarCol">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="text-muted small text-uppercase">Folders</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0" id="sidebarCollapseBtn" title="Hide folder list"><i class="bi bi-chevron-bar-left"></i></button>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{{$active := .active_folder}}
|
||||
{{$unread := .unread_counts}}
|
||||
{{$counts := .folder_counts}}
|
||||
{{range .folders}}
|
||||
<div class="d-flex align-items-center folder-row">
|
||||
<a href="/webmail/mail/{{.}}" data-folder="{{.}}" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if eq . $active}}active{{end}}">
|
||||
<span><i class="bi bi-folder2 me-1"></i>{{.}}</span>
|
||||
{{$n := index $unread .}}
|
||||
{{$total := index $counts .}}
|
||||
{{if $total}}
|
||||
<span class="badge {{if $n}}bg-primary{{else}}bg-secondary{{end}} rounded-pill folder-unread-badge" title="{{$total}} total{{if $n}}, {{$n}} unread{{end}}">{{$total}}{{if $n}} / <strong>{{$n}}</strong>{{end}}</span>
|
||||
{{end}}
|
||||
</a>
|
||||
{{if not (isStandardFolder .)}}
|
||||
<form method="post" action="/webmail/mail/folders/{{.}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Remove folder" data-confirm="Remove folder "{{.}}"? Any mail in it moves to INBOX."><i class="bi bi-x-lg"></i></button>
|
||||
</form>
|
||||
{{end}}
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 px-1" id="expandAllBtn" title="Expand all"><i class="bi bi-arrows-expand"></i></button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 px-1" id="collapseAllBtn" title="Collapse all"><i class="bi bi-arrows-collapse"></i></button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0" id="sidebarCollapseBtn" title="Hide folder list"><i class="bi bi-chevron-bar-left"></i></button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<hr class="my-2">
|
||||
<form method="post" action="/webmail/mail/folders/add" class="d-flex gap-1">
|
||||
<input type="text" class="form-control form-control-sm" name="name" placeholder="New folder" maxlength="60" required>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary" title="Create folder"><i class="bi bi-plus-lg"></i></button>
|
||||
</form>
|
||||
<div class="list-group list-group-flush" id="folderList">
|
||||
{{range .folder_tree}}{{template "folderTreeNode" .}}{{end}}
|
||||
</div>
|
||||
<div class="text-muted small mt-2" style="font-size: .72rem;">Right-click a folder for more actions</div>
|
||||
</div>
|
||||
|
||||
<div id="folderContextMenu" class="dropdown-menu" style="display: none; position: fixed;">
|
||||
<button type="button" class="dropdown-item" data-menu-action="new"><i class="bi bi-folder-plus me-2"></i>New folder</button>
|
||||
<button type="button" class="dropdown-item" data-menu-action="mark-all-read"><i class="bi bi-envelope-open me-2"></i>Mark all as read</button>
|
||||
<button type="button" class="dropdown-item" data-menu-action="rename"><i class="bi bi-pencil me-2"></i>Rename</button>
|
||||
<button type="button" class="dropdown-item text-danger" data-menu-action="empty-trash"><i class="bi bi-trash3 me-2"></i>Empty Trash</button>
|
||||
<button type="button" class="dropdown-item text-danger" data-menu-action="clean-spam"><i class="bi bi-shield-x me-2"></i>Clean up Junk</button>
|
||||
<button type="button" class="dropdown-item" data-menu-action="restore-folder"><i class="bi bi-arrow-counterclockwise me-2"></i>Restore</button>
|
||||
<button type="button" class="dropdown-item text-danger" data-menu-action="delete-folder-permanently"><i class="bi bi-trash3-fill me-2"></i>Delete permanently</button>
|
||||
</div>
|
||||
|
||||
<form method="post" id="folderActionForm" class="d-none"></form>
|
||||
|
||||
<div class="mail-list-pane{{if .search_query}} search-mode{{end}}" id="messageListCol">
|
||||
<div class="mail-toolbar">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary border-0 py-0 d-none" id="sidebarShowBtn" title="Show folder list"><i class="bi bi-chevron-bar-right"></i></button>
|
||||
@@ -147,6 +157,7 @@
|
||||
<a href="{{.sort_from_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by sender"><i class="bi bi-person{{if eq .sort_by "from"}}-fill{{end}}"></i>{{if eq .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
|
||||
<a href="{{.sort_date_href}}" class="btn btn-sm btn-outline-secondary border-0 py-0" title="Sort by date"><i class="bi bi-calendar3{{if ne .sort_by "from"}}-fill{{end}}"></i>{{if ne .sort_by "from"}} <i class="bi bi-caret-{{if eq .sort_dir "asc"}}up{{else}}down{{end}}-fill"></i>{{end}}</a>
|
||||
<a href="{{.unread_only_href}}" class="btn btn-sm border-0 py-0 {{if .unread_only}}btn-primary{{else}}btn-outline-secondary{{end}}" title="Unread only"><i class="bi bi-envelope-fill"></i></a>
|
||||
<a href="{{.starred_only_href}}" class="btn btn-sm border-0 py-0 {{if .starred_only}}btn-warning{{else}}btn-outline-secondary{{end}}" title="Favourites only"><i class="bi bi-star-fill"></i></a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,6 +180,7 @@
|
||||
<div class="msg-main">
|
||||
<div class="msg-row1">
|
||||
<span class="msg-from" title="{{if eq .Folder "Sent"}}{{.CachedTo}}{{else}}{{.CachedFrom}}{{end}}">{{$displayName}}</span>
|
||||
<i class="bi msg-star {{if .Starred}}bi-star-fill{{else}}bi-star{{end}}" data-uid="{{.ID}}" data-folder="{{.Folder}}" title="{{if .Starred}}Remove from favourites{{else}}Add to favourites{{end}}"></i>
|
||||
<span class="msg-date">{{strftime "%Y-%m-%d %H:%M" .InternalDate}}</span>
|
||||
</div>
|
||||
<div class="msg-row2">
|
||||
@@ -223,6 +235,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="inputModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="inputModalForm">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="inputModalTitle"><i class="bi bi-pencil me-2"></i>Folder name</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" class="form-control" id="inputModalField" maxlength="60" required>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" id="inputModalConfirm">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "compose_widget" .}}
|
||||
{{template "webmail_shortcuts" .}}
|
||||
|
||||
@@ -247,16 +279,52 @@
|
||||
new bootstrap.Modal(modal).show();
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('[data-confirm]').forEach(function(button) {
|
||||
button.addEventListener('click', async function(e) {
|
||||
// showInputPrompt replaces the browser's native prompt() for folder
|
||||
// name/rename input — a native prompt() blocks the whole tab (including any
|
||||
// in-flight automation/extension driving it) until dismissed, and looks/feels
|
||||
// inconsistent with the confirm dialog above. Resolves the entered text, or
|
||||
// null if cancelled — same null-on-cancel contract prompt() itself has.
|
||||
function showInputPrompt(title, defaultValue) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById('inputModal');
|
||||
const form = document.getElementById('inputModalForm');
|
||||
const field = document.getElementById('inputModalField');
|
||||
document.getElementById('inputModalTitle').textContent = title;
|
||||
field.value = defaultValue || '';
|
||||
let resolved = false;
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (await showConfirmation(this.getAttribute('data-confirm'))) {
|
||||
const form = this.closest('form');
|
||||
if (form) form.submit();
|
||||
}
|
||||
});
|
||||
resolved = true;
|
||||
resolve(field.value.trim());
|
||||
bootstrap.Modal.getInstance(modal).hide();
|
||||
cleanup();
|
||||
};
|
||||
const handleCancel = () => {
|
||||
if (!resolved) resolve(null);
|
||||
cleanup();
|
||||
};
|
||||
const cleanup = () => {
|
||||
form.removeEventListener('submit', handleSubmit);
|
||||
modal.removeEventListener('hidden.bs.modal', handleCancel);
|
||||
};
|
||||
form.addEventListener('submit', handleSubmit);
|
||||
modal.addEventListener('hidden.bs.modal', handleCancel, { once: true });
|
||||
new bootstrap.Modal(modal).show();
|
||||
modal.addEventListener('shown.bs.modal', function() { field.focus(); field.select(); }, { once: true });
|
||||
});
|
||||
}
|
||||
// Delegated (not per-element) so this also covers [data-confirm] buttons
|
||||
// injected later via fetch — the reading pane's own delete button, loaded
|
||||
// into #readingPaneBody by openInPane, wouldn't exist yet at DOMContentLoaded
|
||||
// time for a per-element listener to attach to.
|
||||
document.addEventListener('click', async function(e) {
|
||||
const button = e.target.closest('[data-confirm]');
|
||||
if (!button) return;
|
||||
e.preventDefault();
|
||||
if (await showConfirmation(button.getAttribute('data-confirm'))) {
|
||||
const form = button.closest('form');
|
||||
if (form) form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
// Drag a message row onto a folder in the sidebar to move it there — a
|
||||
@@ -305,6 +373,114 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// Drag-and-drop folder reordering — root folders (INBOX/Junk/Sent/Drafts/
|
||||
// Trash) are static and never draggable; only a custom folder still under
|
||||
// INBOX (server-computed .Renameable — see buildFolderView) can be dragged,
|
||||
// and only among its OWN siblings (rows sharing the same immediate .folder-
|
||||
// children parent — dragging never re-parents a folder, only reorders it
|
||||
// where it already is). A dragged row's own .folder-children (if it has
|
||||
// descendants) moves right along with it, or its whole subtree would get
|
||||
// visually orphaned mid-drag. Reorders the DOM live as you drag (move the
|
||||
// dragged row to whichever side of the row under the cursor it's closer to —
|
||||
// a common no-library technique), persisting just that sibling group's final
|
||||
// order once on dragend. draggingRow being set is what keeps this from firing
|
||||
// during the unrelated "drag a message onto a folder" drag above (that one's
|
||||
// own dragover guard checks draggedUID instead, so the two never interfere).
|
||||
(function() {
|
||||
const list = document.getElementById('folderList');
|
||||
if (!list) return;
|
||||
let draggingRow = null;
|
||||
let draggingChildren = null;
|
||||
|
||||
function persistOrder(parentEl) {
|
||||
const order = Array.from(parentEl.children)
|
||||
.filter(function(el) { return el.classList.contains('folder-row'); })
|
||||
.map(function(el) { return el.dataset.folder; });
|
||||
const body = new URLSearchParams();
|
||||
order.forEach(function(name) { body.append('order', name); });
|
||||
body.set('csrf_token', window.__csrfToken || '');
|
||||
fetch('/webmail/mail/folders/order', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
list.querySelectorAll('.folder-row[draggable="true"]').forEach(function(row) {
|
||||
row.addEventListener('dragstart', function(e) {
|
||||
e.stopPropagation();
|
||||
draggingRow = row;
|
||||
draggingChildren = row.nextElementSibling && row.nextElementSibling.classList.contains('folder-children')
|
||||
? row.nextElementSibling : null;
|
||||
row.classList.add('folder-row-dragging');
|
||||
});
|
||||
row.addEventListener('dragend', function() {
|
||||
row.classList.remove('folder-row-dragging');
|
||||
if (draggingRow) persistOrder(draggingRow.parentElement);
|
||||
draggingRow = null;
|
||||
draggingChildren = null;
|
||||
});
|
||||
row.addEventListener('dragover', function(e) {
|
||||
if (!draggingRow || draggingRow === row) return;
|
||||
if (draggingRow.parentElement !== row.parentElement) return; // reorder only, never re-parent
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = row.getBoundingClientRect();
|
||||
const before = (e.clientY - rect.top) < rect.height / 2;
|
||||
row.parentNode.insertBefore(draggingRow, before ? row : row.nextSibling);
|
||||
if (draggingChildren) row.parentNode.insertBefore(draggingChildren, draggingRow.nextSibling);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Folder tree expand/collapse — a display preference remembered per-browser
|
||||
// (localStorage, a Set of collapsed folder names), same pattern as the
|
||||
// sidebar-hide toggle below. Default is fully expanded (nothing collapsed).
|
||||
(function() {
|
||||
const KEY = 'webmail_collapsed_folders';
|
||||
function loadCollapsed() {
|
||||
try { return new Set(JSON.parse(localStorage.getItem(KEY)) || []); } catch (e) { return new Set(); }
|
||||
}
|
||||
function saveCollapsed(set) { localStorage.setItem(KEY, JSON.stringify(Array.from(set))); }
|
||||
|
||||
function setCollapsed(name, collapsed) {
|
||||
const toggle = document.querySelector('[data-folder-toggle="' + CSS.escape(name) + '"]');
|
||||
const kids = document.querySelector('[data-children-of="' + CSS.escape(name) + '"]');
|
||||
if (toggle) { toggle.classList.toggle('collapsed', collapsed); toggle.title = collapsed ? 'Expand' : 'Collapse'; }
|
||||
if (kids) kids.classList.toggle('collapsed', collapsed);
|
||||
}
|
||||
|
||||
const collapsed = loadCollapsed();
|
||||
collapsed.forEach(function(name) { setCollapsed(name, true); });
|
||||
|
||||
document.querySelectorAll('.folder-expand-toggle').forEach(function(toggle) {
|
||||
toggle.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const name = toggle.dataset.folderToggle;
|
||||
const nowCollapsed = !toggle.classList.contains('collapsed');
|
||||
setCollapsed(name, nowCollapsed);
|
||||
if (nowCollapsed) collapsed.add(name); else collapsed.delete(name);
|
||||
saveCollapsed(collapsed);
|
||||
});
|
||||
});
|
||||
|
||||
const expandAllBtn = document.getElementById('expandAllBtn');
|
||||
const collapseAllBtn = document.getElementById('collapseAllBtn');
|
||||
if (expandAllBtn) expandAllBtn.addEventListener('click', function() {
|
||||
document.querySelectorAll('[data-folder-toggle]').forEach(function(t) { setCollapsed(t.dataset.folderToggle, false); });
|
||||
collapsed.clear();
|
||||
saveCollapsed(collapsed);
|
||||
});
|
||||
if (collapseAllBtn) collapseAllBtn.addEventListener('click', function() {
|
||||
document.querySelectorAll('[data-folder-toggle]').forEach(function(t) {
|
||||
setCollapsed(t.dataset.folderToggle, true);
|
||||
collapsed.add(t.dataset.folderToggle);
|
||||
});
|
||||
saveCollapsed(collapsed);
|
||||
});
|
||||
})();
|
||||
|
||||
// "+N more" toggle for a collapsed same-subject run — reveals every
|
||||
// immediately-following row marked as part of that group, self-terminating
|
||||
// at the first row that isn't (no need to track the count client-side).
|
||||
@@ -343,37 +519,21 @@
|
||||
});
|
||||
})();
|
||||
|
||||
// Reading pane: clicking a row loads the message via fetch instead of
|
||||
// navigating away, mirroring the admin dashboard's message-log modal. Drafts
|
||||
// still navigate to compose (there's nothing to "read"). The clicked row is
|
||||
// marked read optimistically client-side — the pane fetch itself is what
|
||||
// actually marks it read server-side (see webmailMessagePane).
|
||||
// Reading pane + selection share one block because Shift/Ctrl-click needs to
|
||||
// work when clicking anywhere on a row, not just its small checkbox — a plain
|
||||
// click opens the message in the reading pane; Shift-click range-selects from
|
||||
// the last row you interacted with (open or select) to this one; Ctrl/Cmd-click
|
||||
// toggles just this row. Any of those needs the full row list + index, not just
|
||||
// the checkbox's own click event, so it can't be two independent IIFEs the way
|
||||
// "load the pane" and "manage checkboxes" would otherwise naturally split.
|
||||
(function() {
|
||||
const paneBody = document.getElementById('readingPaneBody');
|
||||
document.querySelectorAll('.msg-item').forEach(function(row) {
|
||||
row.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.msg-group-toggle') || e.target.classList.contains('msg-check')) return;
|
||||
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
|
||||
document.querySelectorAll('.msg-item.active').forEach(function(r) { r.classList.remove('active'); });
|
||||
row.classList.add('active');
|
||||
row.classList.remove('unread');
|
||||
paneBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
|
||||
fetch(row.dataset.paneHref)
|
||||
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
|
||||
.then(function(html) { paneBody.innerHTML = html; })
|
||||
.catch(function() { paneBody.innerHTML = '<p class="text-danger">Failed to load the message.</p>'; });
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Selection (checkboxes + select-all + Shift-click range) driving the bulk
|
||||
// toolbar buttons — enabled only once something's actually selected.
|
||||
(function() {
|
||||
const checks = Array.from(document.querySelectorAll('.msg-check'));
|
||||
const rows = Array.from(document.querySelectorAll('.msg-item'));
|
||||
const checks = rows.map(function(row) { return row.querySelector('.msg-check'); });
|
||||
const selectAll = document.getElementById('selectAllCheck');
|
||||
const bulkBtns = document.querySelectorAll('.bulk-btn');
|
||||
const moveSelect = document.getElementById('bulkMoveSelect');
|
||||
let lastCheckedIndex = null;
|
||||
let lastIndex = null;
|
||||
|
||||
function updateToolbar() {
|
||||
const any = checks.some(function(c) { return c.checked; });
|
||||
@@ -382,13 +542,48 @@
|
||||
selectAll.checked = checks.length > 0 && checks.every(function(c) { return c.checked; });
|
||||
}
|
||||
|
||||
function openInPane(row) {
|
||||
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
|
||||
rows.forEach(function(r) { r.classList.remove('active'); });
|
||||
row.classList.add('active');
|
||||
row.classList.remove('unread');
|
||||
paneBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
|
||||
fetch(row.dataset.paneHref)
|
||||
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
|
||||
.then(function(html) { paneBody.innerHTML = html; })
|
||||
.catch(function() { paneBody.innerHTML = '<p class="text-danger">Failed to load the message.</p>'; });
|
||||
}
|
||||
|
||||
rows.forEach(function(row, i) {
|
||||
row.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.msg-group-toggle') || e.target.classList.contains('msg-check')) return;
|
||||
|
||||
if (e.shiftKey && lastIndex !== null) {
|
||||
e.preventDefault();
|
||||
const [from, to] = [lastIndex, i].sort(function(a, b) { return a - b; });
|
||||
for (let j = from; j <= to; j++) checks[j].checked = true;
|
||||
updateToolbar();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
checks[i].checked = !checks[i].checked;
|
||||
lastIndex = i;
|
||||
updateToolbar();
|
||||
return;
|
||||
}
|
||||
lastIndex = i;
|
||||
openInPane(row);
|
||||
});
|
||||
});
|
||||
|
||||
checks.forEach(function(cb, i) {
|
||||
cb.addEventListener('click', function(e) {
|
||||
if (e.shiftKey && lastCheckedIndex !== null) {
|
||||
const [from, to] = [lastCheckedIndex, i].sort(function(a, b) { return a - b; });
|
||||
if (e.shiftKey && lastIndex !== null) {
|
||||
const [from, to] = [lastIndex, i].sort(function(a, b) { return a - b; });
|
||||
for (let j = from; j <= to; j++) checks[j].checked = cb.checked;
|
||||
}
|
||||
lastCheckedIndex = i;
|
||||
lastIndex = i;
|
||||
updateToolbar();
|
||||
});
|
||||
});
|
||||
@@ -437,7 +632,162 @@
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// Star toggle — click the star icon to flip \Flagged (see
|
||||
// db.ToggleMessageStarred) without a full page reload. stopPropagation keeps
|
||||
// this from also triggering the row's own click (which would open the reading
|
||||
// pane / range-select, see the combined IIFE above).
|
||||
document.querySelectorAll('.msg-star').forEach(function(star) {
|
||||
star.addEventListener('click', async function(e) {
|
||||
e.stopPropagation();
|
||||
const uid = star.dataset.uid, folder = star.dataset.folder;
|
||||
const body = new URLSearchParams();
|
||||
body.set('csrf_token', window.__csrfToken || '');
|
||||
const resp = await fetch(`/webmail/mail/${folder}/${uid}/star`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const nowStarred = star.classList.toggle('bi-star-fill');
|
||||
star.classList.toggle('bi-star', !nowStarred);
|
||||
star.title = nowStarred ? 'Remove from favourites' : 'Add to favourites';
|
||||
});
|
||||
});
|
||||
|
||||
// Folder sidebar context menu — right-click any folder for New folder / Mark
|
||||
// all as read / Rename / Empty Trash, replacing the old always-visible "+ New
|
||||
// folder" form. Every action posts through #folderActionForm (shared, its
|
||||
// action/fields set right before submit) rather than one form per action, same
|
||||
// reasoning as the message list's #bulkActionForm.
|
||||
(function() {
|
||||
const menu = document.getElementById('folderContextMenu');
|
||||
const form = document.getElementById('folderActionForm');
|
||||
let menuFolder = null;
|
||||
|
||||
// fields is a plain {name: value} object — each key becomes a hidden
|
||||
// input, replacing whatever the previous action left behind.
|
||||
function postAction(url, fields) {
|
||||
form.action = url;
|
||||
form.querySelectorAll('input[data-dynamic]').forEach(function(el) { el.remove(); });
|
||||
const all = Object.assign({ csrf_token: window.__csrfToken || '' }, fields || {});
|
||||
Object.keys(all).forEach(function(name) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden'; input.name = name; input.value = all[name];
|
||||
input.dataset.dynamic = '1';
|
||||
form.appendChild(input);
|
||||
});
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function hideMenu() { menu.style.display = 'none'; menuFolder = null; }
|
||||
|
||||
document.querySelectorAll('.folder-row').forEach(function(row) {
|
||||
row.addEventListener('contextmenu', function(e) {
|
||||
e.preventDefault();
|
||||
menuFolder = row.dataset.folder;
|
||||
const renameable = row.dataset.renameable === 'true';
|
||||
const isTrash = row.dataset.trash === 'true';
|
||||
const isSpam = row.dataset.spam === 'true';
|
||||
const underTrash = row.dataset.underTrash === 'true';
|
||||
const canAddSubfolder = row.dataset.canAddSubfolder === 'true';
|
||||
menu.querySelector('[data-menu-action="new"]').style.display = canAddSubfolder ? '' : 'none';
|
||||
menu.querySelector('[data-menu-action="rename"]').style.display = renameable ? '' : 'none';
|
||||
menu.querySelector('[data-menu-action="empty-trash"]').style.display = isTrash ? '' : 'none';
|
||||
menu.querySelector('[data-menu-action="clean-spam"]').style.display = isSpam ? '' : 'none';
|
||||
menu.querySelector('[data-menu-action="restore-folder"]').style.display = underTrash ? '' : 'none';
|
||||
menu.querySelector('[data-menu-action="delete-folder-permanently"]').style.display = underTrash ? '' : 'none';
|
||||
menu.style.left = e.clientX + 'px';
|
||||
menu.style.top = e.clientY + 'px';
|
||||
menu.style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
menu.querySelectorAll('[data-menu-action]').forEach(function(item) {
|
||||
item.addEventListener('click', async function() {
|
||||
const folder = menuFolder;
|
||||
const action = item.dataset.menuAction;
|
||||
hideMenu();
|
||||
if (!folder) return;
|
||||
switch (action) {
|
||||
case 'new': {
|
||||
const name = await showInputPrompt('New folder name (inside "' + folder + '")');
|
||||
if (name) postAction('/webmail/mail/folders/add', { name: name, parent: folder });
|
||||
break;
|
||||
}
|
||||
case 'mark-all-read':
|
||||
postAction(`/webmail/mail/${folder}/mark-all-read`);
|
||||
break;
|
||||
case 'rename': {
|
||||
const name = await showInputPrompt('Rename "' + folder + '" to', folder);
|
||||
if (name && name !== folder) {
|
||||
postAction(`/webmail/mail/folders/${folder}/rename`, { new_name: name });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'empty-trash':
|
||||
if (await showConfirmation('Permanently delete every message in Trash (and any folder deleted into it)? This can\'t be undone.')) {
|
||||
postAction(`/webmail/mail/${folder}/empty`);
|
||||
}
|
||||
break;
|
||||
case 'clean-spam':
|
||||
if (await showConfirmation('Permanently delete every message in Junk? This can\'t be undone — they will NOT be moved to Trash.')) {
|
||||
postAction(`/webmail/mail/${folder}/clean-spam`);
|
||||
}
|
||||
break;
|
||||
case 'restore-folder':
|
||||
postAction(`/webmail/mail/folders/${folder}/restore`);
|
||||
break;
|
||||
case 'delete-folder-permanently':
|
||||
if (await showConfirmation('Permanently delete folder "' + folder + '" (and anything nested inside it)? This can\'t be undone.')) {
|
||||
postAction(`/webmail/mail/folders/${folder}/delete-permanently`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!menu.contains(e.target)) hideMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') hideMenu();
|
||||
});
|
||||
window.addEventListener('scroll', hideMenu, true);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{/* folderTreeNode renders one sidebar folder row plus (if any) its children,
|
||||
recursing into itself for each child — . is a *folderViewNode (webmail_mail.go's
|
||||
buildFolderView), which carries everything needed (icon, counts, active state,
|
||||
renameable/can-add-child/is-spam/is-trash) precomputed, so no shared state needs
|
||||
threading through the recursion beyond the node itself. */}}
|
||||
{{define "folderTreeNode"}}
|
||||
<div class="folder-row {{if .Children}}folder-has-children{{end}}" data-folder="{{.Name}}" data-renameable="{{.Renameable}}" data-can-add-subfolder="{{.CanAddKid}}" data-trash="{{.IsTrash}}" data-spam="{{.IsSpam}}" data-under-trash="{{.UnderTrash}}"{{if .Renameable}} draggable="true"{{end}}>
|
||||
{{if .Children}}
|
||||
<i class="bi bi-chevron-down folder-expand-toggle" data-folder-toggle="{{.Name}}" title="Collapse"></i>
|
||||
{{else}}
|
||||
<span class="folder-expand-spacer"></span>
|
||||
{{end}}
|
||||
{{if .Renameable}}<i class="bi bi-grip-vertical folder-drag-handle" title="Drag to reorder"></i>{{end}}
|
||||
<a href="/webmail/mail/{{.Name}}" data-folder="{{.Name}}" draggable="false" class="list-group-item list-group-item-action bg-transparent text-white folder-link flex-grow-1 d-flex justify-content-between align-items-center {{if .Active}}active{{end}}">
|
||||
<span>{{if isStandardFolder .Name}}<i class="bi {{.Icon}} me-1"></i>{{end}}{{.Name}}</span>
|
||||
{{if .Total}}
|
||||
<span class="badge {{if .Unread}}bg-primary{{else}}bg-secondary{{end}} rounded-pill folder-unread-badge" title="{{.Total}} total{{if .Unread}}, {{.Unread}} unread{{end}}">{{.Total}}{{if .Unread}} / <strong>{{.Unread}}</strong>{{end}}</span>
|
||||
{{end}}
|
||||
</a>
|
||||
{{if .Renameable}}
|
||||
<form method="post" action="/webmail/mail/folders/{{.Name}}/remove" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger border-0" title="Delete folder" data-confirm="Delete folder "{{.Name}}"? Any mail in it moves to Trash, keeping this folder as a subfolder there so you can find it."><i class="bi bi-x-lg"></i></button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Children}}
|
||||
<div class="folder-children" data-children-of="{{.Name}}">
|
||||
{{range .Children}}{{template "folderTreeNode" .}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
||||
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
|
||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
||||
<form method="post" action="/webmail/logout" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||
@@ -34,7 +35,7 @@
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
@@ -137,8 +138,13 @@
|
||||
</select>
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">Move</button>
|
||||
</form>
|
||||
{{if .under_trash}}
|
||||
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/restore">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-counterclockwise me-1"></i>Restore</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="{{if eq .active_folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash me-1"></i>{{if eq .active_folder "Trash"}}Delete Permanently{{else}}Move to Trash{{end}}</button>
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm="{{if .under_trash}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}"><i class="bi bi-trash me-1"></i>{{if .under_trash}}Delete Permanently{{else}}Move to Trash{{end}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,15 @@
|
||||
</select>
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Move"><i class="bi bi-folder-symlink"></i></button>
|
||||
</form>
|
||||
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete" onsubmit="return confirm('{{if eq .active_folder "Trash"}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}');">
|
||||
{{if .under_trash}}
|
||||
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/restore">
|
||||
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if eq .active_folder "Trash"}}Delete Permanently{{else}}Move to Trash{{end}}"><i class="bi bi-trash"></i></button>
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Restore"><i class="bi bi-arrow-counterclockwise"></i></button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/delete" data-confirm="{{if .under_trash}}Permanently delete this message? This cannot be undone.{{else}}Move this message to Trash?{{end}}">
|
||||
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" title="{{if .under_trash}}Delete Permanently{{else}}Move to Trash{{end}}"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{{template "csrf_script" .}}
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||
<a href="/webmail/rules" class="btn btn-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
||||
<a href="/webmail/signatures" class="btn btn-outline-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
|
||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
||||
<form method="post" action="/webmail/logout" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||
@@ -33,7 +34,7 @@
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false">
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
@@ -80,7 +81,7 @@
|
||||
<label class="form-label">Then</label>
|
||||
<select class="form-select" name="action" id="rule_action">
|
||||
<option value="move_to_folder">Move to folder</option>
|
||||
<option value="mark_as_spam">Mark as Spam</option>
|
||||
<option value="mark_as_spam">Mark as Junk</option>
|
||||
<option value="delete">Delete</option>
|
||||
<option value="mark_read">Mark as read</option>
|
||||
</select>
|
||||
@@ -136,7 +137,7 @@
|
||||
<td><code>{{ruleSummary .}}</code></td>
|
||||
<td>
|
||||
{{if eq .Action "move_to_folder"}}Move to <strong>{{.ActionValue}}</strong>
|
||||
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Spam</span>
|
||||
{{else if eq .Action "mark_as_spam"}}<span class="text-warning">Mark as Junk</span>
|
||||
{{else if eq .Action "delete"}}<span class="text-danger">Delete</span>
|
||||
{{else}}Mark as read{{end}}
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
{{define "webmail_signatures.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Signatures - Webmail</title>
|
||||
<link href="/webmail/static/vendor/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/webmail/static/vendor/quill/quill.snow.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a1a; color: #e0e0e0; }
|
||||
.card { background-color: #2d2d2d; border: 1px solid #404040; }
|
||||
.sig-preview { background-color: #fff; color: #000; border-radius: 6px; padding: .75rem; max-height: 140px; overflow: hidden; }
|
||||
#sigEditor { height: 180px; background-color: #fff; color: #000; }
|
||||
.ql-toolbar.ql-snow { background-color: #333; border-color: #404040; border-top-left-radius: .375rem; border-top-right-radius: .375rem; }
|
||||
.ql-container.ql-snow { border-color: #404040; border-bottom-left-radius: .375rem; border-bottom-right-radius: .375rem; }
|
||||
.ql-snow .ql-stroke { stroke: #c8c8c8; }
|
||||
.ql-snow .ql-fill, .ql-snow .ql-stroke.ql-fill { fill: #c8c8c8; }
|
||||
.ql-snow .ql-picker { color: #c8c8c8; }
|
||||
.ql-snow .ql-picker-options { background-color: #2d2d2d; border-color: #404040; }
|
||||
.ql-snow .ql-picker-item { color: #c8c8c8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "csrf_script" .}}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1"><i class="bi bi-inbox-fill me-2"></i>Webmail <small class="text-muted">{{.mailbox.Email}}</small></span>
|
||||
<div class="navbar-nav flex-row gap-2 ms-auto">
|
||||
<a href="/webmail/mail/INBOX" class="btn btn-outline-light btn-sm"><i class="bi bi-envelope me-1"></i>Mail</a>
|
||||
<button type="button" onclick="openCompose('/webmail/mail/compose')" class="btn btn-outline-light btn-sm"><i class="bi bi-pencil-square me-1"></i>Compose</button>
|
||||
<a href="/webmail/rules" class="btn btn-outline-light btn-sm"><i class="bi bi-funnel me-1"></i>Rules</a>
|
||||
<a href="/webmail/certs" class="btn btn-outline-light btn-sm"><i class="bi bi-shield-lock me-1"></i>Certs</a>
|
||||
<a href="/webmail/signatures" class="btn btn-light btn-sm"><i class="bi bi-pen me-1"></i>Signatures</a>
|
||||
<a href="/webmail/account" class="btn btn-outline-light btn-sm"><i class="bi bi-gear me-1"></i>Account</a>
|
||||
<form method="post" action="/webmail/logout" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-light btn-sm"><i class="bi bi-box-arrow-right me-1"></i>Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1090;">
|
||||
{{range .flashes}}
|
||||
<div class="toast align-items-center text-bg-{{if eq .Category "error"}}danger{{else}}{{.Category}}{{end}} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-{{if eq .Category "error"}}exclamation-triangle{{else if eq .Category "success"}}check-circle{{else}}info-circle{{end}} me-2"></i>
|
||||
{{.Message}}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="container pb-5">
|
||||
<h2 class="mb-4"><i class="bi bi-pen me-2"></i>Signatures</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0">Your signatures</h5></div>
|
||||
<div class="card-body">
|
||||
{{if .signatures}}
|
||||
{{range .signatures}}
|
||||
<div class="border rounded p-2 mb-3" style="border-color: #404040 !important;">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<strong>{{.Name}}</strong>
|
||||
{{if .IsDefaultNew}}<span class="badge bg-primary ms-1">Default: New</span>{{end}}
|
||||
{{if .IsDefaultReply}}<span class="badge bg-info text-dark ms-1">Default: Reply/Forward</span>{{end}}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="/webmail/signatures?edit={{.ID}}" class="btn btn-outline-secondary" title="Edit"><i class="bi bi-pencil"></i></a>
|
||||
<form method="post" action="/webmail/signatures/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete signature "{{.Name}}"?');">
|
||||
<button type="submit" class="btn btn-outline-danger" title="Delete"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-preview mb-2">{{.ContentHTML | safe}}</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<form method="post" action="/webmail/signatures/{{if .IsDefaultNew}}0{{else}}{{.ID}}{{end}}/default?which=new" class="d-inline">
|
||||
<button type="submit" class="btn {{if .IsDefaultNew}}btn-primary{{else}}btn-outline-secondary{{end}}">{{if .IsDefaultNew}}✓ Default for new{{else}}Use for new{{end}}</button>
|
||||
</form>
|
||||
<form method="post" action="/webmail/signatures/{{if .IsDefaultReply}}0{{else}}{{.ID}}{{end}}/default?which=reply" class="d-inline">
|
||||
<button type="submit" class="btn {{if .IsDefaultReply}}btn-info text-dark{{else}}btn-outline-secondary{{end}}">{{if .IsDefaultReply}}✓ Default for reply/forward{{else}}Use for reply/forward{{end}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="text-muted mb-0">No signatures yet — add one to have it available in compose.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h5 class="mb-0">{{if .editing}}Edit signature{{else}}New signature{{end}}</h5></div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/webmail/signatures/save" id="sigForm">
|
||||
<input type="hidden" name="id" value="{{if .editing}}{{.editing.ID}}{{end}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" name="name" value="{{if .editing}}{{.editing.Name}}{{end}}" placeholder="e.g. Work, Personal" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Content</label>
|
||||
<div id="sigEditor"></div>
|
||||
<textarea name="content_html" style="display: none;"></textarea>
|
||||
<div id="sig_html_seed" style="display: none;">{{if .editing}}{{.editing.ContentHTML}}{{end}}</div>
|
||||
<div class="form-text">Plain text works fine too — just don't use the formatting toolbar. A blank signature is also valid (e.g. to temporarily disable one without deleting it).</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Save</button>
|
||||
{{if .editing}}<a href="/webmail/signatures" class="btn btn-outline-secondary">Cancel</a>{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "compose_widget" .}}
|
||||
|
||||
<script src="/webmail/static/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/webmail/static/vendor/quill/quill.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.toast').forEach(function(el) { new bootstrap.Toast(el, {delay: 5000}).show(); });
|
||||
});
|
||||
|
||||
var sigQuill = new Quill('#sigEditor', {
|
||||
theme: 'snow',
|
||||
modules: { toolbar: [['bold', 'italic', 'underline'], [{ color: [] }], ['link', 'image'], ['clean']] },
|
||||
});
|
||||
(function() {
|
||||
const seed = document.getElementById('sig_html_seed');
|
||||
if (seed && seed.innerHTML.trim()) sigQuill.root.innerHTML = seed.innerHTML;
|
||||
})();
|
||||
document.getElementById('sigForm').addEventListener('submit', function() {
|
||||
document.querySelector('[name="content_html"]').value = sigQuill.root.innerHTML;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user