diff --git a/CHANGELOG.md b/CHANGELOG.md index db8ef7f..128a96d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ What changed in each RustBox release, written for the people who use and run it. Technical details are in the [documentation](docs/). +## 0.2.28 + +- Replying to a message you sent no longer addresses the reply back to yourself; RustBox chooses the other thread participant(s), including when aliases/identities are used. +- The reader now has a direct toggle between thread view and a single-message view; the choice uses the existing mailbox thread preference. +- Polish text decoding is more tolerant of legacy mail: RFC 2047 ISO-8859-2 subjects and common UTF-8/Windows-1252 or ISO-8859-2/Western-Latin mojibake are repaired in headers, bodies and cached message previews. + ## 0.2.27 - Monochrome mode now creates a custom copy when editing a predefined interface style, just like direct colour changes. diff --git a/Cargo.lock b/Cargo.lock index 4f88e6b..f140d3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3506,7 +3506,7 @@ dependencies = [ [[package]] name = "rustbox" -version = "0.2.27" +version = "0.2.28" dependencies = [ "aes-gcm", "async-imap", diff --git a/Cargo.toml b/Cargo.toml index 8e42e45..0fea221 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustbox" -version = "0.2.27" +version = "0.2.28" edition = "2024" rust-version = "1.99.1" license = "AGPL-3.0-only" diff --git a/README.md b/README.md index ad2af0b..f4f8dbd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustBox 0.2.27 +# RustBox 0.2.28 **Fast, self-hosted webmail written in Rust.** RustBox connects to any IMAP/SMTP server and ships as one executable with the whole interface built in, so there is no Node.js, PHP or separate frontend to deploy. diff --git a/RELEASE-MANIFEST.txt b/RELEASE-MANIFEST.txt index 09748ab..0be2795 100644 --- a/RELEASE-MANIFEST.txt +++ b/RELEASE-MANIFEST.txt @@ -1,4 +1,9 @@ -RustBox 0.2.27 release manifest +RustBox 0.2.28 release manifest + +0.2.28 release changes: +- replies to own sent messages target the other participant(s), with account aliases/identities treated as own addresses +- reader toolbar toggles between threaded and single-message presentation using the persisted mailbox `thread_mode` preference +- legacy Polish text repair covers common UTF-8/Windows-1252 and ISO-8859-2/Western-Latin mojibake in headers, bodies and cached summaries/details; exact ISO-8859-2 folded-subject coverage added 0.2.27 release changes: - predefined styles promote to a custom copy when monochrome mode is changed @@ -126,14 +131,14 @@ Docker examples: Operations/docs: - standalone `scripts/migrate_roundcube.py` imports Roundcube contacts/identities using RustBox config.toml and supports SQLite/MySQL/PostgreSQL targets -- README.md documents 0.2.27 configuration +- README.md documents 0.2.28 configuration - docker/README.md documents Compose variants - docs/STORAGE_SEARCH_OPERATIONS.md documents failure modes, shared-storage requirements and migration behavior - TODO.md leaves live SILO/MinIO/AWS/Ceph and real multi-node acceptance validation explicitly pending Build/release metadata: - `scripts/package-release.sh` runs the full Rust release gate before creating ZIP/SHA256 artifacts -- Cargo.toml version = 0.2.27 +- Cargo.toml version = 0.2.28 - rust-toolchain.toml pins Rust 1.99.1 - Cargo.lock is retained for reproducible application builds; the release gate generates it only when missing - CI and scripts/build-release.sh use the retained Cargo.lock (generating it only if missing), then run locked tests/Clippy/release build diff --git a/RELEASE-VALIDATION.txt b/RELEASE-VALIDATION.txt index 1f669a9..a23e12a 100644 --- a/RELEASE-VALIDATION.txt +++ b/RELEASE-VALIDATION.txt @@ -1,7 +1,7 @@ -RustBox 0.2.27 - final source validation +RustBox 0.2.28 - final source validation -0.2.27 scope: -- monochrome preset promotion, wider subject presentation with tooltip, non-reserving hover actions, reader-only popup embedding, adaptive HTML-mail light/dark inference from the actual reader surface, graphical per-folder IMAP storage usage, plus the 0.2.26 licensing/documentation baseline +0.2.28 scope: +- correct reply recipients for own sent messages in threads, reader thread/single-message toggle, and stronger Polish legacy-encoding/mojibake repair, plus the 0.2.27 mail-list and appearance baseline 0.2.25 scope: - filters: apply now on existing messages (`POST /api/filters/match`, IMAP UID SEARCH bounds + cache), exceptions, new conditions and actions, templates, missing-folder creation, "always move from this sender" @@ -67,15 +67,17 @@ RustBox 0.2.27 - final source validation - Composing shows only compact attachment/message size-limit values on one line - 0.2.9 realtime notification gating, browser shortcut preservation and 300-message defaults remain intact - V047-V052 are consolidated into compatibility baseline V052; V053 adds `skin_depth` and `skin_gradients`; V054 adds the theme schedule and account colour; V055 is next -- version metadata identifies 0.2.27 +- version metadata identifies 0.2.28 Completed in this environment: -- offline release validator: scripts/validate-release-static.py (455/455 checks passed) +- offline release validator: scripts/validate-release-static.py (458/458 checks passed) - browser JavaScript assembled in manifest order passes `node --check` +- reply-recipient helper smoke test covers own-account, alias and normal incoming-message replies +- supplied legacy Polish `.eml` decodes to the expected ISO-8859-2 subject and UTF-8 quoted-printable body in an independent MIME parser check - all JSON files parse successfully - Python helper scripts compile successfully - shell scripts pass `bash -n` -- release metadata consistently identifies 0.2.27 +- release metadata consistently identifies 0.2.28 - V052 baseline consolidation plus the V053 skin-depth migration are validated offline - 0.2.25 filter UI (apply now, run now, always move from sender), context submenus and preset editing exercised in Playwright against a mocked API; the 0.2.24 styles rendered at standard and immersive depth with gradients on and off; earlier: all 37 interface styles rendered in light and dark at the immersive depth against a mocked API (Playwright), with no page errors diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 1757e05..322511d 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,7 +1,13 @@ -# RustBox 0.2.27 - implementation status +# RustBox 0.2.28 - implementation status - Draft saves are realtime-silent: Drafts folder/list updates continue, but saving a draft does not surface as a new-mail notification. -This file tracks the `0.2.27` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. +This file tracks the `0.2.28` source snapshot. Storage/search scope and outstanding acceptance work are tracked in `TODO.md`. A feature is only listed as implemented when working code exists in this source tree; partial items are called out explicitly. + +## 0.2.28 thread replies and legacy Polish mail decoding + +- Reply composition detects all configured account/identity addresses; replying to an own sent message selects the first non-own recipient and Reply all keeps the remaining external participants. +- The reader toolbar toggles the existing `thread_mode` mailbox preference between threaded and flat/single-message presentation. +- Header/body rendering repairs common Polish mojibake while retaining RFC 2047 charset decoding; cached summaries/details are normalized on output so old cached rows benefit without a schema migration. ## 0.2.27 mail-list and appearance polish diff --git a/scripts/validate-release-static.py b/scripts/validate-release-static.py index 5ebfa23..3d694cb 100755 --- a/scripts/validate-release-static.py +++ b/scripts/validate-release-static.py @@ -205,6 +205,9 @@ require('imap_source' in compose_js and 'imap_attachments' in compose_js and 'lo require('Attachment::new_inline_with_name' in drafts_rs and 'Attachment::new_inline_with_name' in read("src/smtp/builder.rs"), "inline MIME parts preserve attachment filenames in saved drafts and sent messages") require('mail_parser::MessageParser::default().parse(synthetic.as_bytes())' in read("src/imap/parse.rs") and 'decode_header_bytes' in read("src/imap/parse.rs"), "RFC 2047 header decoding uses mail-parser and tolerant envelope byte handling") require('encoding_rs::Encoding::for_label' in read("src/imap/parse.rs") and 'decodes_polish_rfc2047_subjects' in read("src/imap/parse.rs"), "Polish UTF-8/ISO-8859-2/Windows-1250 header decoding is covered") +require('repair_common_mojibake' in read("src/imap/parse.rs") and 'Ubezpieczenie_nieruchomo=B6ci' in read("src/imap/parse.rs") and 'objętość 12 m³' in read("src/imap/parse.rs"), "legacy Polish mojibake repair covers the supplied ISO-8859-2 pattern without rewriting normal m³ text") +require('function replyRecipientsForMessage(detail)' in compose_js and '...(state.identities || []).map((identity) => identity.email)' in compose_js and 'const replyRecipients = replyRecipientsForMessage(detail);' in compose_js, "replying to an own sent message can target non-own thread participants and recognizes identities") +require('id="thread-mode-btn"' in index_html and 'function toggleReaderThreadMode()' in reader_js and 'thread_mode: next' in reader_js and '$(("#thread-mode-btn"))' not in bindings_js and '$("#thread-mode-btn").addEventListener("click", toggleReaderThreadMode);' in bindings_js, "reader exposes a persisted threaded/single-message toggle") require('let is_pdf = response_content_type_is(&response, "application/pdf");' in read("src/routes/mod.rs") and 'HeaderValue::from_static("SAMEORIGIN")' in read("src/routes/mod.rs") and "HeaderValue::from_static(\"frame-ancestors 'self'\")" in read("src/routes/mod.rs"), "security headers allow same-origin framing for inline PDF previews only") index_html = read("src/web/index.html") require('class="compose-limit-summary"' in index_html and 'Per-file upload limit set with' not in index_html and 'Hard limit for the complete RFC822/MIME message' not in index_html, "Composing shows compact raw size limits without implementation details") diff --git a/src/imap/client.rs b/src/imap/client.rs index 92b2f29..3570e53 100644 --- a/src/imap/client.rs +++ b/src/imap/client.rs @@ -4,7 +4,7 @@ use futures::StreamExt; use super::connection::map_imap_error; use super::parse::{ decode_header_bytes, flag_to_string, has_attachments, imap_address_to_email, - name_attribute_to_string, + name_attribute_to_string, repair_common_mojibake, }; pub use super::error::ImapError; @@ -596,7 +596,9 @@ impl ImapClient for RealImapClient { ImapError::ProtocolError("failed to parse RFC822 message".to_string()) })?; - let text_plain: Option = parsed.body_text(0).map(|s| s.to_string()); + let text_plain: Option = parsed + .body_text(0) + .map(|s| repair_common_mojibake(s.as_ref())); let has_html_part = parsed.parts.iter().any(|part| { part.content_type() @@ -604,7 +606,9 @@ impl ImapClient for RealImapClient { }); let text_html: Option = if has_html_part { - parsed.body_html(0).map(|s| s.to_string()) + parsed + .body_html(0) + .map(|s| repair_common_mojibake(s.as_ref())) } else { None }; diff --git a/src/imap/parse.rs b/src/imap/parse.rs index 0946e49..17dcaf2 100644 --- a/src/imap/parse.rs +++ b/src/imap/parse.rs @@ -1,5 +1,97 @@ use super::types::EmailAddress; +fn mojibake_score(input: &str) -> usize { + input + .chars() + .map(|ch| match ch { + '\u{fffd}' => 8, + 'Ã' | 'Â' | 'Ä' | 'Å' | 'â' | '±' | 'æ' | 'ê' | '³' | 'ñ' | '¶' | '¿' | '¼' | '¡' + | 'Æ' | 'Ê' | '£' | 'Ñ' | '¦' | '¯' | '¬' | '›' | 'œ' | 'ž' => 1, + _ => 0, + }) + .sum() +} + +fn polish_letter_count(input: &str) -> usize { + input + .chars() + .filter(|ch| "ąćęłńóśźżĄĆĘŁŃÓŚŹŻ".contains(*ch)) + .count() +} + +fn windows_1252_bytes(input: &str) -> Option> { + let (bytes, _, had_errors) = encoding_rs::WINDOWS_1252.encode(input); + (!had_errors).then(|| bytes.into_owned()) +} + +fn looks_like_polish_latin2_mojibake(input: &str) -> bool { + const MARKERS: &str = "±æê³ñ¶¿¼¡ÆÊ£Ñ¦¯¬"; + let marker_count = input.chars().filter(|ch| MARKERS.contains(*ch)).count(); + if marker_count >= 2 { + return true; + } + if marker_count == 0 { + return false; + } + + input + .split(|ch: char| !(ch.is_ascii_alphabetic() || MARKERS.contains(ch))) + .any(|token| { + token.chars().count() >= 4 + && token.chars().filter(|ch| ch.is_ascii_alphabetic()).count() >= 3 + && token.chars().any(|ch| MARKERS.contains(ch)) + }) +} + +/// Repairs the two mojibake forms most often seen in older Polish mail: +/// UTF-8 interpreted as Windows-1252 and ISO-8859-2 interpreted as Western Latin. +/// A candidate is accepted only when it reduces suspicious characters and does +/// not lose Polish letters, which keeps normal Unicode mail unchanged. +pub(crate) fn repair_common_mojibake(input: &str) -> String { + let mut best = input.to_string(); + + for _ in 0..2 { + let current = best.clone(); + let current_score = mojibake_score(¤t); + if current_score == 0 { + break; + } + let current_polish = polish_letter_count(¤t); + let Some(bytes) = windows_1252_bytes(¤t) else { + break; + }; + + let mut candidates = Vec::new(); + if let Ok(candidate) = String::from_utf8(bytes.clone()) { + candidates.push(candidate); + } + if looks_like_polish_latin2_mojibake(¤t) + && let Some(encoding) = encoding_rs::Encoding::for_label(b"iso-8859-2") + { + let (candidate, _, _) = encoding.decode(&bytes); + candidates.push(candidate.into_owned()); + } + + let mut improved = false; + for candidate in candidates { + let candidate_score = mojibake_score(&candidate); + let candidate_polish = polish_letter_count(&candidate); + if candidate_score < mojibake_score(&best) + && candidate_polish >= current_polish + && (candidate_polish > current_polish || candidate_score == 0) + { + best = candidate; + improved = true; + } + } + if !improved { + break; + } + } + + best +} + pub(crate) fn name_attribute_to_string(attr: &async_imap::types::NameAttribute<'_>) -> String { use async_imap::types::NameAttribute; match attr { @@ -35,7 +127,7 @@ pub(crate) fn flag_to_string(flag: &async_imap::types::Flag<'_>) -> String { pub(crate) fn decode_rfc2047(input: &str) -> String { if !input.contains("=?") { - return input.to_string(); + return repair_common_mojibake(input); } // Let mail-parser handle RFC 2047 encoded words first. It supports a wider @@ -47,7 +139,7 @@ pub(crate) fn decode_rfc2047(input: &str) -> String { { let decoded = subject.to_string(); if !decoded.is_empty() && decoded != input { - return decoded; + return repair_common_mojibake(&decoded); } } @@ -101,7 +193,7 @@ pub(crate) fn decode_rfc2047(input: &str) -> String { } result.push_str(remaining); - result + repair_common_mojibake(&result) } pub(crate) fn decode_quoted_printable_header(input: &str) -> Option> { @@ -143,11 +235,11 @@ pub(crate) fn decode_header_bytes(bytes: &[u8]) -> String { { let decoded = subject.to_string(); if !decoded.is_empty() { - return decoded; + return repair_common_mojibake(&decoded); } } let text = String::from_utf8_lossy(bytes); - decode_rfc2047(&text) + repair_common_mojibake(&decode_rfc2047(&text)) } pub(crate) fn imap_address_to_email( @@ -220,5 +312,25 @@ mod tests { decode_rfc2047("=?windows-1250?b?WmG/87PmIGfqnGy5IGphn/E=?="), "Zażółć gęślą jaźń" ); + assert_eq!( + decode_rfc2047( + "=?ISO-8859-2?Q?Ubezpieczenie_nieruchomo=B6ci_-_?= =?ISO-8859-2?Q?bliski_termin_p=B3atno=B6ci_sk=B3adki?=" + ), + "Ubezpieczenie nieruchomości - bliski termin płatności składki" + ); + } + + #[test] + fn repairs_common_polish_mojibake() { + assert_eq!( + repair_common_mojibake("Ubezpieczenie nieruchomo¶ci - bliski termin p³atno¶ci sk³adki"), + "Ubezpieczenie nieruchomości - bliski termin płatności składki" + ); + assert_eq!( + repair_common_mojibake("DzieÅ„ dobry, skÅ‚adka za nieruchomość"), + "Dzień dobry, składka za nieruchomość" + ); + assert_eq!(repair_common_mojibake("Åsa"), "Åsa"); + assert_eq!(repair_common_mojibake("objętość 12 m³"), "objętość 12 m³"); } } diff --git a/src/routes/messages/mod.rs b/src/routes/messages/mod.rs index e0a155b..c9503ba 100644 --- a/src/routes/messages/mod.rs +++ b/src/routes/messages/mod.rs @@ -16,6 +16,7 @@ use crate::db::header_sync::HeaderSync; use crate::email_theme; use crate::error::AppError; use crate::imap::client::{ImapClient, ImapCredentials}; +use crate::imap::parse::repair_common_mojibake; use crate::realtime::events::{EventBus, MailEvent, MessageFlagChange}; use crate::routes::folder_counts; use crate::search::engine::{IndexableMessage, SearchEngine, UserIndex}; @@ -403,15 +404,15 @@ pub async fn list_messages( MessageSummary { uid: m.uid, folder: m.folder, - subject: m.subject, + subject: repair_common_mojibake(&m.subject), from_address: m.from_address, - from_name: m.from_name, + from_name: repair_common_mojibake(&m.from_name), to_addresses: m.to_addresses, date: m.date, flags: m.flags, size: m.size, has_attachments: m.has_attachments, - snippet: m.snippet, + snippet: repair_common_mojibake(&m.snippet), reaction: m.reaction, tags: msg_tags, } @@ -1024,9 +1025,9 @@ pub async fn get_message( let indexable = IndexableMessage { uid: msg.uid, folder: msg.folder, - subject: msg.subject, + subject: repair_common_mojibake(&msg.subject), from_address: msg.from_address, - from_name: msg.from_name, + from_name: repair_common_mojibake(&msg.from_name), to_addresses: msg.to_addresses, body_text: text.clone(), date_epoch: crate::db::messages::parse_date_to_epoch_public(&msg.date), @@ -1049,6 +1050,9 @@ pub async fn get_message( ) }; + let body_html = body_html.map(|html| repair_common_mojibake(&html)); + let body_text = body_text.map(|text| repair_common_mojibake(&text)); + let msg = db::messages::get_single_message(&conn, &folder, uid) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; @@ -1058,7 +1062,7 @@ pub async fn get_message( let parsed = mail_parser::MessageParser::default().parse(raw_headers.as_bytes()); let subject = parsed .as_ref() - .and_then(|p| p.subject().map(|s| s.to_string())) + .and_then(|p| p.subject().map(repair_common_mojibake)) .unwrap_or_default(); let (from_address, from_name) = parsed .as_ref() @@ -1070,7 +1074,10 @@ pub async fn get_message( .as_ref() .map(|s| s.to_string()) .unwrap_or_default(), - a.name.as_ref().map(|s| s.to_string()).unwrap_or_default(), + a.name + .as_ref() + .map(|s| repair_common_mojibake(s.as_ref())) + .unwrap_or_default(), ) }), _ => None, @@ -1145,16 +1152,16 @@ pub async fn get_message( folder: m.folder, message_id: m.message_id, in_reply_to: m.in_reply_to, - subject: m.subject, + subject: repair_common_mojibake(&m.subject), from_address: m.from_address, - from_name: m.from_name, + from_name: repair_common_mojibake(&m.from_name), to_addresses: m.to_addresses, cc_addresses: m.cc_addresses, date: m.date, flags: m.flags, size: m.size, has_attachments: m.has_attachments, - snippet: m.snippet, + snippet: repair_common_mojibake(&m.snippet), }) .collect(); @@ -1164,9 +1171,9 @@ pub async fn get_message( message_id: msg.message_id.clone(), in_reply_to: msg.in_reply_to.clone(), references: msg.references_header.clone(), - subject: msg.subject, + subject: repair_common_mojibake(&msg.subject), from_address: msg.from_address, - from_name: msg.from_name, + from_name: repair_common_mojibake(&msg.from_name), to_addresses: parse_address_list(&msg.to_addresses), cc_addresses: parse_address_list(&msg.cc_addresses), date: msg.date, diff --git a/src/web/i18n/pl.json b/src/web/i18n/pl.json index 98a43f8..174fec4 100644 --- a/src/web/i18n/pl.json +++ b/src/web/i18n/pl.json @@ -211,7 +211,7 @@ "Prefer HTML": "Preferuj HTML", "Threads": "Wątki", "Threaded": "Wątki", - "Flat": "Płasko", + "Flat": "Pojedyncze wiadomości", "Default sort": "Domyślne sortowanie", "Newest first": "Najnowsze najpierw", "Oldest first": "Najstarsze najpierw", @@ -846,6 +846,9 @@ "No message selected": "Nie wybrano wiadomości", "Select a message from the list.": "Wybierz wiadomość z listy.", "Thread": "Wątek", + "Single message": "Pojedyncza wiadomość", + "Show single message": "Pokaż pojedynczą wiadomość", + "Show thread": "Pokaż wątek", "Remote images are blocked.": "Zdalne obrazy są zablokowane.", "Load images": "Wczytaj obrazy", "Trust sender": "Zaufaj nadawcy", diff --git a/src/web/index.html b/src/web/index.html index 36b65ab..8295ce6 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -233,6 +233,10 @@ aria-label="Open message full screen" aria-pressed="false"> + diff --git a/src/web/js/40-messages-reader.js b/src/web/js/40-messages-reader.js index 9d862ef..afeda33 100644 --- a/src/web/js/40-messages-reader.js +++ b/src/web/js/40-messages-reader.js @@ -668,11 +668,46 @@ function scheduleMarkRead(detail) { if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay); } +function updateThreadModeButton(detail) { + const button = $("#thread-mode-btn"); + const label = $("#thread-mode-label"); + if (!button || !label) return; + const hasThread = (detail?.thread || []).length > 1; + const threaded = state.mailPrefs.thread_mode !== "flat"; + button.classList.toggle("hidden", !hasThread); + button.setAttribute("aria-pressed", String(threaded)); + const action = threaded ? "Show single message" : "Show thread"; + button.title = trText(action); + button.setAttribute("aria-label", trText(action)); + label.textContent = trText(threaded ? "Single message" : "Thread"); +} + +async function toggleReaderThreadMode() { + if (!state.currentDetail || (state.currentDetail.thread || []).length <= 1) return; + const previous = state.mailPrefs.thread_mode || "threaded"; + const next = previous === "flat" ? "threaded" : "flat"; + state.mailPrefs.thread_mode = next; + const select = $("#mail-pref-thread-mode"); + if (select) select.value = next; + renderThread(state.currentDetail); + try { + state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ thread_mode: next }) }); + if (select) select.value = state.mailPrefs.thread_mode || next; + renderThread(state.currentDetail); + } catch (error) { + state.mailPrefs.thread_mode = previous; + if (select) select.value = previous; + renderThread(state.currentDetail); + toast(error.message, "error"); + } +} + function renderThread(detail) { const thread = detail.thread || []; const section = $("#thread-section"); const list = $("#thread-list"); list.replaceChildren(); + updateThreadModeButton(detail); const enabled = state.mailPrefs.thread_mode !== "flat"; section.classList.toggle("hidden", !enabled || thread.length <= 1); if (!enabled || thread.length <= 1) return; diff --git a/src/web/js/50-compose.js b/src/web/js/50-compose.js index df84447..940e274 100644 --- a/src/web/js/50-compose.js +++ b/src/web/js/50-compose.js @@ -94,7 +94,32 @@ function quoteTextAsHtml(text) { } function ownAddressSet() { - return new Set(state.accounts.map((account) => String(account.email || "").trim().toLowerCase()).filter(Boolean)); + const addresses = [ + ...(state.accounts || []).map((account) => account.email), + ...(state.identities || []).map((identity) => identity.email), + ]; + return new Set(addresses.map((address) => String(address || "").trim().toLowerCase()).filter(Boolean)); +} + +function replyRecipientsForMessage(detail) { + const own = ownAddressSet(); + const sender = String(detail?.from_address || "").trim(); + const senderKey = sender.toLowerCase(); + const senderIsOwn = senderKey && own.has(senderKey); + const recipients = []; + const seen = new Set(); + + for (const entry of [...(detail?.to_addresses || []), ...(detail?.cc_addresses || [])]) { + const address = String(entry?.address || "").trim(); + const key = address.toLowerCase(); + if (!address || own.has(key) || key === senderKey || seen.has(key)) continue; + seen.add(key); + recipients.push(address); + } + + if (!senderIsOwn && sender) return { to: sender, cc: recipients }; + const [to, ...cc] = recipients; + return { to: to || sender, cc }; } function ensureDraftId() { @@ -1732,14 +1757,9 @@ function openCompose(mode = "new", options = {}) { if (detail && (mode === "reply" || mode === "reply-all")) { state.composeReplyMeta = { inReplyTo: detail.message_id || null, references: buildReferences(detail) }; - $("#compose-to").value = detail.from_address || ""; - if (mode === "reply-all") { - const own = ownAddressSet(); - const cc = [...(detail.to_addresses || []), ...(detail.cc_addresses || [])] - .map((entry) => entry.address || "") - .filter((address) => address && address.toLowerCase() !== String(detail.from_address || "").toLowerCase() && !own.has(address.toLowerCase())); - $("#compose-cc").value = [...new Set(cc)].join(", "); - } + const replyRecipients = replyRecipientsForMessage(detail); + $("#compose-to").value = replyRecipients.to; + if (mode === "reply-all") $("#compose-cc").value = replyRecipients.cc.join(", "); $("#compose-subject").value = /^re:/i.test(detail.subject || "") ? detail.subject : `Re: ${detail.subject || ""}`; const quoteStyle = state.prefs.quoteStyle || "standard"; const quote = quoteStyle === "none" ? null : el("div", "compose-quote"); diff --git a/src/web/js/95-bindings-boot.js b/src/web/js/95-bindings-boot.js index 8382178..1d3c5ad 100644 --- a/src/web/js/95-bindings-boot.js +++ b/src/web/js/95-bindings-boot.js @@ -382,6 +382,7 @@ function bindEvents() { $("#reader-prev").addEventListener("click", () => navigateMessage(-1)); $("#reader-next").addEventListener("click", () => navigateMessage(1)); $("#reader-fullscreen").addEventListener("click", toggleReaderFullscreen); + $("#thread-mode-btn").addEventListener("click", toggleReaderThreadMode); $("#thread-toggle").addEventListener("click", () => { state.threadExpanded = !state.threadExpanded; localStorage.setItem("rustbox.threadExpanded", String(state.threadExpanded));