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
+79
View File
@@ -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">
+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}}
+50 -1
View File
@@ -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)
}
}
+228 -1
View File
@@ -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
+6
View File
@@ -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)