Initial commit
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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<String>,
|
||||
|
||||
pub fb: [u8; 32],
|
||||
}
|
||||
|
||||
fn join_addrs(addrs: &[String]) -> String {
|
||||
addrs
|
||||
.iter()
|
||||
.map(|a| a.to_lowercase())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
pub fn email_index(
|
||||
message_ids: &[String],
|
||||
from: &[String],
|
||||
subject: &str,
|
||||
sent_at: &str,
|
||||
to: &[String],
|
||||
) -> EmailIndex {
|
||||
let mut mids: Vec<String> = 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<EmailKey> {
|
||||
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(" <[email protected]> "), "[email protected]");
|
||||
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", "[email protected]"),
|
||||
identity_key("Alice", "[email protected]")
|
||||
);
|
||||
}
|
||||
|
||||
#[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<String> = mids.iter().map(|s| (*s).to_owned()).collect();
|
||||
let f: Vec<String> = from.iter().map(|s| (*s).to_owned()).collect();
|
||||
let t: Vec<String> = 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(&["<B@h>", "<a@H>"], &[], "", "", &[]);
|
||||
let b = idx(&["<a@h>", "<b@h>"], &[], "", "", &[]);
|
||||
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(
|
||||
&["<m1@h>"],
|
||||
&["a@x"],
|
||||
"Hi",
|
||||
"2020-01-01T00:00:00Z",
|
||||
&["b@y"],
|
||||
),
|
||||
idx(
|
||||
&["<m2@h>"],
|
||||
&["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(
|
||||
&["<dup@h>"],
|
||||
&["a@x"],
|
||||
"S",
|
||||
"2020-01-01T00:00:00Z",
|
||||
&["b@y"],
|
||||
),
|
||||
idx(
|
||||
&["<dup@h>"],
|
||||
&["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@h>", "<b@h>"],
|
||||
&["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(&["<m@h>"], &["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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user