1 parent
20d78ee7be
commit
c160f25e4c
16 files changed
+252
-44
No files matched your search
@@ -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
@@ -3506,7 +3506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustbox"
|
||||
version = "0.2.27"
|
||||
version = "0.2.28"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-imap",
|
||||
|
||||
+1
-1
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(¤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<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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user