/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: Apache-2.0 OR MIT */ use serde_json::{Value, json}; pub fn normalize_message_id(raw: &str) -> String { let t = raw.trim(); let t = t.strip_prefix('<').unwrap_or(t); let t = t.strip_suffix('>').unwrap_or(t); t.trim().to_lowercase() } pub fn fold_name(name: &str) -> String { name.trim().to_lowercase() } pub fn blake3_fields(fields: &[&str]) -> [u8; 32] { let mut hasher = blake3::Hasher::new(); for (i, f) in fields.iter().enumerate() { if i > 0 { hasher.update(b"\x1f"); } hasher.update(f.as_bytes()); } *hasher.finalize().as_bytes() } pub fn blake3_bytes(bytes: &[u8]) -> [u8; 32] { *blake3::hash(bytes).as_bytes() } pub fn identity_key(name: &str, email: &str) -> [u8; 32] { blake3_fields(&[name, &email.to_lowercase()]) } pub fn participant_identity_key(calendar_address: &str, name: &str) -> [u8; 32] { blake3_fields(&[&calendar_address.to_lowercase(), name]) } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum EmailKey { MessageId(String), Fallback([u8; 32]), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct EmailIndex { pub mids: Vec, pub fb: [u8; 32], } fn join_addrs(addrs: &[String]) -> String { addrs .iter() .map(|a| a.to_lowercase()) .collect::>() .join(",") } pub fn email_index( message_ids: &[String], from: &[String], subject: &str, sent_at: &str, to: &[String], ) -> EmailIndex { let mut mids: Vec = message_ids .iter() .map(|m| normalize_message_id(m)) .filter(|s| !s.is_empty()) .collect(); mids.sort(); mids.dedup(); let fb = blake3_fields(&[&join_addrs(from), subject, sent_at, &join_addrs(to)]); EmailIndex { mids, fb } } pub fn index_to_json(idx: &EmailIndex) -> String { json!({ "m": idx.mids, "f": hex(&idx.fb) }).to_string() } pub fn index_from_json(text: &str) -> EmailIndex { let v: Value = serde_json::from_str(text).unwrap_or(Value::Null); let mids = v .get("m") .and_then(Value::as_array) .map(|a| { a.iter() .filter_map(|x| x.as_str().map(str::to_owned)) .collect() }) .unwrap_or_default(); let fb = v .get("f") .and_then(Value::as_str) .and_then(unhex) .unwrap_or([0u8; 32]); EmailIndex { mids, fb } } fn hex(bytes: &[u8; 32]) -> String { let mut s = String::with_capacity(64); for b in bytes { use std::fmt::Write; let _ = write!(s, "{b:02x}"); } s } fn unhex(s: &str) -> Option<[u8; 32]> { if s.len() != 64 { return None; } let mut out = [0u8; 32]; for (i, slot) in out.iter_mut().enumerate() { *slot = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?; } Some(out) } pub fn email_key(idx: &EmailIndex) -> EmailKey { if idx.mids.is_empty() { EmailKey::Fallback(idx.fb) } else { EmailKey::MessageId(idx.mids.join("\x1f")) } } pub fn email_keys(indices: &[EmailIndex]) -> Vec { indices.iter().map(email_key).collect() } #[cfg(test)] mod tests { use super::*; #[test] fn message_id_normalization_strips_brackets_and_lowercases() { assert_eq!(normalize_message_id(" "), "abc@host.com"); assert_eq!(normalize_message_id("plain@x"), "plain@x"); } #[test] fn name_fold_is_case_insensitive() { assert_eq!(fold_name(" Sales "), fold_name("sales")); assert_ne!(fold_name("Sales"), fold_name("sale")); } #[test] fn identity_collapse_ignores_signature() { assert_eq!( identity_key("Alice", "A@x.test"), identity_key("Alice", "a@x.test") ); } #[test] fn participant_identity_key_lowercases_address_only() { let a = participant_identity_key("MailTo:User@Host", "Bob"); let b = participant_identity_key("mailto:user@host", "Bob"); assert_eq!(a, b); assert_ne!(a, participant_identity_key("mailto:user@host", "bob")); } fn idx(mids: &[&str], from: &[&str], subj: &str, sent: &str, to: &[&str]) -> EmailIndex { let m: Vec = mids.iter().map(|s| (*s).to_owned()).collect(); let f: Vec = from.iter().map(|s| (*s).to_owned()).collect(); let t: Vec = to.iter().map(|s| (*s).to_owned()).collect(); email_index(&m, &f, subj, sent, &t) } #[test] fn message_id_array_is_normalized_sorted_and_order_independent() { let a = idx(&["", ""], &[], "", "", &[]); let b = idx(&["", ""], &[], "", "", &[]); assert_eq!(a.mids, vec!["a@h".to_owned(), "b@h".to_owned()]); assert_eq!(a.mids, b.mids); } #[test] fn unique_single_message_id_uses_message_id_key() { let keys = email_keys(&[ idx( &[""], &["a@x"], "Hi", "2020-01-01T00:00:00Z", &["b@y"], ), idx( &[""], &["a@x"], "Yo", "2020-01-02T00:00:00Z", &["b@y"], ), ]); assert_eq!(keys[0], EmailKey::MessageId("m1@h".to_owned())); assert_eq!(keys[1], EmailKey::MessageId("m2@h".to_owned())); } #[test] fn shared_message_id_matches_regardless_of_other_fields() { let keys = email_keys(&[ idx( &[""], &["a@x"], "S", "2020-01-01T00:00:00Z", &["b@y"], ), idx( &[""], &["c@x"], "T", "2020-01-02T00:00:00Z", &["d@y"], ), ]); assert_eq!(keys[0], EmailKey::MessageId("dup@h".to_owned())); assert_eq!(keys[0], keys[1], "same Message-ID is the same message"); } #[test] fn only_absent_message_id_falls_back_multi_is_still_message_id() { let keys = email_keys(&[ idx(&[], &["a@x"], "S", "2020-01-01T00:00:00Z", &["b@y"]), idx( &["", ""], &["a@x"], "S", "2020-01-01T00:00:00Z", &["b@y"], ), ]); assert!(matches!(keys[0], EmailKey::Fallback(_))); assert_eq!(keys[1], EmailKey::MessageId("a@h\u{1f}b@h".to_owned())); } #[test] fn fallback_is_case_insensitive_on_addresses_and_offset_preserving_on_sent_at() { let a = idx(&[], &["A@X"], "Subj", "2020-01-01T10:00:00+02:00", &["B@Y"]); let b = idx(&[], &["a@x"], "Subj", "2020-01-01T10:00:00+02:00", &["b@y"]); let c = idx(&[], &["a@x"], "Subj", "2020-01-01T08:00:00Z", &["b@y"]); assert_eq!(a.fb, b.fb); assert_ne!(a.fb, c.fb); } #[test] fn index_json_roundtrips() { let i = idx(&[""], &["a@x"], "S", "2020-01-01T00:00:00Z", &["b@y"]); let j = index_to_json(&i); let back = index_from_json(&j); assert_eq!(i, back); assert_eq!(email_keys(&[i]), email_keys(&[back])); } #[test] fn malformed_stored_json_degrades_to_empty_fallback() { let back = index_from_json("{}"); assert!(back.mids.is_empty()); assert_eq!(back.fb, [0u8; 32]); } }