fixing folders and image rendering in client

This commit is contained in:
2026-08-15 18:38:11 +01:00
parent 6f0c305367
commit 15678c1b6e
16 changed files with 929 additions and 26 deletions
@@ -78,6 +78,52 @@
</div>
</div>
<div class="card mt-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-image me-2"></i>Remote Images</h5></div>
<div class="card-body">
<div class="form-text mb-2">HTML emails can embed images loaded from the sender's own server — a classic tracking pixel. Choose how those are handled.</div>
<form method="POST" action="/webmail/account/remote-images-mode">
<div class="form-check">
<input class="form-check-input" type="radio" name="remote_images_mode" value="ask" id="rim-ask" {{if eq .mailbox.RemoteImagesMode "ask"}}checked{{end}}>
<label class="form-check-label" for="rim-ask">Ask to show remote images <span class="text-muted">(default)</span></label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="remote_images_mode" value="trusted" id="rim-trusted" {{if eq .mailbox.RemoteImagesMode "trusted"}}checked{{end}}>
<label class="form-check-label" for="rim-trusted">Show automatically for senders on my trusted list below</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="remote_images_mode" value="always" id="rim-always" {{if eq .mailbox.RemoteImagesMode "always"}}checked{{end}}>
<label class="form-check-label" for="rim-always">Always show remote images <span class="text-danger">(not recommended)</span></label>
</div>
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-check-lg me-1"></i>Save</button>
</form>
<hr>
<h6>Trusted senders</h6>
<form method="POST" action="/webmail/account/trusted-senders/add" class="row g-2 align-items-end mb-3">
<div class="col-auto">
<input type="email" class="form-control form-control-sm" name="email" placeholder="sender@example.com" required>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-outline-primary btn-sm"><i class="bi bi-plus-lg me-1"></i>Add</button>
</div>
</form>
{{if .trusted_senders}}
<ul class="list-group list-group-flush">
{{range .trusted_senders}}
<li class="list-group-item list-group-item-dark d-flex justify-content-between align-items-center">
{{.Email}}
<form method="post" action="/webmail/account/trusted-senders/{{.ID}}/remove" class="d-inline">
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i></button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="text-muted mb-0">No trusted senders yet.</p>
{{end}}
</div>
</div>
<div class="card mt-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-key-fill me-2"></i>Change Password</h5></div>
<div class="card-body">
+173 -3
View File
@@ -54,7 +54,10 @@
.msg-row2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; color: #adb5bd; }
.msg-subject { color: #e0e0e0; }
#folderContextMenu { z-index: 1085; }
#folderContextMenu, #messageContextMenu { z-index: 1085; }
.dropdown-submenu { position: relative; }
.dropdown-submenu > .dropdown-menu { top: 0; left: 100%; margin-left: 2px; display: none; }
.dropdown-submenu:hover > .dropdown-menu { display: block; }
.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; }
@@ -66,8 +69,10 @@
.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; }
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-wrap: break-word; word-break: break-word; overflow-x: auto; max-width: 100%; }
.msg-body-html img { max-width: 100%; height: auto; }
.msg-body-html table { max-width: 100%; }
.msg-body-text { white-space: pre-wrap; word-break: break-word; overflow-wrap: break-word; }
#readingPaneBody .pane-toolbar { border-bottom: 1px solid #404040; padding-bottom: .75rem; }
</style>
</head>
@@ -219,6 +224,40 @@
<input type="hidden" name="target_folder" id="bulkTargetFolderField">
</form>
{{/* Always rendered (independent of search-mode, unlike the bulk toolbar's own
Move-to dropdown) so the message context menu's Move-to submenu always has a
folder list to build from, search results included. */}}
<select id="allFoldersList" class="d-none">
{{range .folders}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
<div id="messageContextMenu" class="dropdown-menu" style="display: none; position: fixed;">
<button type="button" class="dropdown-item" data-msg-action="reply"><i class="bi bi-reply me-2"></i>Reply</button>
<button type="button" class="dropdown-item" data-msg-action="forward"><i class="bi bi-arrow-right me-2"></i>Forward</button>
<div class="dropdown-divider"></div>
<button type="button" class="dropdown-item" data-msg-action="toggle-read"><i class="bi bi-envelope-open me-2"></i><span data-toggle-read-label>Mark as read</span></button>
<button type="button" class="dropdown-item" data-msg-action="junk"><i class="bi bi-shield-exclamation me-2"></i>Mark as Junk</button>
<div class="dropdown-submenu">
<button type="button" class="dropdown-item dropdown-toggle" data-msg-action="move-toggle">Move to&hellip;</button>
<div class="dropdown-menu" id="messageMoveSubmenu"></div>
</div>
<button type="button" class="dropdown-item" data-msg-action="open-tab"><i class="bi bi-box-arrow-up-right me-2"></i>Open in new tab</button>
<div class="dropdown-divider"></div>
<button type="button" class="dropdown-item text-danger" data-msg-action="delete"><i class="bi bi-trash me-2"></i><span data-delete-label>Delete</span></button>
</div>
<form method="post" id="messageActionForm" class="d-none"></form>
<div class="modal fade" id="messageModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content" style="background-color: #2d2d2d; border-color: #404040;">
<div class="modal-header" style="border-color: #404040;">
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="messageModalBody"></div>
</div>
</div>
</div>
<div class="modal fade" id="confirmationModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
@@ -755,6 +794,137 @@
});
window.addEventListener('scroll', hideMenu, true);
})();
// Message row right-click menu (Reply/Forward/Mark read-unread/Mark as Junk/
// Move to.../Open in new tab/Delete) and double-click (open in a full-size
// modal, reusing the same pane fragment the reading pane already loads —
// useful for a long message without losing the list). Both are additive to
// the existing plain/Shift/Ctrl-click handling above (different event types
// entirely — contextmenu/dblclick never fire alongside a plain click).
(function() {
const menu = document.getElementById('messageContextMenu');
const submenu = document.getElementById('messageMoveSubmenu');
const form = document.getElementById('messageActionForm');
let menuRow = null;
function postMessageAction(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'; menuRow = null; }
function buildMoveSubmenu(row) {
submenu.innerHTML = '';
const rowFolder = row.dataset.folder;
Array.from(document.querySelectorAll('#allFoldersList option')).forEach(function(opt) {
if (opt.value === rowFolder) return;
const item = document.createElement('button');
item.type = 'button';
item.className = 'dropdown-item';
item.textContent = opt.value;
item.addEventListener('click', function() {
hideMenu();
postMessageAction(`/webmail/mail/${rowFolder}/${row.dataset.uid}/move`, { target_folder: opt.value });
});
submenu.appendChild(item);
});
const newItem = document.createElement('button');
newItem.type = 'button';
newItem.className = 'dropdown-item';
newItem.innerHTML = '<i class="bi bi-folder-plus me-2"></i>New folder&hellip;';
newItem.addEventListener('click', async function() {
hideMenu();
const name = await showInputPrompt('New folder name (inside "INBOX")');
if (!name) return;
const body = new URLSearchParams({ name: name, parent: 'INBOX', csrf_token: window.__csrfToken || '' });
const resp = await fetch('/webmail/mail/folders/add', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() });
if (resp.ok) {
postMessageAction(`/webmail/mail/${rowFolder}/${row.dataset.uid}/move`, { target_folder: name });
}
});
submenu.appendChild(newItem);
}
document.querySelectorAll('.msg-row').forEach(function(row) {
row.addEventListener('contextmenu', function(e) {
e.preventDefault();
if (row.dataset.isDraft === 'true') return; // drafts only open in compose
menuRow = row;
const isUnread = row.classList.contains('unread');
menu.querySelector('[data-toggle-read-label]').textContent = isUnread ? 'Mark as read' : 'Mark as unread';
const underTrash = row.dataset.folder && document.querySelector('.folder-row[data-folder="' + CSS.escape(row.dataset.folder) + '"][data-under-trash="true"]') !== null;
menu.querySelector('[data-delete-label]').textContent = underTrash ? 'Delete Permanently' : 'Delete';
buildMoveSubmenu(row);
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
menu.style.display = 'block';
});
row.addEventListener('dblclick', function(e) {
if (e.target.closest('.msg-check, .msg-star, .msg-group-toggle')) return;
if (row.dataset.isDraft === 'true') { window.location.href = row.dataset.href; return; }
const modalBody = document.getElementById('messageModalBody');
modalBody.innerHTML = '<div class="text-center text-muted py-5"><div class="spinner-border" role="status"></div></div>';
new bootstrap.Modal(document.getElementById('messageModal')).show();
fetch(row.dataset.paneHref)
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function(html) { modalBody.innerHTML = html; })
.catch(function() { modalBody.innerHTML = '<p class="text-danger p-3">Failed to load the message.</p>'; });
});
});
menu.querySelectorAll('[data-msg-action]').forEach(function(item) {
item.addEventListener('click', async function() {
const row = menuRow;
const action = item.dataset.msgAction;
if (action === 'move-toggle') return; // hover-only, handled by the submenu itself
hideMenu();
if (!row) return;
const folder = row.dataset.folder, uid = row.dataset.uid;
switch (action) {
case 'reply':
openCompose(`/webmail/mail/compose?reply=${uid}&folder=${folder}`);
break;
case 'forward':
openCompose(`/webmail/mail/compose?forward=${uid}&folder=${folder}`);
break;
case 'toggle-read': {
const isUnread = row.classList.contains('unread');
postMessageAction(`/webmail/mail/${folder}/bulk`, { action: isUnread ? 'read' : 'unread', uid: uid });
break;
}
case 'junk':
postMessageAction(`/webmail/mail/${folder}/${uid}/mark-junk`);
break;
case 'open-tab':
window.open(row.dataset.href, '_blank', 'noopener');
break;
case 'delete': {
const underTrash = menu.querySelector('[data-delete-label]').textContent === 'Delete Permanently';
const confirmMsg = underTrash ? 'Permanently delete this message? This cannot be undone.' : 'Move this message to Trash?';
if (await showConfirmation(confirmMsg)) postMessageAction(`/webmail/mail/${folder}/${uid}/delete`);
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>
+23 -3
View File
@@ -10,8 +10,10 @@
<style>
body { background-color: #1a1a1a; color: #e0e0e0; }
.card { background-color: #2d2d2d; border: 1px solid #404040; }
.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; }
.msg-body-html { background-color: #fff; color: #000; border-radius: 6px; padding: 1rem; overflow-wrap: break-word; word-break: break-word; overflow-x: auto; max-width: 100%; }
.msg-body-html img { max-width: 100%; height: auto; }
.msg-body-html table { max-width: 100%; }
.msg-body-text { white-space: pre-wrap; word-break: break-word; overflow-wrap: break-word; }
</style>
</head>
<body>
@@ -103,6 +105,19 @@
</div>
</div>
<div class="card-body">
{{if .images_blocked}}
<div class="alert alert-secondary d-flex justify-content-between align-items-center py-2 mb-3">
<span><i class="bi bi-shield-lock me-1"></i>Remote images were blocked to protect your privacy.</span>
<span class="d-flex gap-2">
<a href="{{.message_url}}?show_images=1" class="btn btn-sm btn-outline-secondary">Show images</a>
{{if .sender_email}}
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/always-allow-images" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-secondary">Always show images from {{.sender_email}}</button>
</form>
{{end}}
</span>
</div>
{{end}}
{{if .html_body}}
<div class="msg-body-html">{{.html_body}}</div>
{{else if .parsed.TextBody}}
@@ -113,7 +128,12 @@
{{if .parsed.Attachments}}
<hr>
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
<div class="d-flex justify-content-between align-items-center">
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
{{if gt (len .parsed.Attachments) 1}}
<a href="/webmail/mail/{{.active_folder}}/{{.uid}}/attachments.zip" class="btn btn-sm btn-outline-secondary"><i class="bi bi-file-earmark-zip me-1"></i>Download all</a>
{{end}}
</div>
<div class="list-group">
{{$folder := .active_folder}}
{{$uid := .uid}}
@@ -4,6 +4,7 @@
<button type="button" onclick="openCompose('/webmail/mail/compose?reply={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Reply"><i class="bi bi-reply"></i></button>
<button type="button" onclick="openCompose('/webmail/mail/compose?replyall={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Reply All"><i class="bi bi-reply-all"></i></button>
<button type="button" onclick="openCompose('/webmail/mail/compose?forward={{.uid}}&folder={{.active_folder}}')" class="btn btn-outline-primary" title="Forward"><i class="bi bi-arrow-right"></i></button>
<a href="{{.message_url}}" target="_blank" rel="noopener" class="btn btn-outline-primary" title="Open in new tab"><i class="bi bi-box-arrow-up-right"></i></a>
</div>
<div class="d-flex align-items-center gap-2">
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/move" class="d-flex align-items-center gap-1">
@@ -72,6 +73,21 @@
<div><strong>Date:</strong> {{.parsed.Header.Date}}</div>
</div>
{{if .images_blocked}}
<div class="alert alert-secondary d-flex justify-content-between align-items-center py-2 mb-3">
<span><i class="bi bi-shield-lock me-1"></i>Remote images were blocked to protect your privacy.</span>
<span class="d-flex gap-2">
<a href="{{.message_url}}?show_images=1" class="btn btn-sm btn-outline-secondary">Show images</a>
{{if .sender_email}}
<form method="post" action="/webmail/mail/{{.active_folder}}/{{.uid}}/always-allow-images" class="d-inline">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}">
<button type="submit" class="btn btn-sm btn-outline-secondary">Always show images from {{.sender_email}}</button>
</form>
{{end}}
</span>
</div>
{{end}}
{{if .html_body}}
<div class="msg-body-html">{{.html_body}}</div>
{{else if .parsed.TextBody}}
@@ -82,7 +98,12 @@
{{if .parsed.Attachments}}
<hr>
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
<div class="d-flex justify-content-between align-items-center">
<h6><i class="bi bi-paperclip me-1"></i>Attachments</h6>
{{if gt (len .parsed.Attachments) 1}}
<a href="/webmail/mail/{{.active_folder}}/{{.uid}}/attachments.zip" class="btn btn-sm btn-outline-secondary"><i class="bi bi-file-earmark-zip me-1"></i>Download all</a>
{{end}}
</div>
<div class="list-group">
{{$folder := .active_folder}}
{{$uid := .uid}}