From 15678c1b6ebc743f82f2af8710b5d323e8aed401 Mon Sep 17 00:00:00 2001 From: nahakubuilder Date: Sat, 15 Aug 2026 18:38:11 +0100 Subject: [PATCH] fixing folders and image rendering in client --- internal/db/crud_mailboxes.go | 18 +- internal/db/crud_trusted_image_senders.go | 54 +++++ ...ilter_rules_mark_as_spam_migration_test.go | 83 +++++++ internal/db/mailbox_models.go | 3 + internal/db/schema.go | 61 ++++- internal/mailview/mailview.go | 21 +- internal/mailview/mailview_test.go | 46 ++++ internal/webui/remote_images.go | 79 ++++++ internal/webui/templates/webmail_account.html | 46 ++++ internal/webui/templates/webmail_folder.html | 176 +++++++++++++- internal/webui/templates/webmail_message.html | 26 +- .../webui/templates/webmail_message_pane.html | 23 +- internal/webui/webmail_account.go | 51 +++- internal/webui/webmail_inline_images_test.go | 33 +++ internal/webui/webmail_mail.go | 229 +++++++++++++++++- internal/webui/webui.go | 6 + 16 files changed, 929 insertions(+), 26 deletions(-) create mode 100644 internal/db/crud_trusted_image_senders.go create mode 100644 internal/db/filter_rules_mark_as_spam_migration_test.go create mode 100644 internal/webui/remote_images.go create mode 100644 internal/webui/webmail_inline_images_test.go diff --git a/internal/db/crud_mailboxes.go b/internal/db/crud_mailboxes.go index f19156c..4ae82d9 100644 --- a/internal/db/crud_mailboxes.go +++ b/internal/db/crud_mailboxes.go @@ -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 diff --git a/internal/db/crud_trusted_image_senders.go b/internal/db/crud_trusted_image_senders.go new file mode 100644 index 0000000..f228a7a --- /dev/null +++ b/internal/db/crud_trusted_image_senders.go @@ -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 +} diff --git a/internal/db/filter_rules_mark_as_spam_migration_test.go b/internal/db/filter_rules_mark_as_spam_migration_test.go new file mode 100644 index 0000000..b2ef634 --- /dev/null +++ b/internal/db/filter_rules_mark_as_spam_migration_test.go @@ -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) + } +} diff --git a/internal/db/mailbox_models.go b/internal/db/mailbox_models.go index 3d255ee..3f8b6bd 100644 --- a/internal/db/mailbox_models.go +++ b/internal/db/mailbox_models.go @@ -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 diff --git a/internal/db/schema.go b/internal/db/schema.go index f3af05b..393a5e3 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -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 diff --git a/internal/mailview/mailview.go b/internal/mailview/mailview.go index 70bee41..c5c4a81 100644 --- a/internal/mailview/mailview.go +++ b/internal/mailview/mailview.go @@ -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 () + // 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, }) } } diff --git a/internal/mailview/mailview_test.go b/internal/mailview/mailview_test.go index 1499827..e47c2f0 100644 --- a/internal/mailview/mailview_test.go +++ b/internal/mailview/mailview_test.go @@ -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

\r\n" + + "--B\r\nContent-Type: image/png\r\nContent-Disposition: inline; filename=\"header.png\"\r\n" + + "Content-Id: \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) != `

` { + 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

hi

\r\n" + + "--B\r\nContent-Type: image/png\r\n" + + "Content-Id: \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) + } +} diff --git a/internal/webui/remote_images.go b/internal/webui/remote_images.go new file mode 100644 index 0000000..7191bab --- /dev/null +++ b/internal/webui/remote_images.go @@ -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 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 +} diff --git a/internal/webui/templates/webmail_account.html b/internal/webui/templates/webmail_account.html index 71a2c27..e592fb7 100644 --- a/internal/webui/templates/webmail_account.html +++ b/internal/webui/templates/webmail_account.html @@ -78,6 +78,52 @@ +
+
Remote Images
+
+
HTML emails can embed images loaded from the sender's own server — a classic tracking pixel. Choose how those are handled.
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
Trusted senders
+
+
+ +
+
+ +
+
+ {{if .trusted_senders}} +
    + {{range .trusted_senders}} +
  • + {{.Email}} +
    + +
    +
  • + {{end}} +
+ {{else}} +

No trusted senders yet.

+ {{end}} +
+
+
Change Password
diff --git a/internal/webui/templates/webmail_folder.html b/internal/webui/templates/webmail_folder.html index e244a17..5af3602 100644 --- a/internal/webui/templates/webmail_folder.html +++ b/internal/webui/templates/webmail_folder.html @@ -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; } @@ -219,6 +224,40 @@ + {{/* 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. */}} + + + +
+ + +