fixing folders and image rendering in client
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// stripRemoteImages walks already-sanitized HTML (htmlBodyPolicy has already removed
|
||||
// scripts/event handlers/etc. — this never runs on untrusted-for-XSS-purposes input)
|
||||
// and neutralizes any <img src="..."> that isn't a data: URI, renaming it to
|
||||
// data-blocked-src so the browser never fetches it — a classic tracking-pixel/
|
||||
// read-receipt vector otherwise. Reports whether anything was actually blocked, so
|
||||
// the caller only shows a "Show images" banner when there's something to reveal.
|
||||
//
|
||||
// This runs as a second pass over the DOM rather than trying to make bluemonday's own
|
||||
// policy conditionally reject remote img src — bluemonday composes URL-scheme rules
|
||||
// globally per policy (AllowStandardURLs), and UGCPolicy already bakes in "img src
|
||||
// follows the global scheme allowlist" internally, so cleanly restricting only img
|
||||
// src to data: URIs while leaving other elements' href/src alone isn't something the
|
||||
// policy API exposes directly. A dedicated pass keeps the two concerns (XSS
|
||||
// sanitization vs. privacy-motivated image blocking) independent and easy to reason
|
||||
// about separately.
|
||||
func stripRemoteImages(sanitizedHTML string) (cleaned string, blocked bool) {
|
||||
doc, err := html.Parse(strings.NewReader(sanitizedHTML))
|
||||
if err != nil {
|
||||
return sanitizedHTML, false
|
||||
}
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "img" {
|
||||
for i, attr := range n.Attr {
|
||||
if attr.Key != "src" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(attr.Val, "data:") {
|
||||
break
|
||||
}
|
||||
n.Attr[i].Key = "data-blocked-src"
|
||||
blocked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
if !blocked {
|
||||
return sanitizedHTML, false
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
// html.Parse wraps a fragment in a full document (html>head,body) — render just
|
||||
// the body's children back out, matching what was originally passed in (a
|
||||
// fragment, not a full document).
|
||||
body := findBody(doc)
|
||||
if body == nil {
|
||||
return sanitizedHTML, false
|
||||
}
|
||||
for c := body.FirstChild; c != nil; c = c.NextSibling {
|
||||
if err := html.Render(&buf, c); err != nil {
|
||||
return sanitizedHTML, false
|
||||
}
|
||||
}
|
||||
return buf.String(), true
|
||||
}
|
||||
|
||||
func findBody(n *html.Node) *html.Node {
|
||||
if n.Type == html.ElementNode && n.Data == "body" {
|
||||
return n
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if b := findBody(c); b != nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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…</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…';
|
||||
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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -25,6 +25,7 @@ func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
passkeys, _ := a.DB.ListMailboxWebAuthnCredentials(mbox.ID)
|
||||
passwords, _ := a.DB.ListAppPasswordsForMailbox(mbox.ID)
|
||||
trustedSenders, _ := a.DB.ListTrustedImageSenders(mbox.ID)
|
||||
pctFull := 0.0
|
||||
if mbox.QuotaBytes > 0 {
|
||||
pctFull = float64(mbox.UsedBytes) / float64(mbox.QuotaBytes) * 100
|
||||
@@ -34,10 +35,58 @@ func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
// them explicitly here instead.
|
||||
a.render(w, r, "webmail_account.html", M{
|
||||
"mailbox": mbox, "passkeys": passkeys, "passwords": passwords, "pct_full": pctFull,
|
||||
"flashes": popFlashes(w, r),
|
||||
"trusted_senders": trustedSenders,
|
||||
"flashes": popFlashes(w, r),
|
||||
})
|
||||
}
|
||||
|
||||
// remoteImagesModes are the only valid values for esrv_mailboxes.remote_images_mode
|
||||
// — see its schema.go comment for what each means.
|
||||
var remoteImagesModes = map[string]bool{"ask": true, "trusted": true, "always": true}
|
||||
|
||||
func (a *App) webmailSetRemoteImagesMode(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
mode := r.FormValue("remote_images_mode")
|
||||
if !remoteImagesModes[mode] {
|
||||
setFlash(w, "error", "Invalid setting")
|
||||
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.SetMailboxRemoteImagesMode(mbox.ID, mode); err != nil {
|
||||
setFlash(w, "error", "Could not save preference")
|
||||
} else {
|
||||
setFlash(w, "success", "Preference saved")
|
||||
}
|
||||
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) webmailAddTrustedImageSender(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
if email == "" {
|
||||
setFlash(w, "error", "Enter an email address")
|
||||
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := a.DB.AddTrustedImageSender(mbox.ID, email); err != nil {
|
||||
setFlash(w, "error", "Error adding sender")
|
||||
} else {
|
||||
setFlash(w, "success", email+" will now show images automatically")
|
||||
}
|
||||
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) webmailRemoveTrustedImageSender(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
id := int64(atoi(r.PathValue("id")))
|
||||
if err := a.DB.RemoveTrustedImageSender(id, mbox.ID); err != nil {
|
||||
setFlash(w, "error", "Error removing sender")
|
||||
} else {
|
||||
setFlash(w, "success", "Sender removed")
|
||||
}
|
||||
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
|
||||
}
|
||||
|
||||
// webmailSetGroupMessages toggles the "group similar subjects" folder-view preference
|
||||
// (see renderFolderOrSearch) — off by default, per-mailbox, purely a display choice.
|
||||
func (a *App) webmailSetGroupMessages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/mailview"
|
||||
)
|
||||
|
||||
func TestInlineContentIDImagesReplacesWithDataURI(t *testing.T) {
|
||||
html := `<p><img src="cid:img1@example.com"></p>`
|
||||
attachments := []mailview.Attachment{
|
||||
{Filename: "header.png", ContentType: "image/png", Data: []byte("fake-png-bytes"), ContentID: "img1@example.com"},
|
||||
}
|
||||
got := inlineContentIDImages(html, attachments)
|
||||
if strings.Contains(got, "cid:") {
|
||||
t.Errorf("cid: reference survived: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "data:image/png;base64,") {
|
||||
t.Errorf("expected a data: URI in %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineContentIDImagesLeavesUnmatchedRefsAlone(t *testing.T) {
|
||||
html := `<p><img src="cid:unknown@example.com"></p>`
|
||||
// No attachment carries this Content-Id — must not touch the src, since
|
||||
// htmlBodyPolicy.Sanitize (run right after this) already strips any src it
|
||||
// doesn't recognize, and that's the correct outcome for a genuinely missing part.
|
||||
got := inlineContentIDImages(html, nil)
|
||||
if got != html {
|
||||
t.Errorf("got %q, want unchanged", got)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -405,9 +409,18 @@ func (a *App) loadMessageForView(w http.ResponseWriter, r *http.Request, mbox *d
|
||||
}
|
||||
|
||||
folders, _ := a.allFoldersFor(mbox.ID)
|
||||
senderEmail := extractAddress(parsed.Header.From)
|
||||
var htmlBody template.HTML
|
||||
imagesBlocked := false
|
||||
if parsed.HTMLBody != "" {
|
||||
htmlBody = template.HTML(htmlBodyPolicy.Sanitize(parsed.HTMLBody))
|
||||
sanitized := htmlBodyPolicy.Sanitize(inlineContentIDImages(parsed.HTMLBody, parsed.Attachments))
|
||||
if a.shouldShowRemoteImages(r, mbox, senderEmail) {
|
||||
htmlBody = template.HTML(sanitized)
|
||||
} else {
|
||||
cleaned, blocked := stripRemoteImages(sanitized)
|
||||
htmlBody = template.HTML(cleaned)
|
||||
imagesBlocked = blocked
|
||||
}
|
||||
}
|
||||
// Whether this message is already somewhere under Trash (literally "Trash", or a
|
||||
// folder that was itself deleted into Trash) — the delete button's wording/action
|
||||
@@ -422,10 +435,66 @@ func (a *App) loadMessageForView(w http.ResponseWriter, r *http.Request, mbox *d
|
||||
return M{
|
||||
"mailbox": mbox, "folders": folders, "active_folder": folder, "under_trash": underTrash,
|
||||
"uid": uid, "parsed": parsed, "html_body": htmlBody, "smime": smimeStatus, "pgp": pgpStatus,
|
||||
"images_blocked": imagesBlocked, "sender_email": senderEmail,
|
||||
"message_url": MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10),
|
||||
}, true
|
||||
}
|
||||
|
||||
// extractAddress pulls the bare address out of a "Name <addr@example.com>" or plain
|
||||
// "addr@example.com" header value — returns "" if it doesn't parse, rather than
|
||||
// falling back to the raw string, since callers use this for exact-match lookups
|
||||
// (trusted-sender list, the mark-as-junk filter rule) where a malformed value would
|
||||
// otherwise silently create a useless rule/list entry.
|
||||
func extractAddress(raw string) string {
|
||||
addr, err := mail.ParseAddress(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(addr.Address)
|
||||
}
|
||||
|
||||
// inlineContentIDImages resolves cid: references (RFC 2392 — how an HTML email body
|
||||
// points at an image carried as a sibling MIME part rather than a remote URL, e.g.
|
||||
// <img src="cid:abc123@domain">) to a data: URI embedding that attachment's own
|
||||
// bytes. Without this, htmlBodyPolicy.Sanitize silently drops the src entirely — cid:
|
||||
// isn't an http(s)/data scheme it allows — so the image just never renders and the
|
||||
// same bytes only ever show up in the Attachments list, duplicated and disconnected
|
||||
// from where the sender actually placed them in the body. Attachments without a
|
||||
// Content-Id are untouched; this never affects real download-only attachments.
|
||||
func inlineContentIDImages(html string, attachments []mailview.Attachment) string {
|
||||
for _, att := range attachments {
|
||||
if att.ContentID == "" {
|
||||
continue
|
||||
}
|
||||
dataURI := "data:" + att.ContentType + ";base64," + base64.StdEncoding.EncodeToString(att.Data)
|
||||
html = strings.ReplaceAll(html, "cid:"+att.ContentID, dataURI)
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
// shouldShowRemoteImages decides whether a message's remote images render live or get
|
||||
// stripped (see stripRemoteImages) — "always" mode never blocks; "trusted" mode shows
|
||||
// only for a sender on the mailbox's own trusted list; "ask" (default) mode only shows
|
||||
// when this specific request explicitly asked to reveal them once (?show_images=1 —
|
||||
// see webmailMessagePane/webmailMessageView), which never persists past that one view.
|
||||
func (a *App) shouldShowRemoteImages(r *http.Request, mbox *db.Mailbox, senderEmail string) bool {
|
||||
if r.URL.Query().Get("show_images") == "1" {
|
||||
return true
|
||||
}
|
||||
switch mbox.RemoteImagesMode {
|
||||
case "always":
|
||||
return true
|
||||
case "trusted":
|
||||
if senderEmail == "" {
|
||||
return false
|
||||
}
|
||||
trusted, err := a.DB.IsTrustedImageSender(mbox.ID, senderEmail)
|
||||
return err == nil && trusted
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// webmailMessageView renders one message as its own full page — direct links/
|
||||
// bookmarks still work even though the folder view's reading pane (webmailMessagePane)
|
||||
// is how it's normally opened now.
|
||||
@@ -457,6 +526,49 @@ func (a *App) webmailMessagePane(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, r, "webmail_message_pane.html", data)
|
||||
}
|
||||
|
||||
// webmailAlwaysAllowImages is the "Always show images from this sender" action
|
||||
// offered alongside the per-message "Show images" reveal — adds the message's own
|
||||
// sender to the trusted-image-senders list (see IsTrustedImageSender) and redirects
|
||||
// to the standalone message view with images shown immediately, not just from here on
|
||||
// — a full-page navigation either way (from the pane or the standalone view), same as
|
||||
// the existing Delete/Move/Restore actions already do from the reading pane.
|
||||
func (a *App) webmailAlwaysAllowImages(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
folder := r.PathValue("folder")
|
||||
uid := int64(atoi(r.PathValue("uid")))
|
||||
if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
|
||||
return
|
||||
}
|
||||
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
|
||||
dest := MailboxPrefix + "/mail/" + folder + "/" + strconv.FormatInt(uid, 10) + "?show_images=1"
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error loading message")
|
||||
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
||||
return
|
||||
}
|
||||
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
|
||||
parsed, err := mailview.Parse(unwrapped)
|
||||
senderEmail := ""
|
||||
if err == nil {
|
||||
senderEmail = extractAddress(parsed.Header.From)
|
||||
}
|
||||
if senderEmail == "" {
|
||||
setFlash(w, "error", "Could not determine the sender's address")
|
||||
} else if err := a.DB.AddTrustedImageSender(mbox.ID, senderEmail); err != nil {
|
||||
setFlash(w, "error", "Error adding sender")
|
||||
} else {
|
||||
// The trusted list only actually gets consulted in "trusted" mode (see
|
||||
// shouldShowRemoteImages) — in "ask" mode, adding a sender here would silently
|
||||
// do nothing next time despite the success message below, so upgrade "ask" to
|
||||
// "trusted" here too. Never downgrades "always" (already shows everyone).
|
||||
if mbox.RemoteImagesMode == "ask" {
|
||||
a.DB.SetMailboxRemoteImagesMode(mbox.ID, "trusted")
|
||||
}
|
||||
setFlash(w, "success", "Images from "+senderEmail+" will show automatically from now on")
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusFound)
|
||||
}
|
||||
|
||||
// webmailMessageWithAccess loads a message and 404s if it doesn't exist, isn't in
|
||||
// this mailbox, or isn't in the folder the URL claims — mirrors the admin side's
|
||||
// *WithAccess helpers (mailboxWithAccess etc.): never trust the URL's folder segment
|
||||
@@ -534,6 +646,73 @@ func (a *App) webmailRestoreMessage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, MailboxPrefix+"/mail/Trash", http.StatusFound)
|
||||
}
|
||||
|
||||
// webmailMarkAsJunk moves one message to Junk and, unless one already exists, adds a
|
||||
// filter rule ("from" contains this sender's address -> mark_as_spam) so future mail
|
||||
// from them routes straight to Junk at delivery time (mailstore.ApplyRules) — the
|
||||
// "blacklist" the sender asked for, reusing the existing Rules feature rather than a
|
||||
// separate mechanism: it shows up, and can be removed at any time, from the same
|
||||
// Rules page as everything else.
|
||||
func (a *App) webmailMarkAsJunk(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
folder := r.PathValue("folder")
|
||||
uid := int64(atoi(r.PathValue("uid")))
|
||||
if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
|
||||
return
|
||||
}
|
||||
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
|
||||
if err != nil {
|
||||
setFlash(w, "error", "Error loading message")
|
||||
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
||||
return
|
||||
}
|
||||
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
|
||||
parsed, parseErr := mailview.Parse(unwrapped)
|
||||
|
||||
if err := a.DB.MoveMessage(mbox.ID, uid, "Junk"); err != nil {
|
||||
setFlash(w, "error", "Error marking as junk")
|
||||
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
||||
return
|
||||
}
|
||||
msg := "Message marked as junk"
|
||||
if parseErr == nil {
|
||||
if senderEmail := extractAddress(parsed.Header.From); senderEmail != "" {
|
||||
if added, err := a.ensureJunkRuleForSender(mbox.ID, senderEmail); err != nil {
|
||||
a.Logger.Error("create junk rule for %s, mailbox %d: %v", senderEmail, mbox.ID, err)
|
||||
} else if added {
|
||||
msg = "Message marked as junk — future mail from " + senderEmail + " will go there too (see Rules to undo)"
|
||||
}
|
||||
}
|
||||
}
|
||||
setFlash(w, "success", msg)
|
||||
http.Redirect(w, r, MailboxPrefix+"/mail/"+folder, http.StatusFound)
|
||||
}
|
||||
|
||||
// ensureJunkRuleForSender creates a "from contains <email> -> mark_as_spam" rule
|
||||
// unless a matching one already exists — idempotent, so marking several messages
|
||||
// from the same repeat sender as junk doesn't pile up duplicate rules. Returns
|
||||
// whether a new rule was actually created (false when one already covered it).
|
||||
func (a *App) ensureJunkRuleForSender(mailboxID int64, senderEmail string) (bool, error) {
|
||||
rules, err := a.DB.ListRulesForMailbox(mailboxID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.Action != "mark_as_spam" {
|
||||
continue
|
||||
}
|
||||
conditions, _ := rule.Conditions()
|
||||
for _, c := range conditions {
|
||||
if c.Field == "from" && strings.EqualFold(strings.TrimSpace(c.Value), senderEmail) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := a.DB.CreateRule(mailboxID, 0, "from", "contains", senderEmail, "mark_as_spam", ""); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// webmailMessageMove reassigns a message to a different (existing or freshly named)
|
||||
// folder, e.g. from the message view's "Move to..." control.
|
||||
func (a *App) webmailMessageMove(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -655,6 +834,54 @@ func (a *App) webmailAttachmentDownload(w http.ResponseWriter, r *http.Request)
|
||||
w.Write(att.Data)
|
||||
}
|
||||
|
||||
// webmailDownloadAllAttachments bundles every attachment on one message into a single
|
||||
// ZIP — a stdlib archive/zip, no new dependency — rather than making the user click
|
||||
// each attachment separately.
|
||||
func (a *App) webmailDownloadAllAttachments(w http.ResponseWriter, r *http.Request) {
|
||||
mbox := mailboxFromContext(r)
|
||||
folder := r.PathValue("folder")
|
||||
uid := int64(atoi(r.PathValue("uid")))
|
||||
|
||||
if _, ok := a.webmailMessageWithAccess(w, r, mbox.ID, folder, uid); !ok {
|
||||
return
|
||||
}
|
||||
raw, err := a.Mailstore.FetchMessage(mbox.ID, uid)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
unwrapped, _, _ := a.unwrapCrypto(r, mbox.ID, raw)
|
||||
parsed, err := mailview.Parse(unwrapped)
|
||||
if err != nil || len(parsed.Attachments) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="attachments.zip"`)
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
zw := zip.NewWriter(w)
|
||||
usedNames := map[string]int{}
|
||||
for _, att := range parsed.Attachments {
|
||||
name := att.Filename
|
||||
if name == "" {
|
||||
name = "attachment"
|
||||
}
|
||||
// Two attachments sharing a filename (unusual but not disallowed by any MIME
|
||||
// rule) would otherwise silently overwrite each other inside the zip.
|
||||
if usedNames[name] > 0 {
|
||||
ext := filepath.Ext(name)
|
||||
name = strings.TrimSuffix(name, ext) + fmt.Sprintf(" (%d)", usedNames[name]) + ext
|
||||
}
|
||||
usedNames[att.Filename]++
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
a.Logger.Error("zip attachment %q for message %d, mailbox %d: %v", name, uid, mbox.ID, err)
|
||||
continue
|
||||
}
|
||||
f.Write(att.Data)
|
||||
}
|
||||
zw.Close()
|
||||
}
|
||||
|
||||
const maxFolderNameLen = 60
|
||||
|
||||
// webmailAddFolder creates a new custom folder as a child of the folder the sidebar's
|
||||
|
||||
@@ -141,6 +141,9 @@ func (a *App) Mux() *http.ServeMux {
|
||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mfa-setup", a.webmailMFASetupRequiredPage)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/password", a.webmailChangePassword)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/preferences", a.webmailSetGroupMessages)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/remote-images-mode", a.webmailSetRemoteImagesMode)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/trusted-senders/add", a.webmailAddTrustedImageSender)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/trusted-senders/{id}/remove", a.webmailRemoveTrustedImageSender)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/rebuild-cache", a.webmailRebuildMessageCache)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/setup", a.webmailTOTPSetupBegin)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/account/totp/confirm", a.webmailTOTPSetupConfirm)
|
||||
@@ -164,11 +167,14 @@ func (a *App) Mux() *http.ServeMux {
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/move", a.webmailMessageMove)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/star", a.webmailToggleStar)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/restore", a.webmailRestoreMessage)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/mark-junk", a.webmailMarkAsJunk)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/{uid}/always-allow-images", a.webmailAlwaysAllowImages)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/bulk", a.webmailBulkAction)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/mark-all-read", a.webmailMarkAllRead)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/empty", a.webmailEmptyTrash)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/{folder}/clean-spam", a.webmailCleanSpam)
|
||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachment/{idx}", a.webmailAttachmentDownload)
|
||||
webmailMux.HandleFunc("GET "+MailboxPrefix+"/mail/{folder}/{uid}/attachments.zip", a.webmailDownloadAllAttachments)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/add", a.webmailAddFolder)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/remove", a.webmailDeleteFolder)
|
||||
webmailMux.HandleFunc("POST "+MailboxPrefix+"/mail/folders/{name}/rename", a.webmailRenameFolder)
|
||||
|
||||
Reference in New Issue
Block a user