diff --git a/Cargo.lock b/Cargo.lock index f308024..e18280f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -464,9 +464,9 @@ dependencies = [ [[package]] name = "bitcode" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae9f4d868fa036ee7517b997dc2b6efd383a8b2efdcb4b07058260de060d3ef" +checksum = "18c1406a27371b2f76232a2259df6ab607b91b5a0a7476a7729ff590df5a969a" dependencies = [ "arrayvec 0.7.6", "bitcode_derive", @@ -477,9 +477,9 @@ dependencies = [ [[package]] name = "bitcode_derive" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d0b1506d8de85c2198149fb9f6285e6b662a867f84579af2b3ef067a8842d35" +checksum = "42b6b4cb608b8282dc3b53d0f4c9ab404655d562674c682db7e6c0458cc83c23" dependencies = [ "proc-macro2", "quote", @@ -715,9 +715,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.14" +version = "1.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9" +checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af" dependencies = [ "jobserver", "libc", @@ -1888,9 +1888,9 @@ dependencies = [ [[package]] name = "glam" -version = "0.29.2" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc46dd3ec48fdd8e693a98d2b8bafae273a2d54c1de02a2a7e3d57d501f39677" +checksum = "17fcdf9683c406c2fc4d124afd29c0d595e22210d633cbdb8695ba9935ab1dc6" [[package]] name = "glob" @@ -2089,9 +2089,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" +checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" dependencies = [ "atomic-waker", "bytes 1.10.0", @@ -2448,7 +2448,7 @@ dependencies = [ "bytes 1.10.0", "futures-channel", "futures-util", - "h2 0.4.7", + "h2 0.4.8", "http 1.2.0", "http-body 1.0.1", "httparse", @@ -2916,6 +2916,7 @@ version = "0.6.1" dependencies = [ "itertools 0.14.0", "log", + "mailparse", "pretty_assertions", "rayon", "serde", @@ -3363,9 +3364,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", ] @@ -3471,9 +3472,9 @@ checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" [[package]] name = "native-tls" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dab59f8e050d5df8e4dd87d9206fb6f65a483e20ac9fda365ade4fab353196c" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" dependencies = [ "libc", "log", @@ -3640,9 +3641,9 @@ checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" [[package]] name = "oneshot" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79d72a7c0f743d2ebb0a2ad1d219db75fdc799092ed3a884c9144c42a31225bd" +checksum = "b4ce411919553d3f9fa53a0880544cda985a112117a0444d5ff1e870a893d6ea" [[package]] name = "opaque-debug" @@ -3907,7 +3908,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if 1.0.0", "libc", - "redox_syscall 0.5.8", + "redox_syscall 0.5.9", "smallvec 1.14.0", "windows-targets 0.52.6", ] @@ -4581,9 +4582,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" dependencies = [ "bitflags 2.8.0", ] @@ -4675,7 +4676,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.7", + "h2 0.4.8", "http 1.2.0", "http-body 1.0.1", "http-body-util", @@ -4715,9 +4716,9 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.9" +version = "0.17.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75ec5e92c4d8aede845126adc388046234541629e76029599ed35a003c7ed24" +checksum = "da5349ae27d3887ca812fb375b45a4fbb36d8d12d2df394968cd86e35683fe73" dependencies = [ "cc", "cfg-if 1.0.0", @@ -5777,9 +5778,9 @@ dependencies = [ [[package]] name = "string_cache_codegen" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "244292f3441c89febe5b5bdfbb6863aeaf4f64da810ea3050fd927b27b8d92ce" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" dependencies = [ "phf_generator 0.11.3", "phf_shared 0.11.3", @@ -6050,9 +6051,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.17.0" +version = "3.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a40f762a77d2afa88c2d919489e390a12bdd261ed568e60cfa7e48d4e20f0d33" +checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" dependencies = [ "cfg-if 1.0.0", "fastrand", @@ -6538,7 +6539,7 @@ dependencies = [ "axum", "base64 0.22.1", "bytes 1.10.0", - "h2 0.4.7", + "h2 0.4.8", "http 1.2.0", "http-body 1.0.1", "http-body-util", @@ -6730,9 +6731,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "ubyte" @@ -6783,9 +6784,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" +checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" [[package]] name = "unicode-normalization" @@ -7096,7 +7097,7 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" dependencies = [ - "redox_syscall 0.5.8", + "redox_syscall 0.5.9", "wasite", "web-sys", ] @@ -7342,9 +7343,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59690dea168f2198d1a3b0cac23b8063efcd11012f10ae4698f284808c8ef603" +checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" dependencies = [ "memchr", ] @@ -7517,27 +7518,27 @@ dependencies = [ [[package]] name = "zstd" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcf2b778a664581e31e389454a7072dab1647606d44f7feea22cd5abb9c9f3f9" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.2.1" +version = "7.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a3ab4db68cea366acc5c897c7b4d4d1b8994a9cd6e6f841f8964566a419059" +checksum = "f3051792fbdc2e1e143244dc28c60f73d8470e93f3f9cbd0ead44da5ed802722" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.13+zstd.1.5.6" +version = "2.0.14+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38ff0f21cfee8f97d94cef41359e0c89aa6113028ab0291aa8ca0038995a95aa" +checksum = "8fb060d4926e4ac3a3ad15d864e99ceb5f343c6b34f5bd6d81ae6ed417311be5" dependencies = [ "cc", "pkg-config", diff --git a/notmuch/Cargo.toml b/notmuch/Cargo.toml index a0c9b39..64eeb92 100644 --- a/notmuch/Cargo.toml +++ b/notmuch/Cargo.toml @@ -11,6 +11,7 @@ publish = ["xinu"] [dependencies] log = "0.4.14" +mailparse = "0.16.0" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["unbounded_depth"] } thiserror = "2.0.0" diff --git a/notmuch/src/lib.rs b/notmuch/src/lib.rs index 5763a18..8b08bbb 100644 --- a/notmuch/src/lib.rs +++ b/notmuch/src/lib.rs @@ -207,6 +207,7 @@ //! ``` use std::{ + collections::HashMap, ffi::OsStr, io::{self}, path::{Path, PathBuf}, @@ -459,6 +460,8 @@ pub enum NotmuchError { StringUtf8Error(#[from] std::string::FromUtf8Error), #[error("failed to parse str as int")] ParseIntError(#[from] std::num::ParseIntError), + #[error("failed to parse mail: {0}")] + MailParseError(#[from] mailparse::MailParseError), } #[derive(Default)] @@ -605,6 +608,59 @@ impl Notmuch { Ok(serde_json::from_slice(&res)?) } + #[instrument(skip_all)] + pub fn unread_recipients(&self) -> Result, NotmuchError> { + let slice = self.run_notmuch([ + "show", + "--include-html=false", + "--entire-thread=false", + "--body=false", + "--format=json", + // Arbitrary limit to prevent too much work + "--limit=1000", + "is:unread", + ])?; + // Notmuch returns JSON with invalid unicode. So we lossy convert it to a string here and + // use that for parsing in rust. + let s = String::from_utf8_lossy(&slice); + let mut deserializer = serde_json::Deserializer::from_str(&s); + deserializer.disable_recursion_limit(); + let ts: ThreadSet = serde::de::Deserialize::deserialize(&mut deserializer)?; + deserializer.end()?; + let mut r = HashMap::new(); + for t in ts.0 { + for tn in t.0 { + let Some(msg) = tn.0 else { + continue; + }; + let mut addrs = vec![]; + let hdr = msg.headers.to; + if let Some(to) = hdr { + addrs.push(to); + }; + let hdr = msg.headers.cc; + if let Some(cc) = hdr { + addrs.push(cc); + }; + for recipient in addrs { + mailparse::addrparse(&recipient)? + .into_inner() + .iter() + .for_each(|a| { + let mailparse::MailAddr::Single(si) = a else { + return; + }; + let addr = &si.addr; + if addr == "couchmoney@gmail.com" || addr.ends_with("@xinu.tv") { + *r.entry(addr.clone()).or_default() += 1; + } + }); + } + } + } + Ok(r) + } + fn run_notmuch(&self, args: I) -> Result, NotmuchError> where I: IntoIterator, diff --git a/server/src/lib.rs b/server/src/lib.rs index c53463a..22703eb 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -793,7 +793,17 @@ impl FromStr for Query { if word == "is:unread" { unread_only = true } else if word.starts_with("tag:") { - tags.push(word["tag:".len()..].to_string()); + let t = &word["tag:".len()..]; + // Per-address emails are faked as `tag:@/`, rewrite to `to:` form + if t.starts_with('@') && t.contains('.') { + let t = match t.split_once('/') { + None => format!("to:{t}"), + Some((domain, user)) => format!("to:{user}{domain}"), + }; + remainder.push(t); + } else { + tags.push(t.to_string()); + }; /* } else if word.starts_with("tag:") { diff --git a/server/src/nm.rs b/server/src/nm.rs index d6a5443..0c28ac7 100644 --- a/server/src/nm.rs +++ b/server/src/nm.rs @@ -1,11 +1,7 @@ -use std::{ - collections::HashMap, - fs::File, - hash::{DefaultHasher, Hash, Hasher}, - time::Instant, -}; +use std::{collections::HashMap, fs::File}; use letterbox_notmuch::Notmuch; +use letterbox_shared::compute_color; use log::{error, info, warn}; use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail}; use memmap::MmapOptions; @@ -107,7 +103,6 @@ pub async fn search( #[instrument(name="nm::tags", skip_all, fields(needs_unread=needs_unread))] pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result, ServerError> { - let now = Instant::now(); let unread_msg_cnt: HashMap = if needs_unread { // 10000 is an arbitrary number, if there's more than 10k unread messages, we'll // get an inaccurate count. @@ -123,13 +118,11 @@ pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result, ServerError> { } else { HashMap::new() }; - let tags = nm + let tags: Vec<_> = nm .tags()? .into_iter() .map(|tag| { - let mut hasher = DefaultHasher::new(); - tag.hash(&mut hasher); - let hex = format!("#{:06x}", hasher.finish() % (1 << 24)); + let hex = compute_color(&tag); let unread = if needs_unread { *unread_msg_cnt.get(&tag).unwrap_or(&0) } else { @@ -142,8 +135,24 @@ pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result, ServerError> { unread, } }) + .chain( + nm.unread_recipients()? + .into_iter() + .filter_map(|(name, unread)| { + let Some(idx) = name.find('@') else { + return None; + }; + let name = format!("{}/{}", &name[idx..], &name[..idx]); + let bg_color = compute_color(&name); + Some(Tag { + name, + fg_color: "white".to_string(), + bg_color, + unread, + }) + }), + ) .collect(); - info!("Fetching tags took {} seconds", now.elapsed().as_secs_f32()); Ok(tags) } diff --git a/web/src/view/mod.rs b/web/src/view/mod.rs index 0ee5311..031fabb 100644 --- a/web/src/view/mod.rs +++ b/web/src/view/mod.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::{cmp::Ordering, collections::HashSet}; use chrono::{DateTime, Datelike, Duration, Local, Utc}; use human_format::{Formatter, Scales}; @@ -1006,11 +1006,26 @@ pub fn tags(model: &Model) -> Node { } tag_els } - let unread = model + let mut unread = model .tags .as_ref() .map(|tags| tags.iter().filter(|t| t.unread > 0).collect()) .unwrap_or(Vec::new()); + unread.sort_by(|a, b| { + let r = if a.name.starts_with('@') && b.name.starts_with('@') { + a.name.cmp(&b.name) + } else if a.name.starts_with('@') { + Ordering::Less + } else if b.name.starts_with('@') { + Ordering::Greater + } else { + a.name.cmp(&b.name) + }; + if a.name.starts_with('@') || b.name.starts_with('@') { + info!("a {} < b {} = {r:?}", a.name, b.name,); + } + return r; + }); let tags_open = use_state(|| false); let force_tags_open = unread.is_empty(); aside![