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

+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));