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
+13 -5
View File
@@ -5,13 +5,13 @@ import (
"errors"
)
const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled, mfa_exempt, group_messages`
const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled, mfa_exempt, group_messages, remote_images_mode`
func scanMailbox(row *sql.Row) (*Mailbox, error) {
var m Mailbox
var createdAt string
var createdBy sql.NullInt64
if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages); err != nil {
if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.RemoteImagesMode); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -31,7 +31,7 @@ type MailboxWithDomain struct {
}
func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, m.mfa_exempt, m.group_messages, dm.domain_name
rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, m.mfa_exempt, m.group_messages, m.remote_images_mode, dm.domain_name
FROM esrv_mailboxes m JOIN esrv_domains dm ON dm.id = m.domain_id ORDER BY m.email`)
if err != nil {
return nil, err
@@ -42,7 +42,7 @@ func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
var m MailboxWithDomain
var createdAt string
var createdBy sql.NullInt64
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.DomainName); err != nil {
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.RemoteImagesMode, &m.DomainName); err != nil {
return nil, err
}
m.CreatedAt, _ = parseTime(createdAt)
@@ -65,7 +65,7 @@ func (d *DB) ListMailboxesForDomain(domainID int64) ([]Mailbox, error) {
var m Mailbox
var createdAt string
var createdBy sql.NullInt64
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages); err != nil {
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.RemoteImagesMode); err != nil {
return nil, err
}
m.CreatedAt, _ = parseTime(createdAt)
@@ -125,6 +125,14 @@ func (d *DB) SetMailboxGroupMessages(id int64, group bool) error {
return err
}
// SetMailboxRemoteImagesMode sets how remote images in HTML mail are handled — mode
// should be "ask", "trusted", or "always" (not enforced here; webmailSetRemoteImagesMode
// validates against that set before calling this).
func (d *DB) SetMailboxRemoteImagesMode(id int64, mode string) error {
_, err := d.Exec(`UPDATE esrv_mailboxes SET remote_images_mode = ? WHERE id = ?`, mode, id)
return err
}
func (d *DB) SetMailboxQuota(id int64, quotaBytes int64) error {
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
return err
+54
View File
@@ -0,0 +1,54 @@
package db
import "strings"
// TrustedImageSender is one sender a mailbox owner has said to always show remote
// images from (esrv_mailboxes.remote_images_mode = "trusted") — see schema.go's
// comment on esrv_mailbox_trusted_image_senders.
type TrustedImageSender struct {
ID int64
Email string
}
// ListTrustedImageSenders returns a mailbox's trusted-sender list, alphabetically.
func (d *DB) ListTrustedImageSenders(mailboxID int64) ([]TrustedImageSender, error) {
rows, err := d.Query(`SELECT id, email FROM esrv_mailbox_trusted_image_senders WHERE mailbox_id = ? ORDER BY email`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TrustedImageSender
for rows.Next() {
var t TrustedImageSender
if err := rows.Scan(&t.ID, &t.Email); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// AddTrustedImageSender is idempotent (INSERT OR IGNORE) — adding an address already
// on the list is a harmless no-op, not an error, since both the settings-page "Add"
// form and the per-message "always allow this sender" checkbox can reach it.
func (d *DB) AddTrustedImageSender(mailboxID int64, email string) error {
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_trusted_image_senders (mailbox_id, email) VALUES (?, ?)`,
mailboxID, strings.ToLower(strings.TrimSpace(email)))
return err
}
func (d *DB) RemoveTrustedImageSender(id, mailboxID int64) error {
_, err := d.Exec(`DELETE FROM esrv_mailbox_trusted_image_senders WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
return err
}
// IsTrustedImageSender reports whether email is on mailboxID's trusted list —
// case-insensitive, since email local-parts/domains are conventionally
// case-insensitive and a header's casing is never guaranteed to match what was typed
// into the settings page.
func (d *DB) IsTrustedImageSender(mailboxID int64, email string) (bool, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_trusted_image_senders WHERE mailbox_id = ? AND email = ?`,
mailboxID, strings.ToLower(strings.TrimSpace(email))).Scan(&n)
return n > 0, err
}
@@ -0,0 +1,83 @@
package db
import (
"database/sql"
"path/filepath"
"testing"
)
// TestCreateRuleMarkAsSpamWorksAfterLegacyCheckMigration reproduces a live bug: a DB
// created before 'mark_as_spam' was added to esrv_mailbox_filter_rules' action CHECK
// constraint kept the old constraint forever, since SQLite can't ALTER a CHECK on an
// existing table — so CreateRule with action "mark_as_spam" (webmail's "Mark as Junk"
// auto-blacklist, see webui.ensureJunkRuleForSender) failed with "CHECK constraint
// failed: action IN ('move_to_folder','delete','mark_read')" on any pre-existing
// installation, confirmed live via the webmail context menu.
func TestCreateRuleMarkAsSpamWorksAfterLegacyCheckMigration(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
raw, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
if _, err := raw.Exec(`
CREATE TABLE esrv_mailboxes (id INTEGER PRIMARY KEY AUTOINCREMENT)
`); err != nil {
t.Fatal(err)
}
if _, err := raw.Exec(`
CREATE TABLE esrv_mailbox_filter_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
priority INTEGER NOT NULL DEFAULT 0,
condition_field TEXT NOT NULL CHECK(condition_field IN ('from','to','subject')),
condition_op TEXT NOT NULL CHECK(condition_op IN ('contains','equals','starts_with')),
condition_value TEXT NOT NULL,
action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read')),
action_value TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
t.Fatal(err)
}
if _, err := raw.Exec(`INSERT INTO esrv_mailboxes (id) VALUES (1)`); err != nil {
t.Fatal(err)
}
if _, err := raw.Exec(`
INSERT INTO esrv_mailbox_filter_rules (mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value)
VALUES (1, 0, 'subject', 'contains', 'existing-rule', 'move_to_folder', 'Archive')
`); err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
database, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close() })
if _, err := database.CreateRule(1, 0, "from", "contains", "spammer@example.com", "mark_as_spam", ""); err != nil {
t.Fatalf("CreateRule with mark_as_spam after migrating a legacy DB: %v", err)
}
rules, err := database.ListRulesForMailbox(1)
if err != nil {
t.Fatal(err)
}
if len(rules) != 2 {
t.Fatalf("expected the pre-existing rule to survive the table rebuild alongside the new one, got %d rules", len(rules))
}
found := false
for _, r := range rules {
if r.ConditionValue == "existing-rule" && r.ActionValue == "Archive" {
found = true
}
}
if !found {
t.Fatalf("pre-existing rule's data was not preserved across the migration: %+v", rules)
}
}
+3
View File
@@ -28,6 +28,9 @@ type Mailbox struct {
// GroupMessages collapses a run of same-subject messages in a folder view into one
// expandable row when true. Off by default — a display preference, not a policy.
GroupMessages bool
// RemoteImagesMode is "ask" (default), "trusted", or "always" — see the
// remote_images_mode column comment in schema.go.
RemoteImagesMode string
}
// MailboxSession is a self-service webmail portal login — a parallel schema to
+55 -6
View File
@@ -6,6 +6,7 @@ package db
import (
"database/sql"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
@@ -211,7 +212,15 @@ CREATE TABLE IF NOT EXISTS esrv_mailboxes (
mfa_exempt INTEGER NOT NULL DEFAULT 0,
-- Off by default: collapse a run of same-subject messages in a folder view into one
-- expandable row. Per-mailbox, not global, since this is purely a display preference.
group_messages INTEGER NOT NULL DEFAULT 0
group_messages INTEGER NOT NULL DEFAULT 0,
-- Remote (http/https) images in an HTML email body are a classic tracking-pixel /
-- read-receipt leak, so they're never auto-loaded — this controls when they show at
-- all: 'ask' (default) strips them and offers a per-message "Show images" reveal;
-- 'trusted' auto-shows only for senders on this mailbox's own
-- esrv_mailbox_trusted_image_senders list; 'always' never blocks (not recommended,
-- offered anyway since it's the mailbox owner's own call). See
-- webmail_mail.go's loadMessageForView / stripRemoteImages.
remote_images_mode TEXT NOT NULL DEFAULT 'ask'
);
-- Self-service webmail portal sessions — deliberately a parallel schema to
@@ -297,6 +306,18 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Senders a mailbox owner has explicitly said to always show remote images from
-- (esrv_mailboxes.remote_images_mode = 'trusted') — added either from the account
-- settings page or via the "always allow images from this sender" checkbox offered
-- alongside the per-message "Show images" reveal.
CREATE TABLE IF NOT EXISTS esrv_mailbox_trusted_image_senders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
email TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(mailbox_id, email)
);
-- One row per stored message. cached_from/cached_subject are deliberately plaintext
-- (a narrow, confirmed exception to "encrypted at rest") so IMAP LIST/basic SEARCH
-- don't need to decrypt every message in a folder; body and every other header stay
@@ -485,11 +506,9 @@ func migrateAddedColumns(db *sql.DB) {
`ALTER TABLE esrv_domains ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE esrv_mailboxes ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE esrv_mailbox_messages ADD COLUMN cached_to TEXT NOT NULL DEFAULT ''`,
// conditions_json/match_type are retrofittable via ALTER TABLE, but the action
// CHECK constraint (adding 'mark_as_spam') is not — SQLite doesn't support
// altering a CHECK on an existing table. A dev DB created before this change
// would need recreating to accept a mark_as_spam rule; a fresh install gets it
// for free from the CREATE TABLE above.
// The action CHECK constraint (adding 'mark_as_spam') isn't retrofittable via
// ALTER TABLE — see migrateFilterRulesMarkAsSpamCheck below, called at the end
// of this function, which rebuilds the table for DBs that predate it.
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN conditions_json TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN match_type TEXT NOT NULL DEFAULT 'all'`,
// key_pem replaces the old passphrase-wrapped key_ciphertext/key_nonce/key_salt
@@ -505,6 +524,7 @@ func migrateAddedColumns(db *sql.DB) {
`ALTER TABLE esrv_mailbox_folders ADD COLUMN restore_parent_id INTEGER REFERENCES esrv_mailbox_folders(id)`,
`ALTER TABLE esrv_mailbox_folders ADD COLUMN restore_parent_root TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE esrv_mailbox_messages ADD COLUMN restore_folder TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE esrv_mailboxes ADD COLUMN remote_images_mode TEXT NOT NULL DEFAULT 'ask'`,
}
// The three old columns above were NOT NULL with no default, so simply adding
// key_pem left them behind still blocking every new insert (which only ever sets
@@ -523,6 +543,35 @@ func migrateAddedColumns(db *sql.DB) {
// account skip its username change entirely once it re-hits /first-login next.
db.Exec(`UPDATE esrv_admin_users SET must_change_username = 1 WHERE username = ? AND must_change_password = 1`, DefaultAdminUsername)
migrateSpamRenamedToJunk(db)
migrateFilterRulesMarkAsSpamCheck(db)
}
// migrateFilterRulesMarkAsSpamCheck rebuilds esrv_mailbox_filter_rules for any DB
// created before 'mark_as_spam' was added to the action CHECK constraint (webmail's
// "Mark as Junk" auto-blacklist rule, see webmail_mail.go's ensureJunkRuleForSender) —
// SQLite can't ALTER a CHECK constraint on an existing table, so the only way to widen
// it is to recreate the table under the current schema and copy the rows across.
// Detects the stale constraint by inspecting sqlite_master rather than tracking a
// schema-version number, so it stays a no-op forever once a DB is caught up.
func migrateFilterRulesMarkAsSpamCheck(db *sql.DB) {
var tableSQL string
if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'esrv_mailbox_filter_rules'`).Scan(&tableSQL); err != nil {
return
}
if strings.Contains(tableSQL, "mark_as_spam") {
return
}
if _, err := db.Exec(`ALTER TABLE esrv_mailbox_filter_rules RENAME TO esrv_mailbox_filter_rules_old`); err != nil {
return
}
if _, err := db.Exec(schema); err != nil {
return
}
db.Exec(`INSERT INTO esrv_mailbox_filter_rules
(id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, conditions_json, match_type, created_at)
SELECT id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, conditions_json, match_type, created_at
FROM esrv_mailbox_filter_rules_old`)
db.Exec(`DROP TABLE esrv_mailbox_filter_rules_old`)
}
// migrateSpamRenamedToJunk renames the standard "Spam" folder to "Junk" for mailboxes
+15 -6
View File
@@ -30,6 +30,11 @@ type Attachment struct {
Filename string
ContentType string
Data []byte
// ContentID is the part's Content-Id header (RFC 2392), angle brackets stripped,
// or "" if absent. An HTML body referencing this part inline (<img src="cid:...">)
// only renders once that reference is resolved against this — see
// webui.inlineContentIDImages, the caller responsible for doing so.
ContentID string
}
// Message is the parsed result. TextBody/HTMLBody are independently populated when
@@ -114,11 +119,12 @@ func walkMultipart(m *Message, r io.Reader, boundary string) error {
if filename == "" {
filename = params["name"]
}
contentID := strings.Trim(part.Header.Get("Content-Id"), "<>")
switch {
case disp == "attachment" || (filename != "" && disp != "inline"):
m.Attachments = append(m.Attachments, Attachment{
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data,
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data, ContentID: contentID,
})
case mediaType == "text/html":
m.HTMLBody += string(data)
@@ -127,12 +133,15 @@ func walkMultipart(m *Message, r io.Reader, boundary string) error {
m.TextBody += "\n"
}
m.TextBody += string(data)
case filename != "":
// Inline non-text part (e.g. an embedded image) with no explicit
// disposition — still worth surfacing as a downloadable attachment
// rather than silently dropping it.
case filename != "" || contentID != "":
// Inline non-text part (e.g. an embedded image referenced by the HTML
// body via cid:) with no explicit disposition and possibly no filename
// either — still worth surfacing rather than silently dropping it.
if filename == "" {
filename = contentID
}
m.Attachments = append(m.Attachments, Attachment{
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data,
Filename: filename, ContentType: contentTypeFor(mediaType, filename), Data: data, ContentID: contentID,
})
}
}
+46
View File
@@ -89,3 +89,49 @@ func TestParseNestedMultipartMixedWithAlternativeBody(t *testing.T) {
t.Fatalf("attachments = %+v", m.Attachments)
}
}
func TestParseInlineImageCapturesContentID(t *testing.T) {
raw := "" +
"From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" +
"Content-Type: multipart/related; boundary=\"B\"\r\n\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<p><img src=\"cid:img1@example.com\"></p>\r\n" +
"--B\r\nContent-Type: image/png\r\nContent-Disposition: inline; filename=\"header.png\"\r\n" +
"Content-Id: <img1@example.com>\r\n" +
"Content-Transfer-Encoding: BASE64\r\n\r\nSGVsbG8sIHdvcmxkIQ==\r\n" +
"--B--\r\n"
m, err := Parse([]byte(raw))
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(m.HTMLBody) != `<p><img src="cid:img1@example.com"></p>` {
t.Errorf("HTMLBody = %q", m.HTMLBody)
}
if len(m.Attachments) != 1 {
t.Fatalf("attachments = %+v", m.Attachments)
}
if m.Attachments[0].ContentID != "img1@example.com" {
t.Errorf("ContentID = %q, want angle brackets stripped", m.Attachments[0].ContentID)
}
if got := string(m.Attachments[0].Data); got != "Hello, world!" {
t.Errorf("attachment data = %q, want decoded base64", got)
}
}
func TestParseInlineImageWithNoFilenameStillCaptured(t *testing.T) {
raw := "" +
"From: a@example.com\r\nTo: b@example.com\r\nSubject: hi\r\n" +
"Content-Type: multipart/related; boundary=\"B\"\r\n\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<p>hi</p>\r\n" +
"--B\r\nContent-Type: image/png\r\n" +
"Content-Id: <noname@example.com>\r\n\r\nrawbytes" +
"\r\n--B--\r\n"
m, err := Parse([]byte(raw))
if err != nil {
t.Fatal(err)
}
// No filename anywhere on the part — must not be silently dropped just because
// there's nothing to name it; falls back to the Content-Id itself.
if len(m.Attachments) != 1 || m.Attachments[0].ContentID != "noname@example.com" {
t.Fatalf("attachments = %+v", m.Attachments)
}
}
+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)