v0.2.28
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-06 12:30:57 +01:00
1 parent 20d78ee7be
commit c160f25e4c
16 files changed
+252 -44

No files matched your search

+6
View File
@@ -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.
Generated
+1 -1
View File
@@ -3506,7 +3506,7 @@ dependencies = [
[[package]]
name = "rustbox"
version = "0.2.27"
version = "0.2.28"
dependencies = [
"aes-gcm",
"async-imap",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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.
+8 -3
View File
@@ -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
+8 -6
View File
@@ -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
+8 -2
View File
@@ -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
+3
View File
@@ -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")
+7 -3
View File
@@ -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<String> = parsed.body_text(0).map(|s| s.to_string());
let text_plain: Option<String> = 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<String> = 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
};
+117 -5
View File
@@ -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<Vec<u8>> {
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(&current);
if current_score == 0 {
break;
}
let current_polish = polish_letter_count(&current);
let Some(bytes) = windows_1252_bytes(&current) 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(&current)
&& 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<Vec<u8>> {
@@ -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³");
}
}
+19 -12
View File
@@ -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,
+4 -1
View File
@@ -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",
+4
View File
@@ -233,6 +233,10 @@
aria-label="Open message full screen" aria-pressed="false"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-external-link"></use>
</svg></button>
<button id="thread-mode-btn" class="button quiet hidden" type="button" title="Show single message"
aria-label="Show single message" aria-pressed="true"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-list"></use>
</svg><span id="thread-mode-label">Single message</span></button>
<button id="reply-btn" class="button quiet" type="button"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-reply"></use>
</svg><span data-i18n="reply">Reply</span></button>
+35
View File
@@ -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;
+29 -9
View File
@@ -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");
+1
View File
@@ -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));