mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
refactor(workspace): decompose project into multiple crates
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user