refactor(workspace): decompose project into multiple crates

This commit is contained in:
rustmailer
2026-04-23 21:45:34 +08:00
parent 5b884125f7
commit 0b866c81ff
171 changed files with 2203 additions and 2042 deletions
+54
View File
@@ -0,0 +1,54 @@
//
// 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 poem_openapi::Object;
use serde::{Deserialize, Serialize};
use tantivy::doc;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct Envelope {
pub id: String,
pub message_id: String,
pub account_id: u64,
pub account_email: Option<String>,
pub mailbox_id: u64,
pub mailbox_name: Option<String>,
pub uid: u32,
pub subject: String,
pub preview: String,
pub from: String,
pub to: Vec<String>,
pub cc: Vec<String>,
pub bcc: Vec<String>,
pub date: i64,
pub internal_date: i64,
pub ingest_at: i64,
pub size: u32,
pub thread_id: String,
pub attachment_count: usize,
pub regular_attachment_count: usize,
pub tags: Option<Vec<String>>,
pub content_hash: String,
}
impl Envelope {
pub fn has_any_attachments(&self) -> bool {
self.attachment_count > 0
}
}
+21
View File
@@ -0,0 +1,21 @@
//
// 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/>.
pub mod envelope;
pub mod storage;
pub mod tantivy;
+232
View File
@@ -0,0 +1,232 @@
//
// 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 crate::{
common::signal::SIGNAL_MANAGER,
envelope::extractor::reattach_eml_content,
error::{code::ErrorCode, BichonResult},
settings::dir::DATA_DIR_MANAGER,
};
use crate::raise_error;
use bytes::Bytes;
use fjall::{CompressionType, Database, Keyspace, KeyspaceCreateOptions, KvSeparationOptions, config::{BlockSizePolicy, CompressionPolicy}};
use std::{io::Cursor, sync::LazyLock};
use tokio::{
sync::{mpsc, Mutex},
task::{self, JoinHandle},
};
pub static BLOB_MANAGER: LazyLock<BlobManager> = LazyLock::new(BlobManager::new);
pub struct DetachedEmail {
pub email: (String, Bytes),
pub attachments: Option<Vec<(String, Bytes)>>,
}
pub struct BlobManager {
sender: mpsc::Sender<DetachedEmail>,
db: Database,
email_keyspace: Keyspace,
attachments_keyspace: Keyspace,
handle: Mutex<Option<JoinHandle<()>>>,
}
impl BlobManager {
pub async fn shutdown(&self) {
let mut guard = self.handle.lock().await;
if let Some(handle) = guard.take() {
let _ = handle.await;
}
}
fn process_detached_email(
eml: DetachedEmail,
email_ks: &Keyspace,
attach_ks: &Keyspace,
) {
let (email_hash, email_data) = eml.email;
match email_ks.contains_key(&email_hash) {
Ok(false) => {
if let Err(e) = email_ks.insert(email_hash, email_data) {
tracing::error!("CRITICAL: Failed to insert email: {:?}", e);
}
}
Err(e) => tracing::error!("Fjall email_ks error: {:?}", e),
_ => {}
}
if let Some(attachments) = eml.attachments {
for (a_hash, a_data) in attachments {
match attach_ks.contains_key(&a_hash) {
Ok(false) => {
if let Err(e) = attach_ks.insert(a_hash, a_data) {
tracing::error!("CRITICAL: Failed to insert attachment: {:?}", e);
}
}
Err(e) => tracing::error!("Fjall attach_ks error: {:?}", e),
_ => {}
}
}
}
}
pub fn new() -> Self {
let db = Database::builder(&DATA_DIR_MANAGER.storage_dir)
.cache_size(64 * 1024 * 1024)
.max_cached_files(Some(400))
.journal_compression(CompressionType::None)
.max_journaling_size(64 * 1024 * 1024)
.open()
.expect("Failed to initialize Fjall database: Check if the directory exists and has write permissions.");
let email_keyspace = db
.keyspace("email", || {
KeyspaceCreateOptions::default()
.max_memtable_size(16 * 1024 * 1024)
.data_block_size_policy(BlockSizePolicy::all(4 * 1024))
.data_block_compression_policy(
CompressionPolicy::all(CompressionType::Lz4)
)
.with_kv_separation(Some(
KvSeparationOptions::default()
.separation_threshold(1024)
.compression(CompressionType::Lz4)
.file_target_size(512 * 1024 * 1024)
.staleness_threshold(0.5)
.age_cutoff(0.6),
))
})
.expect("Failed to open 'email' keyspace: The partition metadata might be corrupted or inaccessible.");
let attachments_keyspace = db
.keyspace("attachments", || {
KeyspaceCreateOptions::default()
.data_block_size_policy(BlockSizePolicy::all(4 * 1024))
.data_block_compression_policy(
CompressionPolicy::all(CompressionType::Lz4)
)
.with_kv_separation(Some(
KvSeparationOptions::default()
.separation_threshold(1024)
.compression(CompressionType::Lz4)
.file_target_size(512 * 1024 * 1024)
.staleness_threshold(0.5)
.age_cutoff(0.6),
))
.max_memtable_size(16 * 1024 * 1024)
})
.expect("Failed to open 'attachments' keyspace: Check disk space for blob storage initialization.");
let (sender, mut receiver) = mpsc::channel::<DetachedEmail>(100);
let email_ks = email_keyspace.clone();
let attach_ks = attachments_keyspace.clone();
let handler = task::spawn(async move {
let mut shutdown = SIGNAL_MANAGER.subscribe();
loop {
tokio::select! {
res = receiver.recv() => {
match res {
Some(eml) => {
Self::process_detached_email(eml, &email_ks, &attach_ks);
while let Ok(next_eml) = receiver.try_recv() {
Self::process_detached_email(next_eml, &email_ks, &attach_ks);
}
}
None => {
tracing::info!("BlobManager: All senders dropped, closing storage.");
break;
}
}
}
_ = shutdown.recv() => {
receiver.close();
let remaining = receiver.len();
tracing::info!(
"BlobManager: Shutdown signal received. Processing {} remaining tasks...",
remaining
);
while let Some(eml) = receiver.recv().await {
Self::process_detached_email(eml, &email_ks, &attach_ks);
}
tracing::info!("BlobManager: All remaining tasks processed. Closing Fjall.");
break;
}
}
}
});
Self {
sender,
db,
email_keyspace,
attachments_keyspace,
handle: Mutex::new(Some(handler)),
}
}
pub async fn queue(&self, email: DetachedEmail) {
let _ = self.sender.send(email).await;
}
pub fn get_email(&self, content_hash: &str) -> BichonResult<Option<Bytes>> {
self.email_keyspace
.get(content_hash)
.map(|user_value| user_value.map(|s| s.into()))
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub fn get_attachment(&self, content_hash: &str) -> BichonResult<Option<Bytes>> {
self.attachments_keyspace
.get(content_hash)
.map(|user_value| user_value.map(|s| s.into()))
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))
}
pub fn delete<I1, I2>(
&self,
email_content_hashes: I1,
attachment_content_hashes: I2,
) -> BichonResult<()>
where
I1: IntoIterator,
I1::Item: AsRef<str>,
I2: IntoIterator,
I2::Item: AsRef<str> {
let mut batch = self.db.batch();
for hash in email_content_hashes {
batch.remove(&self.email_keyspace, hash.as_ref());
}
for hash in attachment_content_hashes {
batch.remove(&self.attachments_keyspace, hash.as_ref());
}
batch
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
}
}
pub async fn get_reader(account_id: u64, eid: String) -> BichonResult<Cursor<Bytes>> {
let (_, data) = reattach_eml_content(account_id, eid).await?;
Ok(Cursor::new(data))
}
+994
View File
@@ -0,0 +1,994 @@
//
// 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 std::{
collections::{HashMap, HashSet},
ops::Bound,
path::PathBuf,
sync::{Arc, LazyLock},
time::Duration,
};
use crate::{
common::{paginated::DataPage, signal::SIGNAL_MANAGER}, dashboard::{Group, LargestAttachment}, error::{BichonResult, code::ErrorCode}, message::{
attachment::AttachmentMetadata,
search::{AttachmentSearchFilter, SortBy},
tags::{TagAction, TagCount, TagsRequest},
}, raise_error, settings::dir::DATA_DIR_MANAGER, store::tantivy::{
fatal_commit,
fields::{
F_ATTACHMENT_CATEGORY, F_ATTACHMENT_CONTENT_TYPE, F_ATTACHMENT_EXT, F_DATE, F_SIZE,
F_TAGS,
},
model::{AttachmentModel, extract_senders},
schema::SchemaTools,
}
};
use serde_json::json;
use tantivy::{
aggregation::{
agg_req::Aggregations,
agg_result::{AggregationResult, BucketResult},
AggregationCollector, Key,
},
collector::{Count, FacetCollector, TopDocs},
indexer::{LogMergePolicy, UserOperation},
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
schema::{Field, IndexRecordOption, Value},
DocAddress, Index, IndexReader, IndexWriter, Order, TantivyDocument, Term,
};
use tantivy::{schema::Facet, Searcher};
use tokio::{
sync::{mpsc, Mutex},
task::{self, JoinHandle},
};
use tracing::info;
pub static ATTACHMENT_MANAGER: LazyLock<IndexManager> = LazyLock::new(IndexManager::new);
pub struct IndexManager {
index: Arc<Index>,
index_writer: Arc<Mutex<IndexWriter>>,
sender: mpsc::Sender<TantivyDocument>,
reader: IndexReader,
query_parser: QueryParser,
handle: Mutex<Option<JoinHandle<()>>>,
}
impl IndexManager {
pub async fn shutdown(&self) {
let mut guard = self.handle.lock().await;
if let Some(handle) = guard.take() {
let _ = handle.await;
}
}
pub fn new() -> Self {
let index = Self::open_or_create_index(&DATA_DIR_MANAGER.attachment_dir);
let mut merge_policy = LogMergePolicy::default();
merge_policy.set_min_num_segments(25);
merge_policy.set_min_layer_size(10_000);
merge_policy.set_max_docs_before_merge(100_000);
let index_writer = index
.writer_with_num_threads(4, 67_108_864)
.unwrap_or_else(|e| {
panic!(
"Failed to create IndexWriter with 4 threads and 64MB buffer for {:?}: {}",
&DATA_DIR_MANAGER.envelope_dir, e
)
});
index_writer.set_merge_policy(Box::new(merge_policy));
let index_writer = Arc::new(Mutex::new(index_writer));
let reader = index.reader().unwrap_or_else(|e| {
panic!(
"Failed to create IndexReader for {:?}: {}",
&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 writer = index_writer.clone();
let handler = task::spawn(async move {
let mut shutdown = SIGNAL_MANAGER.subscribe();
let mut commit_interval = tokio::time::interval(Duration::from_secs(60));
let mut pending_count = 0;
let commit_threshold = 1000;
loop {
tokio::select! {
maybe_msg = receiver.recv() => {
match maybe_msg {
Some(doc) => {
let mut writer = writer.lock().await;
let mut batch_count = 0;
match writer.add_document(doc) {
Ok(_) => {
batch_count += 1;
}
Err(e) => {
eprintln!("[ERROR] Failed to add document: {e:?}");
tracing::error!("Tantivy: Failed to add document: {e:?}");
}
}
while let Ok(next_doc) = receiver.try_recv() {
match writer.add_document(next_doc) {
Ok(_) => batch_count += 1,
Err(e) => {
eprintln!("[ERROR] Failed to add document: {e:?}");
tracing::error!("Tantivy: Failed to add document: {e:?}");
}
}
}
if batch_count > 0 {
pending_count += batch_count;
}
if pending_count >= commit_threshold {
tracing::info!(
"Tantivy: Reached threshold ({} docs), committing...",
pending_count
);
fatal_commit(&mut writer);
pending_count = 0;
commit_interval.reset();
}
}
None => {
tracing::info!("Tantivy: Receiver closed. Finalizing...");
if pending_count > 0 {
let mut writer = writer.lock().await;
fatal_commit(&mut writer);
}
break;
},
}
}
_ = commit_interval.tick() => {
if pending_count > 0 {
let mut writer = writer.lock().await;
fatal_commit(&mut writer);
pending_count = 0;
tracing::debug!("Tantivy: Periodic commit finished.");
}
}
_ = shutdown.recv() => {
tracing::info!("Tantivy: Shutdown signal received. Performing final commit...");
if pending_count > 0 {
let mut writer = writer.lock().await;
fatal_commit(&mut writer);
}
tracing::info!("Tantivy: Shutdown cleanup complete.");
break;
}
}
}
});
Self {
index: Arc::new(index),
index_writer,
sender,
reader,
query_parser,
handle: Mutex::new(Some(handler)),
}
}
pub async fn queue(&self, doc: TantivyDocument) {
let _ = self.sender.send(doc).await;
}
fn open_or_create_index(index_dir: &PathBuf) -> Index {
let need_create = !index_dir.exists()
|| index_dir
.read_dir()
.map(|mut d| d.next().is_none())
.unwrap_or(true);
if need_create {
info!(
"Attachment index not found or empty, creating new index at {}",
index_dir.display()
);
std::fs::create_dir_all(&index_dir).unwrap_or_else(|e| {
panic!("Failed to create index directory {:?}: {}", index_dir, e)
});
Index::create_in_dir(&index_dir, SchemaTools::attachment_schema())
.unwrap_or_else(|e| panic!("Failed to create index in {:?}: {}", index_dir, e))
} else {
info!(
"Opening existing attachment index at {}",
index_dir.display()
);
Self::open(&index_dir)
}
}
fn open(index_dir: &PathBuf) -> Index {
Index::open_in_dir(index_dir)
.unwrap_or_else(|e| panic!("Failed to open index in {:?}: {}", index_dir, e))
}
fn account_query(&self, account_id: u64) -> Box<TermQuery> {
let account_term =
Term::from_field_u64(SchemaTools::attachment_fields().f_account_id, account_id);
Box::new(TermQuery::new(account_term, IndexRecordOption::Basic))
}
fn mailbox_query(&self, account_id: u64, mailbox_id: u64) -> Box<dyn Query> {
let account_query = TermQuery::new(
Term::from_field_u64(SchemaTools::attachment_fields().f_account_id, account_id),
IndexRecordOption::Basic,
);
let mailbox_query = TermQuery::new(
Term::from_field_u64(SchemaTools::attachment_fields().f_mailbox_id, mailbox_id),
IndexRecordOption::Basic,
);
let boolean_query = BooleanQuery::new(vec![
(Occur::Must, Box::new(account_query)),
(Occur::Must, Box::new(mailbox_query)),
]);
Box::new(boolean_query)
}
fn attachment_query(&self, account_id: u64, aid: &str) -> Box<dyn Query> {
let account_id_query = TermQuery::new(
Term::from_field_u64(SchemaTools::attachment_fields().f_account_id, account_id),
IndexRecordOption::Basic,
);
let envelope_id_query = TermQuery::new(
Term::from_field_text(SchemaTools::attachment_fields().f_id, aid),
IndexRecordOption::Basic,
);
let boolean_query = BooleanQuery::new(vec![
(Occur::Must, Box::new(account_id_query)),
(Occur::Must, Box::new(envelope_id_query)),
]);
Box::new(boolean_query)
}
fn filter_query(
&self,
accounts: Option<HashSet<u64>>,
filter: AttachmentSearchFilter,
parser: QueryParser,
) -> BichonResult<Box<dyn Query>> {
let f = SchemaTools::attachment_fields();
let mut subqueries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
if let Some(authorized_ids) = accounts {
if authorized_ids.is_empty() {
let term = Term::from_field_u64(f.f_account_id, u64::MAX);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
} else {
let mut account_must_queries = Vec::new();
for id in authorized_ids {
let term = Term::from_field_u64(f.f_account_id, id);
account_must_queries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
subqueries.push((
Occur::Must,
Box::new(BooleanQuery::new(account_must_queries)),
));
}
}
if let Some(ref text) = filter.text {
let query = parser
.parse_query(text)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
subqueries.push((Occur::Must, Box::new(query)));
}
if let Some(ref subject_val) = filter.subject {
let term = Term::from_field_text(f.f_subject, subject_val);
let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query)));
}
if let Some(ref tags) = filter.tags {
if !tags.is_empty() {
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
for tag in tags {
let facet = Facet::from_text(tag).map_err(|e| {
raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter)
})?;
let term = Term::from_facet(f.f_tags, &facet);
should_queries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
}
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
}
}
if let Some(from_query) = &filter.from {
let term = Term::from_field_text(f.f_from, from_query);
let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query)));
}
if let Some(content_hash) = &filter.content_hash {
let term = Term::from_field_text(f.f_content_hash, content_hash);
let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query)));
}
if let Some(id) = &filter.id {
let term = Term::from_field_text(f.f_id, id);
let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query)));
}
if let Some(ref name) = filter.attachment_name {
let query_parser =
QueryParser::for_index(&self.index, vec![f.f_name_text, f.f_name_exact]);
if let Ok(q) = query_parser.parse_query(name) {
subqueries.push((Occur::Must, q));
}
}
if let Some(ref extension) = filter.attachment_extension {
let term = Term::from_field_text(f.f_ext, extension);
let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query)));
}
if let Some(ref category) = filter.attachment_category {
let term = Term::from_field_text(f.f_category, category);
let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query)));
}
if let Some(ref content_type) = filter.attachment_content_type {
let term = Term::from_field_text(f.f_content_type, content_type);
let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query)));
}
let start_bound = if let Some(from) = filter.since {
Bound::Included(Term::from_field_i64(f.f_date, from))
} else {
Bound::Unbounded
};
let end_bound = if let Some(to) = filter.before {
Bound::Included(Term::from_field_i64(f.f_date, to))
} else {
Bound::Unbounded
};
if start_bound != Bound::Unbounded || end_bound != Bound::Unbounded {
let q = RangeQuery::new(start_bound, end_bound);
subqueries.push((Occur::Must, Box::new(q)));
}
if let Some(account_ids) = filter.account_ids {
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
for id in account_ids {
let term = Term::from_field_u64(f.f_account_id, id);
should_queries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
}
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
}
if let Some(mailbox_ids) = filter.mailbox_ids {
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
for id in mailbox_ids {
let term = Term::from_field_u64(f.f_mailbox_id, id);
should_queries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
}
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
}
let start_bound = if let Some(from) = filter.min_size {
Bound::Included(Term::from_field_u64(f.f_size, from))
} else {
Bound::Unbounded
};
let end_bound = if let Some(to) = filter.max_size {
Bound::Included(Term::from_field_u64(f.f_size, to))
} else {
Bound::Unbounded
};
if start_bound != Bound::Unbounded || end_bound != Bound::Unbounded {
let q = RangeQuery::new(start_bound, end_bound);
subqueries.push((Occur::Must, Box::new(q)));
}
let mut add_bool_filter = |field: Field, value: Option<bool>| {
if let Some(v) = value {
let term = Term::from_field_bool(field, v);
let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query)));
}
};
add_bool_filter(f.f_is_ocr, filter.is_ocr);
add_bool_filter(f.f_is_message, filter.is_message);
add_bool_filter(f.f_has_text, filter.has_text);
let start_bound = if let Some(from) = filter.min_page_count {
Bound::Included(Term::from_field_u64(f.f_page_count, from))
} else {
Bound::Unbounded
};
let end_bound = if let Some(to) = filter.max_page_count {
Bound::Included(Term::from_field_u64(f.f_page_count, to))
} else {
Bound::Unbounded
};
if start_bound != Bound::Unbounded || end_bound != Bound::Unbounded {
let q = RangeQuery::new(start_bound, end_bound);
subqueries.push((Occur::Must, Box::new(q)));
}
if subqueries.is_empty() {
return Ok(Box::new(AllQuery));
}
Ok(Box::new(BooleanQuery::new(subqueries)))
}
pub async fn get_attachment_by_id(
&self,
account_id: u64,
id: &str,
) -> BichonResult<Option<AttachmentModel>> {
let searcher = self.create_searcher()?;
let f = SchemaTools::attachment_fields();
let query = BooleanQuery::new(vec![
(
Occur::Must,
Box::new(TermQuery::new(
Term::from_field_u64(f.f_account_id, account_id),
IndexRecordOption::Basic,
)),
),
(
Occur::Must,
Box::new(TermQuery::new(
Term::from_field_text(f.f_id, id),
IndexRecordOption::Basic,
)),
),
]);
let docs: Vec<(f32, DocAddress)> = searcher
.search(&query, &TopDocs::with_limit(1).order_by_score())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if let Some((_, doc_address)) = docs.first() {
let doc: TantivyDocument = searcher
.doc(*doc_address)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let attachment = AttachmentModel::from_tantivy_doc(&doc)?;
Ok(Some(attachment))
} else {
Ok(None)
}
}
pub async fn top_10_largest_attachments(
&self,
accounts: &Option<HashSet<u64>>,
) -> BichonResult<Vec<LargestAttachment>> {
self.reader
.reload()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let searcher = self.reader.searcher();
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::attachment_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let attachment_docs: Vec<(Option<u64>, DocAddress)> = searcher
.search(
&query,
&TopDocs::with_limit(200).order_by_fast_field(F_SIZE, Order::Desc),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut result = Vec::new();
let mut seen_hashes = std::collections::HashSet::new();
for (_, doc_address) in attachment_docs {
let doc: TantivyDocument = searcher
.doc(doc_address)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let att = LargestAttachment::from_tantivy_doc(&doc)?;
if seen_hashes.insert(att.content_hash.clone()) {
result.push(att);
}
if result.len() >= 10 {
break;
}
}
Ok(result)
}
pub async fn delete_account_attachments(&self, account_id: u64) -> BichonResult<()> {
let query = self.account_query(account_id);
let mut writer = self.index_writer.lock().await;
writer
.delete_query(query)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
writer
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
}
pub async fn delete_mailbox_attachments(
&self,
account_id: u64,
mailbox_ids: Vec<u64>,
) -> BichonResult<()> {
if mailbox_ids.is_empty() {
return Ok(());
}
let mut queries: Vec<Box<dyn Query>> = Vec::with_capacity(mailbox_ids.len());
for mailbox_id in mailbox_ids {
queries.push(self.mailbox_query(account_id, mailbox_id));
}
let mut writer = self.index_writer.lock().await;
for query in queries {
writer
.delete_query(query)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
writer
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
}
pub async fn delete_envelopes_multi_account(
&self,
deletes: HashMap<u64, Vec<String>>,
) -> BichonResult<()> {
if deletes.is_empty() {
tracing::warn!("delete_envelopes_multi_account: deletes is empty, nothing to delete");
return Ok(());
}
let mut writer = self.index_writer.lock().await;
for (account_id, envelope_ids) in deletes {
let unique_ids: HashSet<&String> = envelope_ids.iter().collect();
if unique_ids.is_empty() {
continue;
}
for eid in unique_ids {
let query = self.attachment_query(account_id, eid);
writer
.delete_query(query)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
writer
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
}
fn collect_facets_recursive(
query: &dyn Query,
searcher: &Searcher,
parent_facet: &str,
all_facets: &mut Vec<TagCount>,
field_name: &str,
) -> BichonResult<()> {
let mut facet_collector = FacetCollector::for_field(field_name);
facet_collector.add_facet(parent_facet);
let facet_counts = searcher
.search(query, &facet_collector)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
for (facet, count) in facet_counts.get(parent_facet) {
all_facets.push(TagCount {
tag: facet.to_string(),
count,
});
Self::collect_facets_recursive(
query,
searcher,
&facet.to_string(),
all_facets,
field_name,
)?;
}
Ok(())
}
pub async fn get_all_tags(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<Vec<TagCount>> {
let searcher = self.reader.searcher();
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::attachment_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let mut all_facets = Vec::new();
Self::collect_facets_recursive(&query, &searcher, "/", &mut all_facets, F_TAGS)?;
Ok(all_facets)
}
pub async fn update_attachment_tags(&self, request: TagsRequest) -> BichonResult<()> {
if request.updates.is_empty() {
tracing::warn!("update_attachment_tags: request is empty, nothing to update");
return Ok(());
}
let searcher = self.create_searcher()?;
let mut writer = self.index_writer.lock().await;
let f_tags = SchemaTools::attachment_fields().f_tags;
let f_id = SchemaTools::attachment_fields().f_id;
let deduplicated_updates: HashMap<u64, HashSet<String>> = request
.updates
.into_iter()
.map(|(account_id, envelope_ids)| (account_id, envelope_ids.into_iter().collect()))
.collect();
let mut operations = Vec::new();
for (account_id, att_ids) in &deduplicated_updates {
for aid in att_ids {
let query = self.attachment_query(*account_id, aid);
let docs = searcher
.search(query.as_ref(), &TopDocs::with_limit(1).order_by_score())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if let Some((_, doc_address)) = docs.first() {
let old_doc: TantivyDocument = searcher
.doc(*doc_address)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut current_tags: HashSet<String> = old_doc
.get_all(f_tags)
.filter_map(|val| val.as_facet())
.map(|facet| facet.to_string())
.collect();
match request.action {
TagAction::Add => {
for tag in &request.tags {
current_tags.insert(tag.clone());
}
}
TagAction::Remove => {
for tag in &request.tags {
current_tags.remove(tag);
}
}
TagAction::Overwrite => {
current_tags = request.tags.iter().cloned().collect();
}
}
let mut new_doc = TantivyDocument::new();
for (field, value) in old_doc.field_values() {
if field != f_tags {
new_doc.add_field_value(field, value);
}
}
for tag in current_tags {
new_doc.add_facet(f_tags, &tag);
}
let delete_term = Term::from_field_text(f_id, aid);
operations.push(UserOperation::Delete(delete_term));
operations.push(UserOperation::Add(new_doc));
}
}
}
writer
.run(operations)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// commit
writer
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
}
pub async fn search(
&self,
accounts: Option<HashSet<u64>>,
filter: AttachmentSearchFilter,
page: u64,
page_size: u64,
desc: bool,
sort_by: SortBy,
) -> BichonResult<DataPage<AttachmentModel>> {
assert!(page > 0, "Page number 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 searcher = self.create_searcher()?;
let total = searcher
.search(&query, &Count)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
as u64;
if total == 0 {
return Ok(DataPage {
current_page: Some(page),
page_size: Some(page_size),
total_items: 0,
items: vec![],
total_pages: Some(0),
});
}
let offset = (page - 1) * page_size;
let total_pages = total.div_ceil(page_size);
if offset > total {
return Ok(DataPage {
current_page: Some(page),
page_size: Some(page_size),
total_items: total,
items: vec![],
total_pages: Some(total_pages),
});
}
let order = if desc { Order::Desc } else { Order::Asc };
let attachment_docs: Vec<DocAddress>;
match sort_by {
SortBy::DATE => {
let date_docs: Vec<(Option<i64>, DocAddress)> = searcher
.search(
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
attachment_docs = date_docs.into_iter().map(|(_, addr)| addr).collect();
}
SortBy::SIZE => {
let size_docs: Vec<(Option<u64>, DocAddress)> = searcher
.search(
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_SIZE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
attachment_docs = size_docs.into_iter().map(|(_, addr)| addr).collect();
}
}
let mut result = Vec::new();
for doc_address in attachment_docs {
let doc: TantivyDocument = searcher
.doc(doc_address)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let envelope = AttachmentModel::from_tantivy_doc(&doc)?;
result.push(envelope);
}
Ok(DataPage {
current_page: Some(page),
page_size: Some(page_size),
total_items: total,
items: result,
total_pages: Some(total_pages),
})
}
fn create_searcher(&self) -> BichonResult<Searcher> {
self.reader
.reload()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(self.reader.searcher())
}
pub async fn get_all_senders(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<HashSet<String>> {
let searcher = self.create_searcher()?;
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::attachment_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let mut contacts_set: HashSet<String> = HashSet::new();
let top_docs = searcher
.search(&query, &TopDocs::with_limit(1_000_000).order_by_score())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
for (_score, doc_address) in top_docs {
let doc: TantivyDocument = searcher
.doc(doc_address)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let contacts = extract_senders(&doc).await?;
for value in contacts {
contacts_set.insert(value);
}
}
Ok(contacts_set)
}
pub fn collect_attachment_metadata(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<AttachmentMetadata> {
let searcher = self.create_searcher()?;
let aggregations: Aggregations = serde_json::from_value(json!({
"exts": {
"terms": {
"field": F_ATTACHMENT_EXT,
"size": 1000
}
},
"cats": {
"terms": {
"field": F_ATTACHMENT_CATEGORY,
"size": 1000
}
},
"content_types": {
"terms": {
"field": F_ATTACHMENT_CONTENT_TYPE,
"size": 1000
}
},
}))
.unwrap();
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::attachment_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let agg_collector = AggregationCollector::from_aggs(aggregations, Default::default());
let agg_results = searcher
.search(&query, &agg_collector)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut exts = Vec::with_capacity(20);
let extensions = agg_results.0.get("exts").unwrap();
if let AggregationResult::BucketResult(BucketResult::Terms { buckets, .. }) = extensions {
for entry in buckets {
if let Key::Str(ext) = &entry.key {
exts.push(Group {
key: ext.clone(),
count: entry.doc_count,
});
}
}
}
let mut cats = Vec::with_capacity(20);
let categories = agg_results.0.get("cats").unwrap();
if let AggregationResult::BucketResult(BucketResult::Terms { buckets, .. }) = categories {
for entry in buckets {
if let Key::Str(cat) = &entry.key {
cats.push(Group {
key: cat.clone(),
count: entry.doc_count,
});
}
}
}
let mut ctypes = Vec::with_capacity(20);
let content_types = agg_results.0.get("content_types").unwrap();
if let AggregationResult::BucketResult(BucketResult::Terms { buckets, .. }) = content_types
{
for entry in buckets {
if let Key::Str(content_type) = &entry.key {
ctypes.push(Group {
key: content_type.clone(),
count: entry.doc_count,
});
}
}
}
Ok(AttachmentMetadata {
extensions: exts,
categories: cats,
content_types: ctypes,
})
}
}
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
//
// 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::schema::Field;
pub const F_ID: &str = "id";
pub const F_MESSAGE_ID: &str = "message_id";
pub const F_ACCOUNT_ID: &str = "account_id";
pub const F_MAILBOX_ID: &str = "mailbox_id";
pub const F_UID: &str = "uid";
pub const F_SUBJECT: &str = "subject";
pub const F_BODY: &str = "body";
pub const F_PREVIEW: &str = "preview";
pub const F_CONTENT_HASH: &str = "content_hash";
pub const F_FROM: &str = "from";
pub const F_TO: &str = "to";
pub const F_CC: &str = "cc";
pub const F_BCC: &str = "bcc";
pub const F_DATE: &str = "date";
pub const F_INTERNAL_DATE: &str = "internal_date";
pub const F_INGEST_AT: &str = "ingest_at";
pub const F_SIZE: &str = "size";
pub const F_THREAD_ID: &str = "thread_id";
pub const F_ATTACHMENT_COUNT: &str = "attachment_count";
pub const F_REGULAR_ATTACHMENT_COUNT: &str = "regular_attachment_count";
pub const F_ATTACHMENTS: &str = "attachments";
pub const F_ATTACHMENT_NAME_TEXT: &str = "attachment_name_text";
pub const F_ATTACHMENT_NAME_EXACT: &str = "attachment_name_exact";
pub const F_ATTACHMENT_CONTENT_HASH: &str = "attachment_content_hash";
pub const F_ATTACHMENT_EXT: &str = "attachment_ext";
pub const F_ATTACHMENT_CATEGORY: &str = "attachment_category";
pub const F_ATTACHMENT_CONTENT_TYPE: &str = "attachment_content_type";
pub const F_ENVELOPE_ID: &str = "eid";
pub const F_TEXT: &str = "text";
pub const F_HAS_TEXT: &str = "has_text";
pub const F_IS_OCR: &str = "is_ocr";
pub const F_IS_INDEXED: &str = "is_indexed";
pub const F_IS_MESSAGE: &str = "is_message";
pub const F_NAME_TEXT: &str = "name_text";
pub const F_NAME_EXACT: &str = "name_exact";
pub const F_PAGE_COUNT: &str = "page_count";
pub const F_TAGS: &str = "tags";
pub const F_AUTO_TAGS: &str = "auto_tags";
pub const F_SHARD_ID: &str = "shard_id";
pub struct EmailFields {
pub f_id: Field,
pub f_message_id: Field,
pub f_account_id: Field,
pub f_mailbox_id: Field,
pub f_uid: Field,
pub f_subject: Field,
pub f_body: Field,
pub f_preview: Field,
pub f_content_hash: Field,
pub f_from: Field,
pub f_to: Field,
pub f_cc: Field,
pub f_bcc: Field,
pub f_date: Field,
pub f_internal_date: Field,
pub f_ingest_at: Field,
pub f_size: Field,
pub f_thread_id: Field,
pub f_attachment_count: Field,
pub f_regular_attachment_count: Field,
pub f_attachments: Field,
pub f_attachment_name_text: Field,
pub f_attachment_name_exact: Field,
pub f_attachment_content_hash: Field,
pub f_attachment_ext: Field,
pub f_attachment_category: Field,
pub f_attachment_content_type: Field,
pub f_tags: Field,
pub f_shard_id: Field,
}
pub struct AttachmentFields {
pub f_id: Field,
pub f_envelope_id: Field, // envelope id
pub f_account_id: Field,
pub f_mailbox_id: Field,
pub f_from: Field,
pub f_subject: Field,
pub f_content_hash: Field,
pub f_text: Field,
pub f_has_text: Field,
pub f_is_ocr: Field,
pub f_page_count: Field,
pub f_is_indexed: Field,
pub f_ingest_at: Field,
pub f_date: Field,
pub f_size: Field,
pub f_is_message: Field,
pub f_name_text: Field, // TEXT
pub f_name_exact: Field, // STRING
pub f_ext: Field,
pub f_category: Field,
pub f_content_type: Field,
pub f_shard_id: Field,
pub f_tags: Field,
pub f_auto_tags: Field,
}
+68
View File
@@ -0,0 +1,68 @@
//
// 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 tantivy::IndexWriter;
pub mod attachment;
pub mod envelope;
pub mod fields;
pub mod model;
pub mod schema;
pub fn fatal_commit(writer: &mut IndexWriter) {
const MAX_RETRIES: usize = 3;
const RETRY_DELAY_MS: u64 = 1000;
for attempt in 0..=MAX_RETRIES {
match writer.commit() {
Ok(_) => {
if attempt > 0 {
eprintln!("[INFO] Commit succeeded on attempt {}", attempt + 1);
}
return;
}
Err(e) => match &e {
tantivy::TantivyError::IoError(io_error) => {
if attempt < MAX_RETRIES {
eprintln!(
"[WARN] Commit failed (attempt {}/{}): {:?}. Retrying in {}ms...",
attempt + 1,
MAX_RETRIES + 1,
io_error,
RETRY_DELAY_MS * (attempt as u64 + 1)
);
std::thread::sleep(std::time::Duration::from_millis(
RETRY_DELAY_MS * (attempt as u64 + 1),
));
} else {
eprintln!(
"[FATAL] Tantivy commit failed after {} attempts: {:?}",
MAX_RETRIES + 1,
io_error
);
std::process::exit(1);
}
}
_ => {
eprintln!("[FATAL] Tantivy commit failed with non-IO error: {e:?}");
std::process::exit(1);
}
},
}
}
}
+446
View File
@@ -0,0 +1,446 @@
//
// 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 poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use tantivy::{
schema::{Facet, Value},
TantivyDocument,
};
use crate::{
raise_error,
{
account::migration::AccountModel,
cache::imap::mailbox::MailBox,
error::{code::ErrorCode, BichonResult},
message::content::AttachmentInfo,
store::{
envelope::Envelope,
tantivy::{
fields::{
F_ACCOUNT_ID, F_ATTACHMENTS, F_ATTACHMENT_CATEGORY, F_ATTACHMENT_CONTENT_TYPE,
F_ATTACHMENT_COUNT, F_CONTENT_HASH, F_DATE, F_ENVELOPE_ID, F_FROM, F_HAS_TEXT,
F_ID, F_INGEST_AT, F_INTERNAL_DATE, F_IS_INDEXED, F_IS_MESSAGE, F_IS_OCR,
F_MAILBOX_ID, F_MESSAGE_ID, F_PREVIEW, F_REGULAR_ATTACHMENT_COUNT, F_SHARD_ID,
F_SIZE, F_SUBJECT, F_TEXT, F_THREAD_ID, F_UID,
},
schema::SchemaTools,
},
},
},
};
#[derive(Debug, Clone)]
pub struct EnvelopeWithAttachments {
pub envelope: Envelope,
pub attachments: Option<Vec<AttachmentInfo>>,
}
impl EnvelopeWithAttachments {
pub fn to_document(&self, body_text: &str, shard_id: u64) -> BichonResult<TantivyDocument> {
let fields = SchemaTools::email_fields();
let mut doc = TantivyDocument::new();
doc.add_text(fields.f_id, &self.envelope.id);
doc.add_text(fields.f_message_id, &self.envelope.message_id);
doc.add_u64(fields.f_account_id, self.envelope.account_id);
doc.add_u64(fields.f_mailbox_id, self.envelope.mailbox_id);
doc.add_u64(fields.f_uid, self.envelope.uid as u64);
doc.add_text(fields.f_subject, &self.envelope.subject);
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_from, &self.envelope.from);
doc.add_text(fields.f_body, body_text);
for to in &self.envelope.to {
doc.add_text(fields.f_to, to);
}
for cc in &self.envelope.cc {
doc.add_text(fields.f_cc, cc);
}
for bcc in &self.envelope.bcc {
doc.add_text(fields.f_bcc, bcc);
}
doc.add_i64(fields.f_date, self.envelope.date);
doc.add_i64(fields.f_internal_date, self.envelope.internal_date);
doc.add_u64(fields.f_size, self.envelope.size as u64);
doc.add_i64(fields.f_ingest_at, self.envelope.ingest_at);
doc.add_text(fields.f_thread_id, &self.envelope.thread_id);
if let Some(ref atts) = self.attachments {
let atts_json = serde_json::to_string(atts).unwrap_or_else(|_| "[]".to_string());
doc.add_text(fields.f_attachments, atts_json);
for att in atts {
if !att.is_inline() {
if let Some(ref filename) = att.filename {
doc.add_text(fields.f_attachment_name_text, filename);
doc.add_text(fields.f_attachment_name_exact, filename);
}
if let Some(ext) = att.get_extension() {
doc.add_text(fields.f_attachment_ext, ext);
}
let category = att.get_category().to_string();
doc.add_text(fields.f_attachment_category, category);
let file_type = att.file_type.to_lowercase();
doc.add_text(fields.f_attachment_content_type, file_type);
}
doc.add_text(fields.f_attachment_content_hash, &att.content_hash);
}
}
doc.add_u64(
fields.f_attachment_count,
self.envelope.attachment_count as u64,
);
doc.add_u64(
fields.f_regular_attachment_count,
self.envelope.regular_attachment_count as u64,
);
doc.add_u64(fields.f_shard_id, shard_id);
Ok(doc)
}
pub fn from_tantivy_doc(doc: &TantivyDocument) -> BichonResult<Self> {
let fields = SchemaTools::email_fields();
let attachments_raw = extract_string_field(doc, fields.f_attachments, F_ATTACHMENTS).ok();
let attachments: Option<Vec<AttachmentInfo>> =
attachments_raw.and_then(|json| serde_json::from_str(&json).ok());
let tags: Vec<String> = doc
.get_all(fields.f_tags)
.filter_map(|value| value.as_facet())
.map(|facet_encoded_str| {
Facet::from_encoded(facet_encoded_str.as_bytes().to_vec())
.ok()
.map(|facet| facet.to_string())
})
.flatten()
.collect();
let account_id = extract_u64_field(doc, fields.f_account_id, F_ACCOUNT_ID)?;
let mailbox_id = extract_u64_field(doc, fields.f_mailbox_id, F_MAILBOX_ID)?;
let account = AccountModel::get(account_id)?;
let mailbox = MailBox::get(mailbox_id)?;
let envelope = Envelope {
id: extract_string_field(doc, fields.f_id, F_ID)?,
message_id: extract_string_field(doc, fields.f_message_id, F_MESSAGE_ID)?,
account_id,
account_email: Some(account.email),
mailbox_id,
mailbox_name: Some(mailbox.name),
uid: extract_u64_field(doc, fields.f_uid, F_UID)? as u32,
subject: extract_string_field(doc, fields.f_subject, F_SUBJECT)?,
preview: extract_string_field(doc, fields.f_preview, F_PREVIEW).unwrap_or_default(),
from: extract_string_field(doc, fields.f_from, F_FROM)?,
to: extract_vec_string_field(doc, fields.f_to)?,
cc: extract_vec_string_field(doc, fields.f_cc)?,
bcc: extract_vec_string_field(doc, fields.f_bcc)?,
date: extract_i64_field(doc, fields.f_date, F_DATE)?,
internal_date: extract_i64_field(doc, fields.f_internal_date, F_INTERNAL_DATE)?,
size: extract_u64_field(doc, fields.f_size, F_SIZE)? as u32,
thread_id: extract_string_field(doc, fields.f_thread_id, F_THREAD_ID)?,
attachment_count: extract_u64_field(doc, fields.f_attachment_count, F_ATTACHMENT_COUNT)?
as usize,
regular_attachment_count: extract_u64_field(
doc,
fields.f_regular_attachment_count,
F_REGULAR_ATTACHMENT_COUNT,
)? as usize,
tags: if tags.is_empty() { None } else { Some(tags) },
content_hash: extract_string_field(doc, fields.f_content_hash, F_CONTENT_HASH)?,
ingest_at: extract_i64_field(doc, fields.f_ingest_at, F_INGEST_AT)?,
};
Ok(EnvelopeWithAttachments {
envelope,
attachments,
})
}
}
fn extract_u64_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
field_name: &str,
) -> BichonResult<u64> {
extract_option_u64_field(document, field)?.ok_or_else(|| {
raise_error!(
format!("'{}' field is not a u64", field_name),
ErrorCode::InternalError
)
})
}
fn extract_option_u64_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
) -> BichonResult<Option<u64>> {
Ok(document.get_first(field).and_then(|v| v.as_u64()))
}
fn extract_bool_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
field_name: &str,
) -> BichonResult<bool> {
let value = document.get_first(field).ok_or_else(|| {
raise_error!(
format!("miss '{}' field in tantivy document", field_name),
ErrorCode::InternalError
)
})?;
value.as_bool().ok_or_else(|| {
raise_error!(
format!("'{}' field is not a u64", field_name),
ErrorCode::InternalError
)
})
}
fn extract_i64_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
field_name: &str,
) -> BichonResult<i64> {
let value = document.get_first(field).ok_or_else(|| {
raise_error!(
format!("miss '{}' field in tantivy document", field_name),
ErrorCode::InternalError
)
})?;
value.as_i64().ok_or_else(|| {
raise_error!(
format!("'{}' field is not a i64", field_name),
ErrorCode::InternalError
)
})
}
fn extract_string_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
field_name: &str,
) -> BichonResult<String> {
extract_option_string_field(document, field)?.ok_or_else(|| {
raise_error!(
format!("'{}' field is not a string", field_name),
ErrorCode::InternalError
)
})
}
fn extract_option_string_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
) -> BichonResult<Option<String>> {
Ok(document
.get_first(field)
.and_then(|v| v.as_str())
.map(|s| s.to_string()))
}
fn extract_vec_string_field(
document: &TantivyDocument,
field: tantivy::schema::Field,
) -> BichonResult<Vec<String>> {
let value = document
.get_all(field)
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
Ok(value)
}
pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
let fields = SchemaTools::email_fields();
let mut all_contacts = HashSet::new();
if let Ok(from_val) = extract_string_field(doc, fields.f_from, F_FROM) {
if !from_val.is_empty() {
all_contacts.insert(from_val);
}
}
let multi_fields = [fields.f_to, fields.f_cc, fields.f_bcc];
for field in multi_fields {
if let Ok(vals) = extract_vec_string_field(doc, field) {
for v in vals {
if !v.is_empty() {
all_contacts.insert(v);
}
}
}
}
Ok(all_contacts)
}
pub async fn extract_senders(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
let fields = SchemaTools::attachment_fields();
let mut senders = HashSet::new();
if let Ok(from_val) = extract_string_field(doc, fields.f_from, F_FROM) {
if !from_val.is_empty() {
senders.insert(from_val);
}
}
Ok(senders)
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct AttachmentModel {
pub id: String,
pub envelope_id: String,
pub account_id: u64,
pub account_email: Option<String>,
pub mailbox_id: u64,
pub mailbox_name: Option<String>,
pub subject: String,
pub content_hash: String,
pub from: String,
pub date: i64,
pub ingest_at: i64,
pub size: u64,
pub ext: Option<String>,
pub category: String,
pub content_type: String,
pub shard_id: u64,
pub text: Option<String>,
pub has_text: bool,
pub is_ocr: bool,
pub page_count: Option<u64>,
pub is_indexed: bool,
pub is_message: bool,
pub name: Option<String>,
pub tags: Option<Vec<String>>,
pub auto_tags: Option<Vec<String>>,
}
impl AttachmentModel {
pub fn into_document(self) -> TantivyDocument {
let f = SchemaTools::attachment_fields();
let mut doc = TantivyDocument::new();
doc.add_text(f.f_id, self.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_mailbox_id, self.mailbox_id);
doc.add_text(f.f_subject, self.subject);
doc.add_text(f.f_content_hash, self.content_hash);
doc.add_text(f.f_from, self.from);
doc.add_i64(f.f_date, self.date);
doc.add_i64(f.f_ingest_at, self.ingest_at);
doc.add_u64(f.f_size, self.size);
if let Some(ext) = self.ext {
doc.add_text(f.f_ext, ext);
}
doc.add_text(f.f_category, self.category);
doc.add_text(f.f_content_type, self.content_type);
doc.add_u64(f.f_shard_id, self.shard_id);
if let Some(text) = self.text {
doc.add_text(f.f_text, text);
}
doc.add_bool(f.f_has_text, self.has_text);
doc.add_bool(f.f_is_ocr, self.is_ocr);
if let Some(page_count) = self.page_count {
doc.add_u64(f.f_page_count, page_count);
}
doc.add_bool(f.f_is_indexed, self.is_indexed);
doc.add_bool(f.f_is_message, self.is_message);
if let Some(name) = self.name {
doc.add_text(f.f_name_text, name.clone());
doc.add_text(f.f_name_exact, name);
}
doc
}
pub fn from_tantivy_doc(doc: &TantivyDocument) -> BichonResult<Self> {
let f = SchemaTools::attachment_fields();
let tags: Vec<String> = doc
.get_all(f.f_tags)
.filter_map(|value| value.as_facet())
.map(|facet_encoded_str| {
Facet::from_encoded(facet_encoded_str.as_bytes().to_vec())
.ok()
.map(|facet| facet.to_string())
})
.flatten()
.collect();
let auto_tags: Vec<String> = doc
.get_all(f.f_auto_tags)
.filter_map(|value| value.as_facet())
.map(|facet_encoded_str| {
Facet::from_encoded(facet_encoded_str.as_bytes().to_vec())
.ok()
.map(|facet| facet.to_string())
})
.flatten()
.collect();
let account_id = extract_u64_field(doc, f.f_account_id, F_ACCOUNT_ID)?;
let mailbox_id = extract_u64_field(doc, f.f_mailbox_id, F_MAILBOX_ID)?;
let account = AccountModel::get(account_id)?;
let mailbox = MailBox::get(mailbox_id)?;
Ok(Self {
id: extract_string_field(doc, f.f_id, F_ID)?,
envelope_id: extract_string_field(doc, f.f_envelope_id, F_ENVELOPE_ID)?,
account_id,
account_email: Some(account.email),
mailbox_id,
mailbox_name: Some(mailbox.name),
subject: extract_string_field(doc, f.f_subject, F_SUBJECT)?,
content_hash: extract_string_field(doc, f.f_content_hash, F_CONTENT_HASH)?,
from: extract_string_field(doc, f.f_from, F_FROM)?,
date: extract_i64_field(doc, f.f_date, F_DATE)?,
ingest_at: extract_i64_field(doc, f.f_ingest_at, F_INGEST_AT)?,
size: extract_u64_field(doc, f.f_size, F_SIZE)?,
ext: extract_option_string_field(doc, f.f_ext)?,
category: extract_string_field(doc, f.f_category, F_ATTACHMENT_CATEGORY)?,
content_type: extract_string_field(doc, f.f_content_type, F_ATTACHMENT_CONTENT_TYPE)?,
shard_id: extract_u64_field(doc, f.f_shard_id, F_SHARD_ID)?,
text: extract_string_field(doc, f.f_text, F_TEXT).ok(),
has_text: extract_bool_field(doc, f.f_has_text, F_HAS_TEXT)?,
is_ocr: extract_bool_field(doc, f.f_is_ocr, F_IS_OCR)?,
page_count: extract_option_u64_field(doc, f.f_page_count)?,
is_indexed: extract_bool_field(doc, f.f_is_indexed, F_IS_INDEXED)?,
is_message: extract_bool_field(doc, f.f_is_message, F_IS_MESSAGE)?,
name: extract_option_string_field(doc, f.f_name_exact)?,
tags: if tags.is_empty() { None } else { Some(tags) },
auto_tags: if auto_tags.is_empty() {
None
} else {
Some(auto_tags)
},
})
}
}
+212
View File
@@ -0,0 +1,212 @@
//
// 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 std::sync::{Arc, LazyLock};
use tantivy::schema::{FacetOptions, Field, INDEXED};
use tantivy::schema::{Schema, FAST, STORED, STRING, TEXT};
use crate::store::tantivy::fields::{
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_NAME_EXACT, F_ATTACHMENT_NAME_TEXT, F_AUTO_TAGS, F_BCC, 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_IS_INDEXED, F_IS_MESSAGE, F_IS_OCR, F_MAILBOX_ID, F_MESSAGE_ID, F_NAME_EXACT, F_NAME_TEXT,
F_PAGE_COUNT, F_PREVIEW, F_REGULAR_ATTACHMENT_COUNT, F_SHARD_ID, F_SIZE, F_SUBJECT, F_TAGS,
F_TEXT, F_THREAD_ID, F_TO, F_UID,
};
static EMAIL_FIELDS: LazyLock<Arc<EmailFields>> = LazyLock::new(|| {
let (_, fields) = SchemaTools::create_email_schema();
Arc::new(fields)
});
static ATTACHMENT_FIELDS: LazyLock<Arc<AttachmentFields>> = LazyLock::new(|| {
let (_, fields) = SchemaTools::create_attachment_schema();
Arc::new(fields)
});
pub struct SchemaTools;
impl SchemaTools {
pub fn email_schema() -> Schema {
let (schema, _) = Self::create_email_schema();
schema
}
pub fn email_fields() -> &'static EmailFields {
&EMAIL_FIELDS
}
pub fn email_default_fields() -> Vec<Field> {
let fields = Self::email_fields();
vec![
fields.f_subject,
fields.f_body,
fields.f_attachment_name_text,
fields.f_attachment_name_exact,
fields.f_from,
fields.f_to,
]
}
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,
]
}
pub fn create_email_schema() -> (Schema, EmailFields) {
let mut builder = Schema::builder();
let f_id = builder.add_text_field(F_ID, STRING | STORED | FAST);
let f_message_id = builder.add_text_field(F_MESSAGE_ID, STRING | STORED);
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_uid = builder.add_u64_field(F_UID, INDEXED | STORED | FAST);
let f_subject = builder.add_text_field(F_SUBJECT, TEXT | STORED);
let f_body = builder.add_text_field(F_BODY, TEXT);
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_from = builder.add_text_field(F_FROM, STRING | STORED | FAST);
let f_to = builder.add_text_field(F_TO, 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_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_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_thread_id = builder.add_text_field(F_THREAD_ID, STRING | STORED | FAST);
let f_attachment_count = builder.add_u64_field(F_ATTACHMENT_COUNT, INDEXED | STORED | FAST);
let f_regular_attachment_count =
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_exact = builder.add_text_field(F_ATTACHMENT_NAME_EXACT, STRING);
let f_attachments = builder.add_text_field(F_ATTACHMENTS, STORED);
let f_attachment_content_hash =
builder.add_text_field(F_ATTACHMENT_CONTENT_HASH, STRING | STORED | FAST);
let f_attachment_ext = builder.add_text_field(F_ATTACHMENT_EXT, STRING | STORED | FAST);
let f_attachment_category =
builder.add_text_field(F_ATTACHMENT_CATEGORY, STRING | STORED | FAST);
let f_attachment_content_type =
builder.add_text_field(F_ATTACHMENT_CONTENT_TYPE, STRING | STORED | FAST);
let f_tags = builder.add_facet_field(F_TAGS, FacetOptions::default().set_stored());
let f_shard_id = builder.add_u64_field(F_SHARD_ID, INDEXED | STORED | FAST);
let fields = EmailFields {
f_id,
f_message_id,
f_account_id,
f_mailbox_id,
f_uid,
f_subject,
f_body,
f_preview,
f_content_hash,
f_from,
f_to,
f_cc,
f_bcc,
f_date,
f_internal_date,
f_ingest_at,
f_size,
f_thread_id,
f_attachment_count,
f_regular_attachment_count,
f_attachments,
f_attachment_name_text,
f_attachment_name_exact,
f_attachment_content_hash,
f_attachment_ext,
f_attachment_category,
f_attachment_content_type,
f_tags,
f_shard_id,
};
(builder.build(), fields)
}
pub fn create_attachment_schema() -> (Schema, AttachmentFields) {
let mut builder = Schema::builder();
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_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_subject = builder.add_text_field(F_SUBJECT, TEXT | STORED);
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_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_size = builder.add_u64_field(F_SIZE, INDEXED | STORED | FAST);
let f_ext = builder.add_text_field(F_ATTACHMENT_EXT, STRING | STORED | FAST);
let f_category = builder.add_text_field(F_ATTACHMENT_CATEGORY, STRING | STORED | FAST);
let f_content_type =
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_text = builder.add_text_field(F_TEXT, TEXT);
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_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_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_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_auto_tags =
builder.add_facet_field(F_AUTO_TAGS, FacetOptions::default().set_stored());
let fields = AttachmentFields {
f_id,
f_envelope_id,
f_account_id,
f_mailbox_id,
f_subject,
f_content_hash,
f_from,
f_date,
f_ingest_at,
f_size,
f_ext,
f_category,
f_content_type,
f_shard_id,
f_text,
f_has_text,
f_is_ocr,
f_page_count,
f_is_indexed,
f_is_message,
f_name_text,
f_name_exact,
f_tags,
f_auto_tags,
};
(builder.build(), fields)
}
}