feat: use stemmer for multilingual token matching

This commit is contained in:
rustmailer
2026-05-05 03:49:20 +08:00
parent 9bacf3fb7a
commit f583c3413c
12 changed files with 367 additions and 110 deletions
Generated
+25 -11
View File
@@ -505,6 +505,7 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"dashmap", "dashmap",
"deunicode",
"email_address", "email_address",
"encoding_rs", "encoding_rs",
"fjall", "fjall",
@@ -513,7 +514,7 @@ dependencies = [
"html2text", "html2text",
"itertools", "itertools",
"itoa", "itoa",
"lru 0.17.0", "lru 0.18.0",
"mail-parser", "mail-parser",
"mail-send", "mail-send",
"murmur3", "murmur3",
@@ -546,6 +547,7 @@ dependencies = [
"utf7-imap", "utf7-imap",
"uuid", "uuid",
"webpki-roots", "webpki-roots",
"whichlang",
] ]
[[package]] [[package]]
@@ -1331,6 +1333,12 @@ dependencies = [
"unicode-xid", "unicode-xid",
] ]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]] [[package]]
name = "dialoguer" name = "dialoguer"
version = "0.12.0" version = "0.12.0"
@@ -2719,9 +2727,9 @@ checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]] [[package]]
name = "libmimalloc-sys" name = "libmimalloc-sys"
version = "0.1.46" version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc89deee4af0429081d2a518c0431ae068222a5a262a3bc6ff4d8535ec2e02fe" checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6"
dependencies = [ dependencies = [
"cc", "cc",
] ]
@@ -2795,9 +2803,9 @@ dependencies = [
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.17.0" version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0b564323a0fb6d54b864f625ae139de9612e27edb944dda37c109f05aac531" checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
dependencies = [ dependencies = [
"hashbrown 0.17.0", "hashbrown 0.17.0",
] ]
@@ -2879,9 +2887,9 @@ dependencies = [
[[package]] [[package]]
name = "mail-parser" name = "mail-parser"
version = "0.11.2" version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f82a3d6522697593ba4c683e0a6ee5a40fee93bc1a525e3cc6eeb3da11fd8897" checksum = "d8a2420e9ce11c2b0583ca97ddff7ab2398c8a613154e9b72e3bafdbf767f1d7"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"hashify", "hashify",
@@ -2966,9 +2974,9 @@ dependencies = [
[[package]] [[package]]
name = "mimalloc" name = "mimalloc"
version = "0.1.49" version = "0.1.50"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aca3c01a711f395b4257b81674c0e90e8dd1f1e62c4b7db45f684cc7a4fcb18a" checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640"
dependencies = [ dependencies = [
"libmimalloc-sys", "libmimalloc-sys",
] ]
@@ -4274,9 +4282,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.39" version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"log", "log",
@@ -6097,6 +6105,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "whichlang"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b9aa3ad29c3d08283ac6b769e3ec15ad1ddb88af7d2e9bc402c574973b937e7"
[[package]] [[package]]
name = "widestring" name = "widestring"
version = "1.2.1" version = "1.2.1"
+4 -4
View File
@@ -11,7 +11,7 @@ edition = "2021"
[workspace.dependencies] [workspace.dependencies]
chrono = "0.4.44" chrono = "0.4.44"
clap = { version = "4.6.1", features = ["derive", "env"] } clap = { version = "4.6.1", features = ["derive", "env"] }
mimalloc = "0.1.49" mimalloc = "0.1.50"
native_db = "0.8.2" native_db = "0.8.2"
itertools = "0.14.0" itertools = "0.14.0"
native_model = "0.4.20" native_model = "0.4.20"
@@ -38,7 +38,7 @@ regex = "1.12.3"
email_address = "0.2.9" email_address = "0.2.9"
futures = "0.3.32" futures = "0.3.32"
utf7-imap = "0.3.2" utf7-imap = "0.3.2"
mail-parser = { version = '0.11.2', features = ["serde"] } mail-parser = { version = '0.11.3', features = ["serde"] }
# mail-send = "0.5.2" # mail-send = "0.5.2"
tokio-rustls = { version = "0.26.4", default-features = false, features = [ tokio-rustls = { version = "0.26.4", default-features = false, features = [
"ring", "ring",
@@ -52,12 +52,12 @@ num_cpus = "1.17.0"
rand = "0.10.1" rand = "0.10.1"
encoding_rs = "0.8.35" encoding_rs = "0.8.35"
webpki-roots = "1.0.7" webpki-roots = "1.0.7"
rustls = { version = "0.23.39", default-features = false, features = ["ring"] } rustls = { version = "0.23.40", default-features = false, features = ["ring"] }
rustls-pki-types = "1.14.1" rustls-pki-types = "1.14.1"
tokio-io-timeout = "1.2.1" tokio-io-timeout = "1.2.1"
semver = "1.0.28" semver = "1.0.28"
governor = "0.10.4" governor = "0.10.4"
lru = "0.17.0" lru = "0.18.0"
mime_guess = "2.0.5" mime_guess = "2.0.5"
hex = "0.4.3" hex = "0.4.3"
time = { version = "0.3.47", features = [ time = { version = "0.3.47", features = [
+3 -1
View File
@@ -48,7 +48,7 @@ async-imap = { git = "https://github.com/rustmailer/async-imap.git", branch = "m
"runtime-tokio", "runtime-tokio",
"compress", "compress",
] } ] }
tantivy = { version = "0.26.0", features = ["zstd-compression"] } tantivy = { version = "0.26.1", features = ["zstd-compression"] }
webpki-roots.workspace = true webpki-roots.workspace = true
rustls.workspace = true rustls.workspace = true
rustls-pki-types.workspace = true rustls-pki-types.workspace = true
@@ -68,3 +68,5 @@ uuid.workspace = true
fjall.workspace = true fjall.workspace = true
tracing-log.workspace = true tracing-log.workspace = true
tokio-util.workspace = true tokio-util.workspace = true
whichlang = "0.1.1"
deunicode = "1.6.2"
+31 -20
View File
@@ -25,19 +25,26 @@ use std::{
}; };
use crate::{ use crate::{
common::{paginated::DataPage, signal::SIGNAL_MANAGER}, dashboard::{Group, LargestAttachment}, error::{BichonResult, code::ErrorCode}, message::{ common::{paginated::DataPage, signal::SIGNAL_MANAGER},
dashboard::{Group, LargestAttachment},
error::{code::ErrorCode, BichonResult},
message::{
attachment::AttachmentMetadata, attachment::AttachmentMetadata,
search::{AttachmentSearchFilter, SortBy}, search::{AttachmentSearchFilter, SortBy},
tags::{TagAction, TagCount, TagsRequest}, tags::{TagAction, TagCount, TagsRequest},
}, raise_error, settings::dir::DATA_DIR_MANAGER, store::tantivy::{ },
raise_error,
settings::dir::DATA_DIR_MANAGER,
store::tantivy::{
fatal_commit, fatal_commit,
fields::{ fields::{
F_ATTACHMENT_CATEGORY, F_ATTACHMENT_CONTENT_TYPE, F_ATTACHMENT_EXT, F_DATE, F_SIZE, F_ATTACHMENT_CATEGORY, F_ATTACHMENT_CONTENT_TYPE, F_ATTACHMENT_EXT, F_DATE, F_SIZE,
F_TAGS, F_TAGS,
}, },
model::{AttachmentModel, extract_senders}, model::{extract_senders, AttachmentModel},
schema::SchemaTools, schema::SchemaTools,
} tokenizers::EuroTokenizer,
},
}; };
use serde_json::json; use serde_json::json;
@@ -67,7 +74,6 @@ pub struct IndexManager {
index_writer: Arc<Mutex<IndexWriter>>, index_writer: Arc<Mutex<IndexWriter>>,
sender: mpsc::Sender<TantivyDocument>, sender: mpsc::Sender<TantivyDocument>,
reader: IndexReader, reader: IndexReader,
query_parser: QueryParser,
handle: Mutex<Option<JoinHandle<()>>>, handle: Mutex<Option<JoinHandle<()>>>,
} }
@@ -80,6 +86,7 @@ impl IndexManager {
} }
pub fn new() -> Self { pub fn new() -> Self {
let index = Self::open_or_create_index(&DATA_DIR_MANAGER.attachment_dir); let index = Self::open_or_create_index(&DATA_DIR_MANAGER.attachment_dir);
index.tokenizers().register("euro", EuroTokenizer::new());
let mut merge_policy = LogMergePolicy::default(); let mut merge_policy = LogMergePolicy::default();
merge_policy.set_min_num_segments(25); merge_policy.set_min_num_segments(25);
merge_policy.set_min_layer_size(10_000); merge_policy.set_min_layer_size(10_000);
@@ -101,9 +108,6 @@ impl IndexManager {
&DATA_DIR_MANAGER.envelope_dir, e &DATA_DIR_MANAGER.envelope_dir, e
) )
}); });
let mut query_parser =
QueryParser::for_index(&index, SchemaTools::attachment_default_fields());
query_parser.set_conjunction_by_default();
let (sender, mut receiver) = mpsc::channel::<TantivyDocument>(100); let (sender, mut receiver) = mpsc::channel::<TantivyDocument>(100);
@@ -186,7 +190,6 @@ impl IndexManager {
index_writer, index_writer,
sender, sender,
reader, reader,
query_parser,
handle: Mutex::new(Some(handler)), handle: Mutex::new(Some(handler)),
} }
} }
@@ -267,7 +270,6 @@ impl IndexManager {
&self, &self,
accounts: Option<HashSet<u64>>, accounts: Option<HashSet<u64>>,
filter: AttachmentSearchFilter, filter: AttachmentSearchFilter,
parser: QueryParser,
) -> BichonResult<Box<dyn Query>> { ) -> BichonResult<Box<dyn Query>> {
let f = SchemaTools::attachment_fields(); let f = SchemaTools::attachment_fields();
let mut subqueries: Vec<(Occur, Box<dyn Query>)> = Vec::new(); let mut subqueries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
@@ -296,16 +298,21 @@ impl IndexManager {
} }
if let Some(ref text) = filter.text { if let Some(ref text) = filter.text {
let query = parser let query_parser =
QueryParser::for_index(&self.index, SchemaTools::attachment_default_fields());
let query = query_parser
.parse_query(text) .parse_query(text)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?; .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
subqueries.push((Occur::Must, Box::new(query))); subqueries.push((Occur::Must, Box::new(query)));
} }
if let Some(ref subject_val) = filter.subject { if let Some(ref subject_val) = filter.subject {
let term = Term::from_field_text(f.f_subject, subject_val); let query_parser = QueryParser::for_index(&self.index, vec![f.f_subject]);
let query = TermQuery::new(term, IndexRecordOption::Basic); let q = query_parser
subqueries.push((Occur::Must, Box::new(query))); .parse_query(subject_val)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
subqueries.push((Occur::Must, q));
} }
if let Some(ref tags) = filter.tags { if let Some(ref tags) = filter.tags {
@@ -329,9 +336,11 @@ impl IndexManager {
} }
if let Some(from_query) = &filter.from { if let Some(from_query) = &filter.from {
let term = Term::from_field_text(f.f_from, from_query); let query_parser = QueryParser::for_index(&self.index, vec![f.f_from_text]);
let query = TermQuery::new(term, IndexRecordOption::Basic); let q = query_parser
subqueries.push((Occur::Must, Box::new(query))); .parse_query(from_query)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
subqueries.push((Occur::Must, q))
} }
if let Some(content_hash) = &filter.content_hash { if let Some(content_hash) = &filter.content_hash {
@@ -349,10 +358,12 @@ impl IndexManager {
if let Some(ref name) = filter.attachment_name { if let Some(ref name) = filter.attachment_name {
let query_parser = let query_parser =
QueryParser::for_index(&self.index, vec![f.f_name_text, f.f_name_exact]); QueryParser::for_index(&self.index, vec![f.f_name_text, f.f_name_exact]);
if let Ok(q) = query_parser.parse_query(name) {
let q = query_parser
.parse_query(name)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
subqueries.push((Occur::Must, q)); subqueries.push((Occur::Must, q));
} }
}
if let Some(ref extension) = filter.attachment_extension { if let Some(ref extension) = filter.attachment_extension {
let term = Term::from_field_text(f.f_ext, extension); let term = Term::from_field_text(f.f_ext, extension);
@@ -774,7 +785,7 @@ impl IndexManager {
) -> BichonResult<DataPage<AttachmentModel>> { ) -> BichonResult<DataPage<AttachmentModel>> {
assert!(page > 0, "Page number must be greater than 0"); assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0"); assert!(page_size > 0, "Page size must be greater than 0");
let query = self.filter_query(accounts, filter, self.query_parser.clone())?; let query = self.filter_query(accounts, filter)?;
let searcher = self.create_searcher()?; let searcher = self.create_searcher()?;
let total = searcher let total = searcher
.search(&query, &Count) .search(&query, &Count)
+42 -24
View File
@@ -46,6 +46,7 @@ use crate::{
}, },
model::{extract_contacts, EnvelopeWithAttachments}, model::{extract_contacts, EnvelopeWithAttachments},
schema::SchemaTools, schema::SchemaTools,
tokenizers::EuroTokenizer,
}, },
}, },
utc_now, utc_now,
@@ -67,7 +68,7 @@ use tantivy::{
schema::{IndexRecordOption, Value}, schema::{IndexRecordOption, Value},
DocAddress, Index, IndexReader, IndexWriter, Order, TantivyDocument, Term, DocAddress, Index, IndexReader, IndexWriter, Order, TantivyDocument, Term,
}; };
use tantivy::{query::RegexQuery, schema::Facet, Searcher}; use tantivy::{schema::Facet, Searcher};
use tokio::{ use tokio::{
sync::{mpsc, Mutex}, sync::{mpsc, Mutex},
task::{self, JoinHandle}, task::{self, JoinHandle},
@@ -81,7 +82,6 @@ pub struct IndexManager {
index_writer: Arc<Mutex<IndexWriter>>, index_writer: Arc<Mutex<IndexWriter>>,
sender: mpsc::Sender<TantivyDocument>, sender: mpsc::Sender<TantivyDocument>,
reader: IndexReader, reader: IndexReader,
query_parser: QueryParser,
handle: Mutex<Option<JoinHandle<()>>>, handle: Mutex<Option<JoinHandle<()>>>,
} }
@@ -94,6 +94,7 @@ impl IndexManager {
} }
pub fn new() -> Self { pub fn new() -> Self {
let index = Self::open_or_create_index(&DATA_DIR_MANAGER.envelope_dir); let index = Self::open_or_create_index(&DATA_DIR_MANAGER.envelope_dir);
index.tokenizers().register("euro", EuroTokenizer::new());
let mut merge_policy = LogMergePolicy::default(); let mut merge_policy = LogMergePolicy::default();
merge_policy.set_min_num_segments(25); merge_policy.set_min_num_segments(25);
merge_policy.set_min_layer_size(10_000); merge_policy.set_min_layer_size(10_000);
@@ -115,8 +116,6 @@ impl IndexManager {
&DATA_DIR_MANAGER.envelope_dir, e &DATA_DIR_MANAGER.envelope_dir, e
) )
}); });
let mut query_parser = QueryParser::for_index(&index, SchemaTools::email_default_fields());
query_parser.set_conjunction_by_default();
let (sender, mut receiver) = mpsc::channel::<TantivyDocument>(100); let (sender, mut receiver) = mpsc::channel::<TantivyDocument>(100);
@@ -199,7 +198,6 @@ impl IndexManager {
index_writer, index_writer,
sender, sender,
reader, reader,
query_parser,
handle: Mutex::new(Some(handler)), handle: Mutex::new(Some(handler)),
} }
} }
@@ -277,7 +275,6 @@ impl IndexManager {
&self, &self,
accounts: Option<HashSet<u64>>, accounts: Option<HashSet<u64>>,
filter: EmailSearchFilter, filter: EmailSearchFilter,
parser: QueryParser,
) -> BichonResult<Box<dyn Query>> { ) -> BichonResult<Box<dyn Query>> {
let f = SchemaTools::email_fields(); let f = SchemaTools::email_fields();
let mut subqueries: Vec<(Occur, Box<dyn Query>)> = Vec::new(); let mut subqueries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
@@ -306,7 +303,10 @@ impl IndexManager {
} }
if let Some(ref text) = filter.text { if let Some(ref text) = filter.text {
let query = parser let query_parser =
QueryParser::for_index(&self.index, SchemaTools::email_default_fields());
let query = query_parser
.parse_query(text) .parse_query(text)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?; .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
subqueries.push((Occur::Must, Box::new(query))); subqueries.push((Occur::Must, Box::new(query)));
@@ -314,17 +314,21 @@ impl IndexManager {
if let Some(ref subject_val) = filter.subject { if let Some(ref subject_val) = filter.subject {
let query_parser = QueryParser::for_index(&self.index, vec![f.f_subject]); let query_parser = QueryParser::for_index(&self.index, vec![f.f_subject]);
if let Ok(q) = query_parser.parse_query(subject_val) { let q = query_parser
.parse_query(subject_val)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
println!("{:#?}", &q);
subqueries.push((Occur::Must, q)); subqueries.push((Occur::Must, q));
} }
}
if let Some(ref body_val) = filter.body { if let Some(ref body_val) = filter.body {
let query_parser = QueryParser::for_index(&self.index, vec![f.f_body]); let query_parser = QueryParser::for_index(&self.index, vec![f.f_body]);
if let Ok(q) = query_parser.parse_query(body_val) {
let q = query_parser
.parse_query(body_val)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
subqueries.push((Occur::Must, q)); subqueries.push((Occur::Must, q));
} }
}
if let Some(ref tags) = filter.tags { if let Some(ref tags) = filter.tags {
if !tags.is_empty() { if !tags.is_empty() {
@@ -347,15 +351,17 @@ impl IndexManager {
} }
for (field, opt_value) in [ for (field, opt_value) in [
(f.f_from, &filter.from), (f.f_from_text, &filter.from),
(f.f_to, &filter.to), (f.f_to_text, &filter.to),
(f.f_cc, &filter.cc), (f.f_cc_text, &filter.cc),
(f.f_bcc, &filter.bcc), (f.f_bcc_text, &filter.bcc),
] { ] {
if let Some(ref v) = opt_value { if let Some(ref v) = opt_value {
if let Ok(query) = RegexQuery::from_pattern(v.as_str(), field) { let query_parser = QueryParser::for_index(&self.index, vec![field]);
subqueries.push((Occur::Must, Box::new(query))); let q = query_parser
} .parse_query(v)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
subqueries.push((Occur::Must, q));
} }
} }
@@ -378,12 +384,24 @@ impl IndexManager {
if let Some(ref name) = filter.attachment_name { if let Some(ref name) = filter.attachment_name {
if name.contains('.') { if name.contains('.') {
let term = Term::from_field_text(f.f_attachment_name_exact, name); let term = Term::from_field_text(f.f_attachment_name_exact, name);
let query = TermQuery::new(term, IndexRecordOption::Basic); let exact_query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Should, Box::new(query)));
}
let query_parser = QueryParser::for_index(&self.index, vec![f.f_attachment_name_text]); let query_parser =
if let Ok(q) = query_parser.parse_query(name) { QueryParser::for_index(&self.index, vec![f.f_attachment_name_text]);
let q: Box<dyn Query> = query_parser
.parse_query(name)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
let query = BooleanQuery::new(vec![
(Occur::Should, Box::new(exact_query)),
(Occur::Should, Box::new(q)),
]);
subqueries.push((Occur::Must, Box::new(query)));
} else {
let query_parser =
QueryParser::for_index(&self.index, vec![f.f_attachment_name_text]);
let q = query_parser
.parse_query(name)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
subqueries.push((Occur::Must, q)); subqueries.push((Occur::Must, q));
} }
} }
@@ -1061,7 +1079,7 @@ impl IndexManager {
) -> BichonResult<DataPage<Envelope>> { ) -> BichonResult<DataPage<Envelope>> {
assert!(page > 0, "Page number must be greater than 0"); assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0"); assert!(page_size > 0, "Page size must be greater than 0");
let query = self.filter_query(accounts, filter, self.query_parser.clone())?; let query = self.filter_query(accounts, filter)?;
let searcher = self.create_searcher()?; let searcher = self.create_searcher()?;
let total = searcher let total = searcher
.search(&query, &Count) .search(&query, &Count)
+12
View File
@@ -27,10 +27,17 @@ pub const F_SUBJECT: &str = "subject";
pub const F_BODY: &str = "body"; pub const F_BODY: &str = "body";
pub const F_PREVIEW: &str = "preview"; pub const F_PREVIEW: &str = "preview";
pub const F_CONTENT_HASH: &str = "content_hash"; pub const F_CONTENT_HASH: &str = "content_hash";
pub const F_FROM: &str = "from"; pub const F_FROM: &str = "from";
pub const F_TO: &str = "to"; pub const F_TO: &str = "to";
pub const F_CC: &str = "cc"; pub const F_CC: &str = "cc";
pub const F_BCC: &str = "bcc"; pub const F_BCC: &str = "bcc";
pub const F_FROM_TEXT: &str = "from_text";
pub const F_TO_TEXT: &str = "to_text";
pub const F_CC_TEXT: &str = "cc_text";
pub const F_BCC_TEXT: &str = "bcc_text";
pub const F_DATE: &str = "date"; pub const F_DATE: &str = "date";
pub const F_INTERNAL_DATE: &str = "internal_date"; pub const F_INTERNAL_DATE: &str = "internal_date";
pub const F_INGEST_AT: &str = "ingest_at"; pub const F_INGEST_AT: &str = "ingest_at";
@@ -73,6 +80,10 @@ pub struct EmailFields {
pub f_to: Field, pub f_to: Field,
pub f_cc: Field, pub f_cc: Field,
pub f_bcc: Field, pub f_bcc: Field,
pub f_from_text: Field,
pub f_to_text: Field,
pub f_cc_text: Field,
pub f_bcc_text: Field,
pub f_date: Field, pub f_date: Field,
pub f_internal_date: Field, pub f_internal_date: Field,
pub f_ingest_at: Field, pub f_ingest_at: Field,
@@ -97,6 +108,7 @@ pub struct AttachmentFields {
pub f_account_id: Field, pub f_account_id: Field,
pub f_mailbox_id: Field, pub f_mailbox_id: Field,
pub f_from: Field, pub f_from: Field,
pub f_from_text: Field,
pub f_subject: Field, pub f_subject: Field,
pub f_content_hash: Field, pub f_content_hash: Field,
pub f_text: Field, pub f_text: Field,
+73
View File
@@ -0,0 +1,73 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use deunicode::deunicode;
use tantivy::tokenizer::{Token, TokenFilter, TokenStream, Tokenizer};
#[derive(Clone, Debug, Default)]
pub struct DeunicodeFilter;
impl TokenFilter for DeunicodeFilter {
type Tokenizer<T: Tokenizer> = DeunicodeFilterWrapper<T>;
fn transform<T: Tokenizer>(self, tokenizer: T) -> Self::Tokenizer<T> {
DeunicodeFilterWrapper { inner: tokenizer }
}
}
#[derive(Clone, Debug)]
pub struct DeunicodeFilterWrapper<T> {
inner: T,
}
impl<T: Tokenizer> Tokenizer for DeunicodeFilterWrapper<T> {
type TokenStream<'a> = DeunicodeTokenStream<T::TokenStream<'a>>;
fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> {
DeunicodeTokenStream {
inner: self.inner.token_stream(text),
}
}
}
pub struct DeunicodeTokenStream<S> {
inner: S,
}
impl<S: TokenStream> TokenStream for DeunicodeTokenStream<S> {
fn advance(&mut self) -> bool {
if !self.inner.advance() {
return false;
}
let token: &mut Token = self.inner.token_mut();
// Only allocate a new String when the text actually contains
// non-ASCII characters to avoid unnecessary overhead.
if !token.text.is_ascii() {
token.text = deunicode(&token.text);
}
true
}
fn token(&self) -> &Token {
self.inner.token()
}
fn token_mut(&mut self) -> &mut Token {
self.inner.token_mut()
}
}
+2
View File
@@ -26,8 +26,10 @@ use crate::{
pub mod attachment; pub mod attachment;
pub mod envelope; pub mod envelope;
pub mod fields; pub mod fields;
pub mod filter;
pub mod model; pub mod model;
pub mod schema; pub mod schema;
pub mod tokenizers;
pub fn fatal_commit(writer: &mut IndexWriter) { pub fn fatal_commit(writer: &mut IndexWriter) {
const MAX_RETRIES: usize = 3; const MAX_RETRIES: usize = 3;
+19 -2
View File
@@ -67,16 +67,20 @@ impl EnvelopeWithAttachments {
doc.add_text(fields.f_preview, &self.envelope.preview); doc.add_text(fields.f_preview, &self.envelope.preview);
doc.add_text(fields.f_content_hash, &self.envelope.content_hash); doc.add_text(fields.f_content_hash, &self.envelope.content_hash);
doc.add_text(fields.f_from, &self.envelope.from); doc.add_text(fields.f_from, &self.envelope.from);
doc.add_text(fields.f_from_text, &self.envelope.from);
doc.add_text(fields.f_body, body_text); doc.add_text(fields.f_body, body_text);
for to in &self.envelope.to { for to in &self.envelope.to {
doc.add_text(fields.f_to, to); doc.add_text(fields.f_to, to);
doc.add_text(fields.f_to_text, to);
} }
for cc in &self.envelope.cc { for cc in &self.envelope.cc {
doc.add_text(fields.f_cc, cc); doc.add_text(fields.f_cc, cc);
doc.add_text(fields.f_cc_text, cc);
} }
for bcc in &self.envelope.bcc { for bcc in &self.envelope.bcc {
doc.add_text(fields.f_bcc, bcc); doc.add_text(fields.f_bcc, bcc);
doc.add_text(fields.f_bcc_text, bcc);
} }
doc.add_i64(fields.f_date, self.envelope.date); doc.add_i64(fields.f_date, self.envelope.date);
@@ -353,9 +357,10 @@ impl AttachmentModel {
doc.add_text(f.f_envelope_id, self.envelope_id); doc.add_text(f.f_envelope_id, self.envelope_id);
doc.add_u64(f.f_account_id, self.account_id); doc.add_u64(f.f_account_id, self.account_id);
doc.add_u64(f.f_mailbox_id, self.mailbox_id); doc.add_u64(f.f_mailbox_id, self.mailbox_id);
doc.add_text(f.f_subject, self.subject); doc.add_text(f.f_subject, &self.subject);
doc.add_text(f.f_content_hash, self.content_hash); doc.add_text(f.f_content_hash, self.content_hash);
doc.add_text(f.f_from, self.from); doc.add_text(f.f_from, &self.from);
doc.add_text(f.f_from_text, &self.from);
doc.add_i64(f.f_date, self.date); doc.add_i64(f.f_date, self.date);
doc.add_i64(f.f_ingest_at, self.ingest_at); doc.add_i64(f.f_ingest_at, self.ingest_at);
doc.add_u64(f.f_size, self.size); doc.add_u64(f.f_size, self.size);
@@ -385,6 +390,18 @@ impl AttachmentModel {
doc.add_text(f.f_name_exact, name); doc.add_text(f.f_name_exact, name);
} }
if let Some(tags) = &self.tags {
for tag in tags {
doc.add_facet(f.f_tags, tag);
}
}
if let Some(tags) = &self.auto_tags {
for tag in tags {
doc.add_facet(f.f_auto_tags, tag);
}
}
doc doc
} }
+70 -36
View File
@@ -17,17 +17,19 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
use tantivy::schema::{FacetOptions, Field, INDEXED}; use tantivy::schema::{
use tantivy::schema::{Schema, FAST, STORED, STRING, TEXT}; FacetOptions, Field, IndexRecordOption, TextFieldIndexing, TextOptions, INDEXED,
};
use tantivy::schema::{Schema, FAST, STORED, STRING};
use crate::store::tantivy::fields::{ use crate::store::tantivy::fields::{
AttachmentFields, EmailFields, F_ACCOUNT_ID, F_ATTACHMENTS, F_ATTACHMENT_CATEGORY, AttachmentFields, EmailFields, F_ACCOUNT_ID, F_ATTACHMENTS, F_ATTACHMENT_CATEGORY,
F_ATTACHMENT_CONTENT_HASH, F_ATTACHMENT_CONTENT_TYPE, F_ATTACHMENT_COUNT, F_ATTACHMENT_EXT, F_ATTACHMENT_CONTENT_HASH, F_ATTACHMENT_CONTENT_TYPE, F_ATTACHMENT_COUNT, F_ATTACHMENT_EXT,
F_ATTACHMENT_NAME_EXACT, F_ATTACHMENT_NAME_TEXT, F_AUTO_TAGS, F_BCC, F_BODY, F_CC, F_ATTACHMENT_NAME_EXACT, F_ATTACHMENT_NAME_TEXT, F_AUTO_TAGS, F_BCC, F_BCC_TEXT, F_BODY, F_CC,
F_CONTENT_HASH, F_DATE, F_ENVELOPE_ID, F_FROM, F_HAS_TEXT, F_ID, F_INGEST_AT, F_INTERNAL_DATE, F_CC_TEXT, F_CONTENT_HASH, F_DATE, F_ENVELOPE_ID, F_FROM, F_FROM_TEXT, F_HAS_TEXT, F_ID,
F_IS_INDEXED, F_IS_MESSAGE, F_IS_OCR, F_MAILBOX_ID, F_MESSAGE_ID, F_NAME_EXACT, F_NAME_TEXT, F_INGEST_AT, F_INTERNAL_DATE, F_IS_INDEXED, F_IS_MESSAGE, F_IS_OCR, F_MAILBOX_ID, F_MESSAGE_ID,
F_PAGE_COUNT, F_PREVIEW, F_REGULAR_ATTACHMENT_COUNT, F_SHARD_ID, F_SIZE, F_SUBJECT, F_TAGS, F_NAME_EXACT, F_NAME_TEXT, F_PAGE_COUNT, F_PREVIEW, F_REGULAR_ATTACHMENT_COUNT, F_SHARD_ID,
F_TEXT, F_THREAD_ID, F_TO, F_UID, F_SIZE, F_SUBJECT, F_TAGS, F_TEXT, F_THREAD_ID, F_TO, F_TO_TEXT, F_UID,
}; };
static EMAIL_FIELDS: LazyLock<Arc<EmailFields>> = LazyLock::new(|| { static EMAIL_FIELDS: LazyLock<Arc<EmailFields>> = LazyLock::new(|| {
@@ -58,29 +60,10 @@ impl SchemaTools {
fields.f_subject, fields.f_subject,
fields.f_body, fields.f_body,
fields.f_attachment_name_text, fields.f_attachment_name_text,
fields.f_attachment_name_exact, fields.f_from_text,
fields.f_from, fields.f_to_text,
fields.f_to, fields.f_cc_text,
] fields.f_bcc_text,
}
pub fn attachment_schema() -> Schema {
let (schema, _) = Self::create_attachment_schema();
schema
}
pub fn attachment_fields() -> &'static AttachmentFields {
&ATTACHMENT_FIELDS
}
pub fn attachment_default_fields() -> Vec<Field> {
let fields = Self::attachment_fields();
vec![
fields.f_subject,
fields.f_text,
fields.f_name_exact,
fields.f_name_text,
fields.f_from,
] ]
} }
@@ -91,14 +74,21 @@ impl SchemaTools {
let f_account_id = builder.add_u64_field(F_ACCOUNT_ID, INDEXED | STORED | FAST); let f_account_id = builder.add_u64_field(F_ACCOUNT_ID, INDEXED | STORED | FAST);
let f_mailbox_id = builder.add_u64_field(F_MAILBOX_ID, INDEXED | STORED | FAST); let f_mailbox_id = builder.add_u64_field(F_MAILBOX_ID, INDEXED | STORED | FAST);
let f_uid = builder.add_u64_field(F_UID, INDEXED | STORED | FAST); let f_uid = builder.add_u64_field(F_UID, INDEXED | STORED | FAST);
let f_subject = builder.add_text_field(F_SUBJECT, TEXT | STORED); let f_subject = builder.add_text_field(F_SUBJECT, Self::text_store("euro"));
let f_body = builder.add_text_field(F_BODY, TEXT); let f_body = builder.add_text_field(F_BODY, Self::text_no_store("euro"));
let f_preview = builder.add_text_field(F_PREVIEW, STORED); let f_preview = builder.add_text_field(F_PREVIEW, STORED);
let f_content_hash = builder.add_text_field(F_CONTENT_HASH, STRING | STORED | FAST); let f_content_hash = builder.add_text_field(F_CONTENT_HASH, STRING | STORED | FAST);
let f_from = builder.add_text_field(F_FROM, STRING | STORED | FAST); let f_from = builder.add_text_field(F_FROM, STRING | STORED | FAST);
let f_to = builder.add_text_field(F_TO, STRING | STORED); let f_to = builder.add_text_field(F_TO, STRING | STORED);
let f_cc = builder.add_text_field(F_CC, STRING | STORED); let f_cc = builder.add_text_field(F_CC, STRING | STORED);
let f_bcc = builder.add_text_field(F_BCC, STRING | STORED); let f_bcc = builder.add_text_field(F_BCC, STRING | STORED);
let f_from_text = builder.add_text_field(F_FROM_TEXT, Self::text_no_store("euro"));
let f_to_text = builder.add_text_field(F_TO_TEXT, Self::text_no_store("euro"));
let f_cc_text = builder.add_text_field(F_CC_TEXT, Self::text_no_store("euro"));
let f_bcc_text = builder.add_text_field(F_BCC_TEXT, Self::text_no_store("euro"));
let f_date = builder.add_i64_field(F_DATE, INDEXED | STORED | FAST); let f_date = builder.add_i64_field(F_DATE, INDEXED | STORED | FAST);
let f_internal_date = builder.add_i64_field(F_INTERNAL_DATE, INDEXED | STORED | FAST); let f_internal_date = builder.add_i64_field(F_INTERNAL_DATE, INDEXED | STORED | FAST);
let f_ingest_at = builder.add_i64_field(F_INGEST_AT, INDEXED | STORED | FAST); let f_ingest_at = builder.add_i64_field(F_INGEST_AT, INDEXED | STORED | FAST);
@@ -107,7 +97,8 @@ impl SchemaTools {
let f_attachment_count = builder.add_u64_field(F_ATTACHMENT_COUNT, INDEXED | STORED | FAST); let f_attachment_count = builder.add_u64_field(F_ATTACHMENT_COUNT, INDEXED | STORED | FAST);
let f_regular_attachment_count = let f_regular_attachment_count =
builder.add_u64_field(F_REGULAR_ATTACHMENT_COUNT, INDEXED | STORED | FAST); builder.add_u64_field(F_REGULAR_ATTACHMENT_COUNT, INDEXED | STORED | FAST);
let f_attachment_name_text = builder.add_text_field(F_ATTACHMENT_NAME_TEXT, TEXT); let f_attachment_name_text =
builder.add_text_field(F_ATTACHMENT_NAME_TEXT, Self::text_no_store("euro"));
let f_attachment_name_exact = builder.add_text_field(F_ATTACHMENT_NAME_EXACT, STRING); let f_attachment_name_exact = builder.add_text_field(F_ATTACHMENT_NAME_EXACT, STRING);
let f_attachments = builder.add_text_field(F_ATTACHMENTS, STORED); let f_attachments = builder.add_text_field(F_ATTACHMENTS, STORED);
let f_attachment_content_hash = let f_attachment_content_hash =
@@ -133,6 +124,10 @@ impl SchemaTools {
f_to, f_to,
f_cc, f_cc,
f_bcc, f_bcc,
f_from_text,
f_to_text,
f_cc_text,
f_bcc_text,
f_date, f_date,
f_internal_date, f_internal_date,
f_ingest_at, f_ingest_at,
@@ -153,15 +148,35 @@ impl SchemaTools {
(builder.build(), fields) (builder.build(), fields)
} }
pub fn attachment_schema() -> Schema {
let (schema, _) = Self::create_attachment_schema();
schema
}
pub fn attachment_fields() -> &'static AttachmentFields {
&ATTACHMENT_FIELDS
}
pub fn attachment_default_fields() -> Vec<Field> {
let fields = Self::attachment_fields();
vec![
fields.f_subject,
fields.f_text,
fields.f_name_text,
fields.f_from_text,
]
}
pub fn create_attachment_schema() -> (Schema, AttachmentFields) { pub fn create_attachment_schema() -> (Schema, AttachmentFields) {
let mut builder = Schema::builder(); let mut builder = Schema::builder();
let f_id = builder.add_text_field(F_ID, STRING | STORED | FAST); let f_id = builder.add_text_field(F_ID, STRING | STORED | FAST);
let f_envelope_id = builder.add_text_field(F_ENVELOPE_ID, STRING | STORED | FAST); let f_envelope_id = builder.add_text_field(F_ENVELOPE_ID, STRING | STORED | FAST);
let f_account_id = builder.add_u64_field(F_ACCOUNT_ID, INDEXED | STORED | FAST); let f_account_id = builder.add_u64_field(F_ACCOUNT_ID, INDEXED | STORED | FAST);
let f_mailbox_id = builder.add_u64_field(F_MAILBOX_ID, INDEXED | STORED | FAST); let f_mailbox_id = builder.add_u64_field(F_MAILBOX_ID, INDEXED | STORED | FAST);
let f_subject = builder.add_text_field(F_SUBJECT, TEXT | STORED); let f_subject = builder.add_text_field(F_SUBJECT, Self::text_store("euro"));
let f_content_hash = builder.add_text_field(F_CONTENT_HASH, STRING | STORED | FAST); let f_content_hash = builder.add_text_field(F_CONTENT_HASH, STRING | STORED | FAST);
let f_from = builder.add_text_field(F_FROM, STRING | STORED | FAST); let f_from = builder.add_text_field(F_FROM, STRING | STORED | FAST);
let f_from_text = builder.add_text_field(F_FROM_TEXT, Self::text_no_store("euro"));
let f_date = builder.add_i64_field(F_DATE, INDEXED | STORED | FAST); let f_date = builder.add_i64_field(F_DATE, INDEXED | STORED | FAST);
let f_ingest_at = builder.add_i64_field(F_INGEST_AT, INDEXED | STORED | FAST); let f_ingest_at = builder.add_i64_field(F_INGEST_AT, INDEXED | STORED | FAST);
let f_size = builder.add_u64_field(F_SIZE, INDEXED | STORED | FAST); let f_size = builder.add_u64_field(F_SIZE, INDEXED | STORED | FAST);
@@ -170,13 +185,13 @@ impl SchemaTools {
let f_content_type = let f_content_type =
builder.add_text_field(F_ATTACHMENT_CONTENT_TYPE, STRING | STORED | FAST); builder.add_text_field(F_ATTACHMENT_CONTENT_TYPE, STRING | STORED | FAST);
let f_shard_id = builder.add_u64_field(F_SHARD_ID, INDEXED | STORED | FAST); let f_shard_id = builder.add_u64_field(F_SHARD_ID, INDEXED | STORED | FAST);
let f_text = builder.add_text_field(F_TEXT, TEXT); let f_text = builder.add_text_field(F_TEXT, Self::text_no_store("euro"));
let f_has_text = builder.add_bool_field(F_HAS_TEXT, INDEXED | STORED | FAST); let f_has_text = builder.add_bool_field(F_HAS_TEXT, INDEXED | STORED | FAST);
let f_is_ocr = builder.add_bool_field(F_IS_OCR, INDEXED | STORED | FAST); let f_is_ocr = builder.add_bool_field(F_IS_OCR, INDEXED | STORED | FAST);
let f_page_count = builder.add_u64_field(F_PAGE_COUNT, INDEXED | STORED | FAST); let f_page_count = builder.add_u64_field(F_PAGE_COUNT, INDEXED | STORED | FAST);
let f_is_indexed = builder.add_bool_field(F_IS_INDEXED, INDEXED | STORED | FAST); let f_is_indexed = builder.add_bool_field(F_IS_INDEXED, INDEXED | STORED | FAST);
let f_is_message = builder.add_bool_field(F_IS_MESSAGE, INDEXED | STORED | FAST); let f_is_message = builder.add_bool_field(F_IS_MESSAGE, INDEXED | STORED | FAST);
let f_name_text = builder.add_text_field(F_NAME_TEXT, TEXT); let f_name_text = builder.add_text_field(F_NAME_TEXT, Self::text_no_store("euro"));
let f_name_exact = builder.add_text_field(F_NAME_EXACT, STRING | STORED); let f_name_exact = builder.add_text_field(F_NAME_EXACT, STRING | STORED);
let f_tags = builder.add_facet_field(F_TAGS, FacetOptions::default().set_stored()); let f_tags = builder.add_facet_field(F_TAGS, FacetOptions::default().set_stored());
let f_auto_tags = let f_auto_tags =
@@ -189,6 +204,7 @@ impl SchemaTools {
f_subject, f_subject,
f_content_hash, f_content_hash,
f_from, f_from,
f_from_text,
f_date, f_date,
f_ingest_at, f_ingest_at,
f_size, f_size,
@@ -209,4 +225,22 @@ impl SchemaTools {
}; };
(builder.build(), fields) (builder.build(), fields)
} }
fn text_no_store(tokenizer: &str) -> TextOptions {
TextOptions::default().set_indexing_options(
TextFieldIndexing::default()
.set_tokenizer(tokenizer)
.set_index_option(IndexRecordOption::WithFreqsAndPositions),
)
}
fn text_store(tokenizer: &str) -> TextOptions {
TextOptions::default()
.set_indexing_options(
TextFieldIndexing::default()
.set_tokenizer(tokenizer)
.set_index_option(IndexRecordOption::WithFreqsAndPositions),
)
.set_stored()
}
} }
@@ -0,0 +1,72 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use tantivy::tokenizer::*;
use whichlang::{detect_language, Lang};
use crate::store::tantivy::filter::DeunicodeFilter;
#[derive(Clone)]
pub struct EuroTokenizer {
en: TextAnalyzer,
fr: TextAnalyzer,
de: TextAnalyzer,
es: TextAnalyzer,
nl: TextAnalyzer,
default: TextAnalyzer,
}
impl EuroTokenizer {
pub fn new() -> Self {
fn build(lang: Language) -> TextAnalyzer {
TextAnalyzer::builder(SimpleTokenizer::default())
.filter(RemoveLongFilter::limit(40))
.filter(LowerCaser)
.filter(DeunicodeFilter)
.filter(Stemmer::new(lang))
.build()
}
Self {
en: build(Language::English),
fr: build(Language::French),
de: build(Language::German),
es: build(Language::Spanish),
nl: build(Language::Dutch),
default: TextAnalyzer::builder(SimpleTokenizer::default())
.filter(RemoveLongFilter::limit(40))
.filter(LowerCaser)
.build(),
}
}
}
impl Tokenizer for EuroTokenizer {
type TokenStream<'a> = BoxTokenStream<'a>;
fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> {
match detect_language(text) {
Lang::Eng => self.en.token_stream(text),
Lang::Fra => self.fr.token_stream(text),
Lang::Deu => self.de.token_stream(text),
Lang::Spa => self.es.token_stream(text),
Lang::Nld => self.nl.token_stream(text),
_ => self.default.token_stream(text), // fallback
}
}
}
+2
View File
@@ -117,5 +117,7 @@ async fn initialize() -> BichonResult<()> {
BichonTls::initialize().await?; BichonTls::initialize().await?;
BichonContext::initialize().await?; BichonContext::initialize().await?;
LazyLock::force(&BLOB_MANAGER); LazyLock::force(&BLOB_MANAGER);
LazyLock::force(&ENVELOPE_MANAGER);
LazyLock::force(&ATTACHMENT_MANAGER);
Ok(()) Ok(())
} }