1 parent
20d78ee7be
commit
c160f25e4c
16 files changed
+252
-44
No files matched your search
+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