From c953171572f74ca6dc3156e640e4e6e0350b737a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Wed, 4 Mar 2026 11:19:05 +0100 Subject: [PATCH] decode envelope subject and addresses --- Cargo.lock | 136 +++++++++++++++++++++++++++- Cargo.toml | 1 + src/imap/envelope/command/get.rs | 4 +- src/imap/envelope/command/list.rs | 82 ++++++++++++----- src/imap/envelope/command/thread.rs | 6 +- 5 files changed, 198 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 723c9459..168188ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "0.6.21" @@ -76,6 +82,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "ariadne" version = "0.2.0" @@ -189,6 +204,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64", + "encoding_rs", +] + [[package]] name = "chrono" version = "0.4.44" @@ -198,6 +223,20 @@ dependencies = [ "num-traits", ] +[[package]] +name = "chumsky" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba4a05c9ce83b07de31b31c874e87c069881ac4355db9e752e3a55c11ec75a6" +dependencies = [ + "hashbrown 0.15.5", + "regex-automata 0.3.9", + "serde", + "stacker", + "unicode-ident", + "unicode-segmentation", +] + [[package]] name = "clap" version = "4.5.60" @@ -414,6 +453,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_filter" version = "1.0.0" @@ -556,6 +604,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -593,6 +643,7 @@ dependencies = [ "log", "native-tls", "pimalaya-toolbox", + "rfc2047-decoder", "rustls", "rustls-platform-verifier", "secrecy", @@ -1021,6 +1072,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1191,6 +1251,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quote" version = "1.0.44" @@ -1200,6 +1270,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -1270,8 +1346,19 @@ checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.14", + "regex-syntax 0.8.10", +] + +[[package]] +name = "regex-automata" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.7.5", ] [[package]] @@ -1282,15 +1369,35 @@ checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.10", ] +[[package]] +name = "regex-syntax" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" + [[package]] name = "regex-syntax" version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rfc2047-decoder" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504ea008279c473dfeafce327fa26bf8825463cfcc06ef82f866187e81334d16" +dependencies = [ + "base64", + "charset", + "chumsky", + "memchr", + "quoted_printable", + "thiserror 2.0.18", +] + [[package]] name = "ring" version = "0.17.14" @@ -1587,6 +1694,20 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.52.0", + "windows-sys 0.59.0", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2010,6 +2131,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index a0d1e768..b55d4522 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ io-imap = { version = "0.0.1", default-features = false, optional = true } io-process = { version = "0.0.2", default-features = false } io-stream = { version = "0.0.2", default-features = false, features = ["std"] } log = "0.4" +rfc2047-decoder = "1" native-tls = { version = "0.2", optional = true } pimalaya-toolbox = { version = "0.0.4", default-features = false, features = ["config", "terminal", "secret", "command"] } rustls = { version = "0.23", default-features = false, optional = true } diff --git a/src/imap/envelope/command/get.rs b/src/imap/envelope/command/get.rs index 00cd267c..42062d2a 100644 --- a/src/imap/envelope/command/get.rs +++ b/src/imap/envelope/command/get.rs @@ -17,7 +17,7 @@ use serde::{Serialize, Serializer}; use crate::{ config::ImapConfig, imap::{ - envelope::command::list::format_addresses, + envelope::command::list::{decode_mime, format_addresses}, mailbox::arg::name::MailboxNameOptionalFlag, stream, }, @@ -125,7 +125,7 @@ impl EnvelopeDetailTable { detail.date = String::from_utf8_lossy(d.as_ref()).to_string(); } if let Some(s) = &env.subject.0 { - detail.subject = String::from_utf8_lossy(s.as_ref()).to_string(); + detail.subject = decode_mime(&String::from_utf8_lossy(s.as_ref())); } if let Some(m) = &env.message_id.0 { detail.message_id = String::from_utf8_lossy(m.as_ref()).to_string(); diff --git a/src/imap/envelope/command/list.rs b/src/imap/envelope/command/list.rs index 2f8467c8..c53b639d 100644 --- a/src/imap/envelope/command/list.rs +++ b/src/imap/envelope/command/list.rs @@ -7,16 +7,31 @@ use io_imap::{ coroutines::{fetch::*, select::*}, types::{ core::Vec1, + envelope::Address, fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName}, sequence::SequenceSet, }, }; use io_stream::runtimes::std::handle; +use log::debug; use pimalaya_toolbox::terminal::printer::Printer; +use rfc2047_decoder::{Decoder, RecoverStrategy}; use serde::{Serialize, Serializer}; use crate::{config::ImapConfig, imap::mailbox::arg::name::MailboxNameOptionalArg, imap::stream}; +/// Decode RFC 2047 MIME-encoded string, falling back to original on error. +pub fn decode_mime(s: &str) -> String { + let decoder = Decoder::new().too_long_encoded_word_strategy(RecoverStrategy::Decode); + match decoder.decode(s.as_bytes()) { + Ok(s) => s, + Err(err) => { + debug!("cannot decode rfc2047 string `{s}`: {err}"); + s.to_string() + } + } +} + /// List message envelopes in a mailbox. /// /// This command displays envelopes for messages in the specified @@ -58,9 +73,8 @@ impl ListEnvelopesCommand { let sequence_set: SequenceSet = self.sequence.parse()?; // FETCH envelopes - let item_names = MacroOrMessageDataItemNames::MessageDataItemNames(vec![ - MessageDataItemName::Envelope, - ]); + let item_names = + MacroOrMessageDataItemNames::MessageDataItemNames(vec![MessageDataItemName::Envelope]); let mut arg = None; let mut coroutine = ImapFetch::new(context, sequence_set, item_names, self.uid); @@ -119,9 +133,9 @@ impl EnvelopesTable { date = String::from_utf8_lossy(d.as_ref()).to_string(); } if let Some(s) = &env.subject.0 { - subject = String::from_utf8_lossy(s.as_ref()).to_string(); + subject = decode_mime(String::from_utf8_lossy(s.as_ref()).as_ref()); } - from = format_addresses(&env.from); + from = format_addresses_short(&env.from); } _ => {} } @@ -138,10 +152,7 @@ impl EnvelopesTable { entries.sort_by_key(|e| e.id); - Self { - entries, - uid_mode, - } + Self { entries, uid_mode } } } @@ -184,10 +195,8 @@ impl Serialize for EnvelopesTable { } } -use io_imap::types::envelope::Address; - -pub fn format_address(addr: &Address<'_>) -> String { - // NString wraps Option, access via .0 +/// Format email address from mailbox and host parts. +fn format_email(addr: &Address<'_>) -> String { let mailbox = addr .mailbox .0 @@ -200,24 +209,49 @@ pub fn format_address(addr: &Address<'_>) -> String { .as_ref() .map(|h| String::from_utf8_lossy(h.as_ref()).to_string()) .unwrap_or_default(); - let name = addr - .name - .0 - .as_ref() - .map(|n| String::from_utf8_lossy(n.as_ref()).to_string()); - let email = if !mailbox.is_empty() && !host.is_empty() { + if !mailbox.is_empty() && !host.is_empty() { format!("{mailbox}@{host}") } else { mailbox - }; - - match name { - Some(n) if !n.is_empty() => format!("{n} <{email}>"), - _ => email, } } +/// Short format for list view (name OR email, not both). +pub fn format_address_short(addr: &Address<'_>) -> String { + // If name exists, show decoded name only + if let Some(n) = &addr.name.0 { + let name = decode_mime(&String::from_utf8_lossy(n.as_ref())); + if !name.is_empty() { + return name; + } + } + // Otherwise show email + format_email(addr) +} + +/// Full format for detailed view (Name or email). +pub fn format_address(addr: &Address<'_>) -> String { + let email = format_email(addr); + if let Some(n) = &addr.name.0 { + let name = decode_mime(&String::from_utf8_lossy(n.as_ref())); + if !name.is_empty() { + return format!("{name} <{email}>"); + } + } + email +} + +/// Short addresses formatter for list view. +pub fn format_addresses_short(addrs: &[Address<'_>]) -> String { + addrs + .iter() + .map(format_address_short) + .collect::>() + .join(", ") +} + +/// Full addresses formatter for detailed view. pub fn format_addresses(addrs: &[Address<'_>]) -> String { addrs .iter() diff --git a/src/imap/envelope/command/thread.rs b/src/imap/envelope/command/thread.rs index 688008c1..29e53da9 100644 --- a/src/imap/envelope/command/thread.rs +++ b/src/imap/envelope/command/thread.rs @@ -17,7 +17,9 @@ use serde::{Serialize, Serializer}; use crate::{ config::ImapConfig, imap::{ - envelope::command::search::parse_query, mailbox::arg::name::MailboxNameOptionalArg, stream, + envelope::command::{list::decode_mime, search::parse_query}, + mailbox::arg::name::MailboxNameOptionalArg, + stream, }, }; @@ -188,7 +190,7 @@ fn fetch_subjects( MessageDataItem::Envelope(env) => { // NString wraps Option, access via .0 if let Some(s) = &env.subject.0 { - subject = String::from_utf8_lossy(s.as_ref()).to_string(); + subject = decode_mime(&String::from_utf8_lossy(s.as_ref())); } } _ => {}