mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: add attachment search view
This commit is contained in:
+6
-5
@@ -23,13 +23,13 @@ use bichon::{
|
||||
modules::{
|
||||
cache::imap::task::SYNC_TASKS,
|
||||
common::rustls::BichonTls,
|
||||
context::{executors::BichonContext, Initialize},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
context::{Initialize, executors::BichonContext},
|
||||
error::{BichonResult, code::ErrorCode},
|
||||
logger,
|
||||
rest::start_http_server,
|
||||
settings::cli::SETTINGS,
|
||||
smtp::{start_smtp_server, SmtpServer},
|
||||
store::{storage::BLOB_MANAGER, tantivy::manager::INDEX_MANAGER},
|
||||
smtp::{SmtpServer, start_smtp_server},
|
||||
store::{storage::BLOB_MANAGER, tantivy::{attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER}},
|
||||
tasks::PeriodicTasks,
|
||||
},
|
||||
raise_error,
|
||||
@@ -95,7 +95,8 @@ async fn main() -> BichonResult<()> {
|
||||
}
|
||||
|
||||
SYNC_TASKS.shutdown().await;
|
||||
INDEX_MANAGER.shutdown().await;
|
||||
ENVELOPE_MANAGER.shutdown().await;
|
||||
ATTACHMENT_MANAGER.shutdown().await;
|
||||
BLOB_MANAGER.shutdown().await;
|
||||
info!("Bichon server stopped.");
|
||||
Ok(())
|
||||
|
||||
@@ -35,7 +35,7 @@ use crate::{
|
||||
cache::imap::mailbox::MailBox,
|
||||
database::{list_all_impl, secondary_find_impl, with_transaction},
|
||||
error::BichonResult,
|
||||
store::tantivy::manager::INDEX_MANAGER,
|
||||
store::tantivy::envelope::ENVELOPE_MANAGER,
|
||||
users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel, DEFAULT_ADMIN_USER_ID},
|
||||
},
|
||||
utc_now,
|
||||
@@ -372,7 +372,7 @@ impl AccountV4 {
|
||||
OAuth2AccessToken::try_delete(account.id).await?;
|
||||
UserModel::cleanup_account(account.id).await?;
|
||||
MailBox::clean(account.id).await?;
|
||||
INDEX_MANAGER.delete_account_envelopes(account.id).await?;
|
||||
ENVELOPE_MANAGER.delete_account_envelopes(account.id).await?;
|
||||
Self::delete_account(account.id).await?;
|
||||
info!("Sequential cleanup completed for account: {}", account.id);
|
||||
Ok(())
|
||||
|
||||
@@ -96,10 +96,27 @@ pub struct AccountError {
|
||||
}
|
||||
|
||||
impl DownloadState {
|
||||
pub async fn init(account_id: u64) -> BichonResult<()> {
|
||||
let state = DownloadState {
|
||||
pub fn empty(account_id: u64) -> Self {
|
||||
DownloadState {
|
||||
account_id,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn init(account_id: u64) -> BichonResult<()> {
|
||||
let now = utc_now!();
|
||||
let state = DownloadState {
|
||||
account_id,
|
||||
last_trigger_at: now,
|
||||
active_session: Some(DownloadSession {
|
||||
start_time: now,
|
||||
status: DownloadStatus::Running,
|
||||
trigger: TriggerType::Scheduled,
|
||||
..Default::default()
|
||||
}),
|
||||
history: Default::default(),
|
||||
last_finished_at: Default::default(),
|
||||
global_errors: Default::default(),
|
||||
};
|
||||
upsert_impl(DB_MANAGER.envelope_db(), state).await
|
||||
}
|
||||
|
||||
+44
-74
@@ -24,23 +24,19 @@ use crate::{
|
||||
},
|
||||
cache::{
|
||||
imap::{
|
||||
download::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_by_date},
|
||||
find_intersecting_mailboxes, find_missing_mailboxes,
|
||||
mailbox::MailBox,
|
||||
download::rebuild::{
|
||||
rebuild_mailbox_cache, rebuild_mailbox_cache_by_date,
|
||||
DEFAULT_MAX_CONCURRENT_PER_ACCOUNT,
|
||||
},
|
||||
},
|
||||
SEMAPHORE,
|
||||
},
|
||||
error::{code::ErrorCode, BichonError, BichonResult},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
imap::executor::ImapExecutor,
|
||||
store::tantivy::manager::INDEX_MANAGER,
|
||||
store::tantivy::envelope::ENVELOPE_MANAGER,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use tokio::sync::Semaphore;
|
||||
use std::time::Instant;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
@@ -564,7 +560,9 @@ pub async fn reconcile_mailboxes(
|
||||
//Mail folders that are not locally need to be downloaded.
|
||||
if !missing_mailboxes.is_empty() {
|
||||
MailBox::batch_insert(&missing_mailboxes).await?;
|
||||
let mut handles = Vec::new();
|
||||
|
||||
let mut has_error = false;
|
||||
let mut last_err = None;
|
||||
for mailbox in &missing_mailboxes {
|
||||
if token.is_cancelled() {
|
||||
DownloadState::update_session_status(
|
||||
@@ -579,8 +577,7 @@ pub async fn reconcile_mailboxes(
|
||||
let account = account.clone();
|
||||
let mailbox = mailbox.clone();
|
||||
|
||||
let local_semaphore = Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_PER_ACCOUNT));
|
||||
let global_permit = match SEMAPHORE.clone().acquire_owned().await {
|
||||
let _global_permit = match SEMAPHORE.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(err) => {
|
||||
error!(
|
||||
@@ -591,74 +588,47 @@ pub async fn reconcile_mailboxes(
|
||||
}
|
||||
};
|
||||
|
||||
let local_permit = match local_semaphore.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to acquire local semaphore permit for account {} mailbox '{}': {:#?}",
|
||||
account.id, &mailbox.name, err
|
||||
);
|
||||
drop(global_permit);
|
||||
continue;
|
||||
let result = match &account.date_since {
|
||||
Some(date_since) => {
|
||||
rebuild_mailbox_cache_by_date(
|
||||
&account,
|
||||
mailbox.id,
|
||||
&date_since.since_date()?,
|
||||
&mailbox,
|
||||
FetchDirection::Since,
|
||||
token.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
let token_clone = token.clone();
|
||||
let handle: tokio::task::JoinHandle<Result<(), BichonError>> =
|
||||
tokio::spawn(async move {
|
||||
let _global_permit = global_permit;
|
||||
let _local_permit = local_permit;
|
||||
match &account.date_since {
|
||||
Some(date_since) => {
|
||||
rebuild_mailbox_cache_by_date(
|
||||
&account,
|
||||
mailbox.id,
|
||||
&date_since.since_date()?,
|
||||
&mailbox,
|
||||
FetchDirection::Since,
|
||||
token_clone,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => match &account.date_before {
|
||||
Some(r) => {
|
||||
rebuild_mailbox_cache_by_date(
|
||||
&account,
|
||||
mailbox.id,
|
||||
&r.calculate_date()?,
|
||||
&mailbox,
|
||||
FetchDirection::Before,
|
||||
token_clone,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
rebuild_mailbox_cache(&account, &mailbox, &mailbox, token_clone)
|
||||
.await
|
||||
}
|
||||
},
|
||||
None => match &account.date_before {
|
||||
Some(r) => {
|
||||
rebuild_mailbox_cache_by_date(
|
||||
&account,
|
||||
mailbox.id,
|
||||
&r.calculate_date()?,
|
||||
&mailbox,
|
||||
FetchDirection::Before,
|
||||
token.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
rebuild_mailbox_cache(&account, &mailbox, &mailbox, token.clone()).await
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mut has_error = false;
|
||||
let mut last_err = None;
|
||||
|
||||
for task in handles {
|
||||
match task.await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(err)) => {
|
||||
has_error = true;
|
||||
tracing::error!("Folder sync task failed: {:#?}", err);
|
||||
last_err = Some(err);
|
||||
}
|
||||
Err(e) => {
|
||||
has_error = true;
|
||||
tracing::error!("Task panicked or runtime error: {:#?}", e);
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
has_error = true;
|
||||
tracing::error!("Folder sync task failed: {:#?}", err);
|
||||
last_err = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_error {
|
||||
if let Some(e) = last_err {
|
||||
return Err(e);
|
||||
@@ -680,7 +650,7 @@ async fn perform_incremental_sync(
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<()> {
|
||||
if remote_mailbox.exists > 0 {
|
||||
let local_max_uid = INDEX_MANAGER
|
||||
let local_max_uid = ENVELOPE_MANAGER
|
||||
.get_max_uid(account.id, local_mailbox.id)
|
||||
.await?;
|
||||
match local_max_uid {
|
||||
|
||||
+22
-78
@@ -24,23 +24,22 @@ use crate::{
|
||||
},
|
||||
cache::{
|
||||
imap::{
|
||||
download::flow::{
|
||||
fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection,
|
||||
},
|
||||
mailbox::MailBox,
|
||||
download::flow::{fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection},
|
||||
},
|
||||
SEMAPHORE,
|
||||
},
|
||||
error::{code::ErrorCode, BichonError, BichonResult},
|
||||
store::tantivy::manager::INDEX_MANAGER,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
store::tantivy::envelope::ENVELOPE_MANAGER,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub const DEFAULT_MAX_CONCURRENT_PER_ACCOUNT: usize = 3;
|
||||
|
||||
pub async fn rebuild_cache(
|
||||
account: &AccountModel,
|
||||
remote_mailboxes: &[MailBox],
|
||||
@@ -53,9 +52,9 @@ pub async fn rebuild_cache(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let local_semaphore = Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_PER_ACCOUNT));
|
||||
let mut has_error = false;
|
||||
let mut last_err = None;
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for mailbox in remote_mailboxes {
|
||||
if token.is_cancelled() {
|
||||
DownloadState::update_session_status(
|
||||
@@ -85,7 +84,7 @@ pub async fn rebuild_cache(
|
||||
let account = account.clone();
|
||||
let mailbox = mailbox.clone();
|
||||
|
||||
let global_permit = match SEMAPHORE.clone().acquire_owned().await {
|
||||
let _global_permit = match SEMAPHORE.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(err) => {
|
||||
error!(
|
||||
@@ -96,43 +95,16 @@ pub async fn rebuild_cache(
|
||||
}
|
||||
};
|
||||
|
||||
let local_permit = match local_semaphore.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
match fetch_and_save_full_mailbox(&account, &mailbox, token.clone()).await {
|
||||
Ok(_) => {},
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to acquire local semaphore permit for account {} mailbox '{}': {:#?}",
|
||||
account.id, &mailbox.name, err
|
||||
);
|
||||
drop(global_permit);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let token_clone = token.clone();
|
||||
let handle: tokio::task::JoinHandle<Result<(), BichonError>> = tokio::spawn(async move {
|
||||
let _global_permit = global_permit;
|
||||
let _local_permit = local_permit;
|
||||
fetch_and_save_full_mailbox(&account, &mailbox, token_clone).await
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
let mut has_error = false;
|
||||
let mut last_err = None;
|
||||
|
||||
for task in handles {
|
||||
match task.await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(err)) => {
|
||||
has_error = true;
|
||||
tracing::error!("Folder sync task failed: {:#?}", err);
|
||||
last_err = Some(err);
|
||||
}
|
||||
Err(e) => {
|
||||
has_error = true;
|
||||
tracing::error!("Task panicked or runtime error: {:#?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_error {
|
||||
if let Some(e) = last_err {
|
||||
return Err(e);
|
||||
@@ -159,8 +131,8 @@ pub async fn rebuild_cache_by_date(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut handles = Vec::new();
|
||||
let local_semaphore = Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_PER_ACCOUNT));
|
||||
let mut has_error = false;
|
||||
let mut last_err = None;
|
||||
|
||||
for mailbox in remote_mailboxes {
|
||||
if token.is_cancelled() {
|
||||
@@ -194,7 +166,7 @@ pub async fn rebuild_cache_by_date(
|
||||
let date = date.to_string();
|
||||
let direction = direction.clone();
|
||||
|
||||
let global_permit = match SEMAPHORE.clone().acquire_owned().await {
|
||||
let _global_permit = match SEMAPHORE.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(err) => {
|
||||
error!(
|
||||
@@ -204,46 +176,18 @@ pub async fn rebuild_cache_by_date(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let local_permit = match local_semaphore.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
match fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction, token.clone())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to acquire local semaphore permit for account {} mailbox '{}': {:#?}",
|
||||
account.id, &mailbox.name, err
|
||||
);
|
||||
drop(global_permit);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let token_clone = token.clone();
|
||||
let handle: tokio::task::JoinHandle<Result<(), BichonError>> =
|
||||
tokio::spawn(async move {
|
||||
let _global_permit = global_permit;
|
||||
let _local_permit = local_permit;
|
||||
fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction, token_clone)
|
||||
.await
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
let mut has_error = false;
|
||||
let mut last_err = None;
|
||||
|
||||
for task in handles {
|
||||
match task.await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(err)) => {
|
||||
has_error = true;
|
||||
tracing::error!("Folder sync task failed: {:#?}", err);
|
||||
last_err = Some(err);
|
||||
}
|
||||
Err(e) => {
|
||||
has_error = true;
|
||||
tracing::error!("Task panicked or runtime error: {:#?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_error {
|
||||
if let Some(e) = last_err {
|
||||
return Err(e);
|
||||
@@ -263,7 +207,7 @@ pub async fn rebuild_mailbox_cache(
|
||||
remote_mailbox: &MailBox,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<()> {
|
||||
INDEX_MANAGER
|
||||
ENVELOPE_MANAGER
|
||||
.delete_mailbox_envelopes(account.id, vec![local_mailbox.id])
|
||||
.await?;
|
||||
|
||||
@@ -297,7 +241,7 @@ pub async fn rebuild_mailbox_cache_by_date(
|
||||
direction: FetchDirection,
|
||||
token: CancellationToken,
|
||||
) -> BichonResult<()> {
|
||||
INDEX_MANAGER
|
||||
ENVELOPE_MANAGER
|
||||
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
|
||||
.await?;
|
||||
if remote.exists == 0 {
|
||||
|
||||
Vendored
+1
-1
@@ -140,7 +140,7 @@ impl AccountSyncTask {
|
||||
account_id
|
||||
);
|
||||
token.cancel();
|
||||
if let Err(_) = tokio::time::timeout(Duration::from_secs(10), handler.stop()).await
|
||||
if let Err(_) = tokio::time::timeout(Duration::from_secs(5), handler.stop()).await
|
||||
{
|
||||
error!(
|
||||
"Shutdown: Account {} download task forced timeout.",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::modules::{
|
||||
store::tantivy::{manager::INDEX_MANAGER, schema::SchemaTools},
|
||||
store::tantivy::{envelope::ENVELOPE_MANAGER, fields::F_ID, schema::SchemaTools},
|
||||
users::permissions::Permission,
|
||||
};
|
||||
use poem_openapi::Object;
|
||||
@@ -66,9 +66,9 @@ impl DashboardStats {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
|
||||
let mut stat = INDEX_MANAGER.get_dashboard_stats(&authorized_ids).await?;
|
||||
let mut stat = ENVELOPE_MANAGER.get_dashboard_stats(&authorized_ids).await?;
|
||||
|
||||
stat.top_largest_emails = INDEX_MANAGER.top_10_largest_emails(&authorized_ids).await?;
|
||||
stat.top_largest_emails = ENVELOPE_MANAGER.top_10_largest_emails(&authorized_ids).await?;
|
||||
|
||||
stat.account_count = if has_all_accounts {
|
||||
AccountModel::count().await?
|
||||
@@ -76,13 +76,13 @@ impl DashboardStats {
|
||||
authorized_ids.as_ref().map(|ids| ids.len()).unwrap_or(0)
|
||||
};
|
||||
|
||||
stat.email_count = INDEX_MANAGER.total_emails(&authorized_ids)?;
|
||||
stat.email_count = ENVELOPE_MANAGER.total_emails(&authorized_ids)?;
|
||||
|
||||
if has_all_accounts {
|
||||
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir)
|
||||
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.storage_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
stat.index_usage_bytes = get_total_size(&&DATA_DIR_MANAGER.tantivy_dir)
|
||||
stat.index_usage_bytes = get_total_size(&&DATA_DIR_MANAGER.envelope_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
} else {
|
||||
stat.storage_usage_bytes = 0;
|
||||
@@ -139,13 +139,13 @@ impl LargestEmail {
|
||||
|
||||
let value = document.get_first(fields.f_id).ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("'{}' field not found", stringify!(field)),
|
||||
format!("'{}' field not found", F_ID),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
let id = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("'{}' field is not a string", stringify!(field)),
|
||||
format!("'{}' field is not a string", F_ID),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
@@ -159,3 +159,58 @@ impl LargestEmail {
|
||||
Ok(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct LargestAttachment {
|
||||
pub name: String, // Attachment name
|
||||
pub size_bytes: u64, // Attachment size in bytes
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl LargestAttachment {
|
||||
pub fn from_tantivy_doc(document: &TantivyDocument) -> BichonResult<Self> {
|
||||
let fields = SchemaTools::attachment_fields();
|
||||
let value = document.get_first(fields.f_size).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"miss 'size' field in tantivy document".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
let size_bytes = value.as_u64().ok_or_else(|| {
|
||||
raise_error!("'size' field is not a u64".into(), ErrorCode::InternalError)
|
||||
})?;
|
||||
let value = document.get_first(fields.f_name_exact).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"'name_exact' field not found".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
let name = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
|
||||
raise_error!(
|
||||
"'name_exact' field is not a string".into(),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
|
||||
let value = document.get_first(fields.f_id).ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("'{}' field not found", F_ID),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
let id = value.as_str().map(|s| s.to_string()).ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("'{}' field is not a string", F_ID),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
|
||||
let envelope = LargestAttachment {
|
||||
name,
|
||||
size_bytes,
|
||||
id,
|
||||
};
|
||||
|
||||
Ok(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@ use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::modules::message::content::AttachmentInfo;
|
||||
use crate::modules::store::storage::{DetachedEmail, BLOB_MANAGER};
|
||||
use crate::modules::store::tantivy::manager::INDEX_MANAGER;
|
||||
use crate::modules::store::tantivy::model::EnvelopeWithAttachments;
|
||||
use crate::modules::store::tantivy::attachment::ATTACHMENT_MANAGER;
|
||||
use crate::modules::store::tantivy::envelope::ENVELOPE_MANAGER;
|
||||
use crate::modules::store::tantivy::model::{AttachmentModel, EnvelopeWithAttachments};
|
||||
use crate::modules::utils::html::extract_text;
|
||||
use crate::modules::utils::{compute_content_hash, hex_hash};
|
||||
use crate::{id, modules::store::envelope::Envelope};
|
||||
@@ -31,6 +32,7 @@ use crate::{raise_error, utc_now};
|
||||
use async_imap::types::Fetch;
|
||||
use bytes::Bytes;
|
||||
use mail_parser::{Address, HeaderName, Message, MessageParser, MimeHeaders};
|
||||
use tantivy::TantivyDocument;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -154,12 +156,43 @@ async fn extract_envelope_core(
|
||||
let attachment_count = message.attachment_count();
|
||||
let attachments = detach_and_store_attachments(body, &message, &email_content_hash).await;
|
||||
|
||||
let inline_with_id_count = attachments
|
||||
let envelope_id = Uuid::new_v4().to_string();
|
||||
let now = utc_now!();
|
||||
|
||||
let attachment_docs: Vec<TantivyDocument> = attachments
|
||||
.iter()
|
||||
.filter(|a| a.inline && a.content_id.is_some())
|
||||
.count();
|
||||
.filter(|a| !a.inline || a.content_id.is_none())
|
||||
.map(|a| AttachmentModel {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
envelope_id: envelope_id.clone(),
|
||||
account_id,
|
||||
account_email: None,
|
||||
mailbox_id,
|
||||
mailbox_name: None,
|
||||
subject: subject.clone(),
|
||||
content_hash: a.content_hash.clone(),
|
||||
from: from.clone(),
|
||||
date,
|
||||
ingest_at: now,
|
||||
size: a.size as u64,
|
||||
ext: a.get_extension(),
|
||||
category: a.get_category().to_string(),
|
||||
content_type: a.file_type.clone(),
|
||||
shard_id: 0,
|
||||
text: None,
|
||||
has_text: false,
|
||||
is_ocr: false,
|
||||
page_count: None,
|
||||
is_indexed: false,
|
||||
is_message: a.is_message,
|
||||
name: a.filename.clone(),
|
||||
tags: None,
|
||||
auto_tags: None,
|
||||
}).map(|a|a.into_document())
|
||||
.collect();
|
||||
|
||||
let envelope = Envelope {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
id: envelope_id,
|
||||
message_id,
|
||||
account_id,
|
||||
mailbox_id,
|
||||
@@ -172,11 +205,11 @@ async fn extract_envelope_core(
|
||||
bcc,
|
||||
date,
|
||||
internal_date,
|
||||
ingest_at: utc_now!(),
|
||||
ingest_at: now,
|
||||
size,
|
||||
thread_id,
|
||||
attachment_count,
|
||||
regular_attachment_count: attachment_count - inline_with_id_count,
|
||||
regular_attachment_count: attachment_docs.len(),
|
||||
tags: None,
|
||||
account_email: None,
|
||||
mailbox_name: None,
|
||||
@@ -188,7 +221,10 @@ async fn extract_envelope_core(
|
||||
attachments: Some(attachments),
|
||||
};
|
||||
let doc = ea.to_document(&body_text, 0)?;
|
||||
INDEX_MANAGER.queue(doc).await;
|
||||
ENVELOPE_MANAGER.queue(doc).await;
|
||||
for doc in attachment_docs {
|
||||
ATTACHMENT_MANAGER.queue(doc).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -369,7 +405,7 @@ pub async fn reattach_eml_content(
|
||||
account_id: u64,
|
||||
envelope_id: String,
|
||||
) -> BichonResult<(Envelope, Bytes)> {
|
||||
let e = INDEX_MANAGER
|
||||
let e = ENVELOPE_MANAGER
|
||||
.get_envelope_by_id(account_id, &envelope_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::modules::{
|
||||
cache::imap::mailbox::MailBox, error::BichonResult, store::tantivy::manager::INDEX_MANAGER,
|
||||
cache::imap::mailbox::MailBox, error::BichonResult, store::tantivy::envelope::ENVELOPE_MANAGER,
|
||||
};
|
||||
|
||||
pub async fn delete_mailbox_impl(account_id: u64, mailbox_id: u64) -> BichonResult<()> {
|
||||
@@ -42,7 +42,7 @@ pub async fn delete_mailbox_impl(account_id: u64, mailbox_id: u64) -> BichonResu
|
||||
MailBox::delete(*id).await?;
|
||||
}
|
||||
|
||||
INDEX_MANAGER
|
||||
ENVELOPE_MANAGER
|
||||
.delete_mailbox_envelopes(account_id, ids_to_delete.clone())
|
||||
.await?;
|
||||
Ok(())
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::modules::store::tantivy::manager::INDEX_MANAGER;
|
||||
use crate::modules::store::tantivy::envelope::ENVELOPE_MANAGER;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub async fn delete_messages_impl(request: HashMap<u64, Vec<String>>) -> BichonResult<()> {
|
||||
INDEX_MANAGER.delete_envelopes_multi_account(request).await
|
||||
ENVELOPE_MANAGER.delete_envelopes_multi_account(request).await
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::modules::{
|
||||
account::migration::AccountModel,
|
||||
error::BichonResult,
|
||||
rest::response::DataPage,
|
||||
store::{envelope::Envelope, tantivy::manager::INDEX_MANAGER},
|
||||
store::{envelope::Envelope, tantivy::envelope::ENVELOPE_MANAGER},
|
||||
};
|
||||
|
||||
pub async fn get_thread_messages(
|
||||
@@ -30,7 +30,7 @@ pub async fn get_thread_messages(
|
||||
page_size: u64,
|
||||
) -> BichonResult<DataPage<Envelope>> {
|
||||
AccountModel::check_account_exists(account_id).await?;
|
||||
INDEX_MANAGER
|
||||
ENVELOPE_MANAGER
|
||||
.list_thread_envelopes(account_id, thread_id, page, page_size, true)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -24,13 +24,18 @@ use crate::{
|
||||
modules::{
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
rest::response::DataPage,
|
||||
store::{envelope::Envelope, tantivy::manager::INDEX_MANAGER},
|
||||
store::{
|
||||
envelope::Envelope,
|
||||
tantivy::{
|
||||
attachment::ATTACHMENT_MANAGER, envelope::ENVELOPE_MANAGER, model::AttachmentModel,
|
||||
},
|
||||
},
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct SearchFilter {
|
||||
pub struct EmailSearchFilter {
|
||||
pub text: Option<String>,
|
||||
pub subject: Option<String>,
|
||||
pub body: Option<String>,
|
||||
@@ -61,14 +66,14 @@ pub enum SortBy {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct SearchRequest {
|
||||
filter: SearchFilter,
|
||||
pub struct EmailSearchRequest {
|
||||
filter: EmailSearchFilter,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
sort_by: Option<SortBy>,
|
||||
desc: Option<bool>,
|
||||
}
|
||||
impl SearchRequest {
|
||||
impl EmailSearchRequest {
|
||||
pub fn validate(&self) -> BichonResult<()> {
|
||||
if self.page == 0 || self.page_size == 0 {
|
||||
return Err(raise_error!(
|
||||
@@ -89,10 +94,81 @@ impl SearchRequest {
|
||||
|
||||
pub async fn search_messages_impl(
|
||||
accounts: Option<HashSet<u64>>,
|
||||
request: SearchRequest,
|
||||
request: EmailSearchRequest,
|
||||
) -> BichonResult<DataPage<Envelope>> {
|
||||
request.validate()?;
|
||||
INDEX_MANAGER
|
||||
ENVELOPE_MANAGER
|
||||
.search(
|
||||
accounts,
|
||||
request.filter,
|
||||
request.page,
|
||||
request.page_size,
|
||||
request.desc.unwrap_or(true),
|
||||
request.sort_by.unwrap_or(SortBy::DATE),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct AttachmentSearchFilter {
|
||||
pub text: Option<String>,
|
||||
pub subject: Option<String>,
|
||||
pub from: Option<String>,
|
||||
pub since: Option<i64>,
|
||||
pub before: Option<i64>,
|
||||
pub account_ids: Option<HashSet<u64>>,
|
||||
pub mailbox_ids: Option<HashSet<u64>>,
|
||||
pub min_size: Option<u64>,
|
||||
pub max_size: Option<u64>,
|
||||
|
||||
pub attachment_name: Option<String>,
|
||||
|
||||
pub tags: Option<HashSet<String>>,
|
||||
pub attachment_extension: Option<String>,
|
||||
pub attachment_category: Option<String>,
|
||||
pub attachment_content_type: Option<String>,
|
||||
|
||||
pub is_ocr: Option<bool>,
|
||||
pub is_message: Option<bool>,
|
||||
pub has_text: Option<bool>,
|
||||
|
||||
pub min_page_count: Option<u64>,
|
||||
pub max_page_count: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct AttachmentSearchRequest {
|
||||
filter: AttachmentSearchFilter,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
sort_by: Option<SortBy>,
|
||||
desc: Option<bool>,
|
||||
}
|
||||
impl AttachmentSearchRequest {
|
||||
pub fn validate(&self) -> BichonResult<()> {
|
||||
if self.page == 0 || self.page_size == 0 {
|
||||
return Err(raise_error!(
|
||||
"Both page and page_size must be greater than 0.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
if self.page_size > 500 {
|
||||
return Err(raise_error!(
|
||||
"The page_size exceeds the maximum allowed limit of 500.".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_attachment_impl(
|
||||
accounts: Option<HashSet<u64>>,
|
||||
request: AttachmentSearchRequest,
|
||||
) -> BichonResult<DataPage<AttachmentModel>> {
|
||||
request.validate()?;
|
||||
ATTACHMENT_MANAGER
|
||||
.search(
|
||||
accounts,
|
||||
request.filter,
|
||||
|
||||
@@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct TagsRequest {
|
||||
pub updates: HashMap<u64, Vec<String>>, // account_id -> envelope_ids
|
||||
pub updates: HashMap<u64, Vec<String>>,
|
||||
pub tags: Vec<String>,
|
||||
pub action: TagAction,
|
||||
}
|
||||
|
||||
@@ -27,13 +27,11 @@ use crate::modules::account::state::DownloadState;
|
||||
use crate::modules::account::view::AccountResp;
|
||||
use crate::modules::common::auth::ClientContext;
|
||||
use crate::modules::common::paginated::paginate_vec;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::response::DataPage;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use crate::modules::users::UserModel;
|
||||
use crate::raise_error;
|
||||
use poem_openapi::param::{Path, Query};
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::OpenApi;
|
||||
@@ -201,12 +199,8 @@ impl AccountApi {
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
|
||||
.await?;
|
||||
let state = DownloadState::get(account_id).await?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"account download state is not found".into(),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
let state = DownloadState::get(account_id).await?;
|
||||
let state = state.unwrap_or(DownloadState::empty(account_id));
|
||||
Ok(Json(state))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//
|
||||
// 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::modules::common::auth::ClientContext;
|
||||
use crate::modules::message::attachment::AttachmentMetadata;
|
||||
use crate::modules::message::search::search_attachment_impl;
|
||||
use crate::modules::message::search::AttachmentSearchRequest;
|
||||
use crate::modules::message::tags::TagCount;
|
||||
use crate::modules::message::tags::TagsRequest;
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
use crate::modules::rest::response::DataPage;
|
||||
use crate::modules::rest::ApiResult;
|
||||
use crate::modules::rest::ErrorCode;
|
||||
use crate::modules::store::tantivy::attachment::ATTACHMENT_MANAGER;
|
||||
use crate::modules::store::tantivy::model::AttachmentModel;
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use crate::modules::utils::validate_tag;
|
||||
use crate::raise_error;
|
||||
use poem_openapi::param::Path;
|
||||
use poem_openapi::payload::Json;
|
||||
use poem_openapi::OpenApi;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct AttachmentApi;
|
||||
|
||||
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Attachment")]
|
||||
impl AttachmentApi {
|
||||
/// Searches messages across all mailboxes using various filter criteria.
|
||||
/// The search filters are provided in the request body.
|
||||
#[oai(
|
||||
path = "/search-attachment",
|
||||
method = "post",
|
||||
operation_id = "search_attachment"
|
||||
)]
|
||||
async fn search_attachment(
|
||||
&self,
|
||||
payload: Json<AttachmentSearchRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<DataPage<AttachmentModel>>> {
|
||||
let authorized_ids: Option<HashSet<u64>> = if context
|
||||
.has_permission(None, Permission::DATA_READ_ALL)
|
||||
.await
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
Ok(Json(
|
||||
search_attachment_impl(authorized_ids, payload.0).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Retrieves the attachment (metadata) of a specific message.
|
||||
#[oai(
|
||||
path = "/attachment/:account_id/:attachment_id",
|
||||
method = "get",
|
||||
operation_id = "get_attachment"
|
||||
)]
|
||||
async fn get_attachment(
|
||||
&self,
|
||||
/// The ID of the account.
|
||||
account_id: Path<u64>,
|
||||
/// The ID of the attachment.
|
||||
attachment_id: Path<String>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<AttachmentModel>> {
|
||||
let account_id = account_id.0;
|
||||
context
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.await?;
|
||||
let attachment_id = attachment_id.0;
|
||||
let a = ATTACHMENT_MANAGER
|
||||
.get_attachment_by_id(account_id, &attachment_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Attachment not found: account_id={} envelope_id={}",
|
||||
account_id, &attachment_id
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
Ok(Json(a))
|
||||
}
|
||||
|
||||
/// Returns all facets in the index along with their document counts.
|
||||
#[oai(
|
||||
path = "/all-attachment-tags",
|
||||
method = "get",
|
||||
operation_id = "get_all_attachment_tags"
|
||||
)]
|
||||
async fn get_all_attachment_tags(
|
||||
&self,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<Vec<TagCount>>> {
|
||||
let authorized_ids: Option<HashSet<u64>> = if context
|
||||
.has_permission(None, Permission::DATA_READ_ALL)
|
||||
.await
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
Ok(Json(ATTACHMENT_MANAGER.get_all_tags(authorized_ids).await?))
|
||||
}
|
||||
|
||||
/// Adds or removes facet tags for multiple emails across accounts.
|
||||
#[oai(
|
||||
path = "/update-attachment-tags",
|
||||
method = "post",
|
||||
operation_id = "update_attachment_tags"
|
||||
)]
|
||||
async fn update_attachment_tags(
|
||||
&self,
|
||||
req: Json<TagsRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<()> {
|
||||
let req = req.0;
|
||||
for tag in &req.tags {
|
||||
validate_tag(tag)
|
||||
.map_err(|e| raise_error!(format!("{}", e), ErrorCode::InvalidParameter))?;
|
||||
}
|
||||
|
||||
for account_id in req.updates.keys() {
|
||||
context
|
||||
.require_permission(Some(*account_id), Permission::DATA_MANAGE)
|
||||
.await?;
|
||||
}
|
||||
|
||||
ATTACHMENT_MANAGER.update_attachment_tags(req).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieves a unique list of all contact email addresses across authorized accounts.
|
||||
#[oai(
|
||||
path = "/attachment-senders",
|
||||
method = "get",
|
||||
operation_id = "get_attachment_senders"
|
||||
)]
|
||||
async fn get_attachment_senders(
|
||||
&self,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<HashSet<String>>> {
|
||||
let authorized_ids: Option<HashSet<u64>> = if context
|
||||
.has_permission(None, Permission::DATA_READ_ALL)
|
||||
.await
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
Ok(Json(
|
||||
ATTACHMENT_MANAGER.get_all_senders(authorized_ids).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Retrieves unique metadata for all attachments across authorized accounts.
|
||||
#[oai(
|
||||
path = "/attachment_metadata",
|
||||
method = "get",
|
||||
operation_id = "get_attachment_metadata"
|
||||
)]
|
||||
async fn get_attachment_metadata(
|
||||
&self,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<AttachmentMetadata>> {
|
||||
let authorized_ids: Option<HashSet<u64>> = if context
|
||||
.has_permission(None, Permission::DATA_READ_ALL)
|
||||
.await
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
Ok(Json(
|
||||
ATTACHMENT_MANAGER.collect_attachment_metadata(authorized_ids)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,12 @@ use crate::modules::message::append::restore_emails;
|
||||
use crate::modules::message::append::RestoreMessagesRequest;
|
||||
use crate::modules::message::attachment::retrieve_attachment_content;
|
||||
use crate::modules::message::attachment::retrieve_nested_attachment_content;
|
||||
use crate::modules::message::attachment::AttachmentMetadata;
|
||||
use crate::modules::message::content::retrieve_nested_eml_content;
|
||||
use crate::modules::message::content::FullNestedMessageContent;
|
||||
use crate::modules::message::content::{retrieve_email_content, FullMessageContent};
|
||||
use crate::modules::message::delete::delete_messages_impl;
|
||||
use crate::modules::message::list::get_thread_messages;
|
||||
use crate::modules::message::search::{search_messages_impl, SearchRequest};
|
||||
use crate::modules::message::search::{search_messages_impl, EmailSearchRequest};
|
||||
use crate::modules::message::tags::TagCount;
|
||||
use crate::modules::message::tags::TagsRequest;
|
||||
use crate::modules::rest::api::ApiTags;
|
||||
@@ -37,7 +36,7 @@ use crate::modules::rest::ApiResult;
|
||||
use crate::modules::rest::ErrorCode;
|
||||
use crate::modules::store::envelope::Envelope;
|
||||
use crate::modules::store::storage::get_reader;
|
||||
use crate::modules::store::tantivy::manager::INDEX_MANAGER;
|
||||
use crate::modules::store::tantivy::envelope::ENVELOPE_MANAGER;
|
||||
use crate::modules::users::permissions::Permission;
|
||||
use crate::modules::utils::validate_tag;
|
||||
use crate::raise_error;
|
||||
@@ -82,7 +81,7 @@ impl MessageApi {
|
||||
)]
|
||||
async fn search_messages(
|
||||
&self,
|
||||
payload: Json<SearchRequest>,
|
||||
payload: Json<EmailSearchRequest>,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<DataPage<Envelope>>> {
|
||||
let authorized_ids: Option<HashSet<u64>> = if context
|
||||
@@ -191,7 +190,7 @@ impl MessageApi {
|
||||
.require_permission(Some(account_id), Permission::DATA_READ)
|
||||
.await?;
|
||||
let envelope_id = envelope_id.0;
|
||||
let e = INDEX_MANAGER
|
||||
let e = ENVELOPE_MANAGER
|
||||
.get_envelope_by_id(account_id, &envelope_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
@@ -335,7 +334,7 @@ impl MessageApi {
|
||||
} else {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
Ok(Json(INDEX_MANAGER.get_all_tags(authorized_ids).await?))
|
||||
Ok(Json(ENVELOPE_MANAGER.get_all_tags(authorized_ids).await?))
|
||||
}
|
||||
|
||||
/// Adds or removes facet tags for multiple emails across accounts.
|
||||
@@ -361,7 +360,7 @@ impl MessageApi {
|
||||
.await?;
|
||||
}
|
||||
|
||||
INDEX_MANAGER.update_envelope_tags(req).await?;
|
||||
ENVELOPE_MANAGER.update_envelope_tags(req).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -372,27 +371,6 @@ impl MessageApi {
|
||||
operation_id = "get_all_contacts"
|
||||
)]
|
||||
async fn get_all_contacts(&self, context: ClientContext) -> ApiResult<Json<HashSet<String>>> {
|
||||
let authorized_ids: Option<HashSet<u64>> = if context
|
||||
.has_permission(None, Permission::DATA_READ_ALL)
|
||||
.await
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
Ok(Json(INDEX_MANAGER.get_all_contacts(authorized_ids).await?))
|
||||
}
|
||||
|
||||
/// Retrieves unique metadata for all attachments across authorized accounts.
|
||||
#[oai(
|
||||
path = "/attachment_metadata",
|
||||
method = "get",
|
||||
operation_id = "get_attachment_metadata"
|
||||
)]
|
||||
async fn get_attachment_metadata(
|
||||
&self,
|
||||
context: ClientContext,
|
||||
) -> ApiResult<Json<AttachmentMetadata>> {
|
||||
let authorized_ids: Option<HashSet<u64>> = if context
|
||||
.has_permission(None, Permission::DATA_READ_ALL)
|
||||
.await
|
||||
@@ -402,7 +380,7 @@ impl MessageApi {
|
||||
Some(context.user.account_access_map.keys().cloned().collect())
|
||||
};
|
||||
Ok(Json(
|
||||
INDEX_MANAGER.collect_attachment_metadata(authorized_ids)?,
|
||||
ENVELOPE_MANAGER.get_all_contacts(authorized_ids).await?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,12 @@ use system::SystemApi;
|
||||
|
||||
use crate::{
|
||||
bichon_version,
|
||||
modules::rest::api::{import::ImportApi, users::UsersApi},
|
||||
modules::rest::api::{attachment::AttachmentApi, import::ImportApi, users::UsersApi},
|
||||
};
|
||||
|
||||
pub mod access_token;
|
||||
pub mod account;
|
||||
pub mod attachment;
|
||||
pub mod auto_config;
|
||||
pub mod import;
|
||||
pub mod mailbox;
|
||||
@@ -43,6 +44,7 @@ pub mod users;
|
||||
#[derive(Tags)]
|
||||
pub enum ApiTags {
|
||||
AccessToken,
|
||||
Attachment,
|
||||
AutoConfig,
|
||||
Account,
|
||||
Mailbox,
|
||||
@@ -55,6 +57,7 @@ pub enum ApiTags {
|
||||
|
||||
type RustMailOpenApi = (
|
||||
AccessTokenApi,
|
||||
AttachmentApi,
|
||||
AutoConfigApi,
|
||||
AccountApi,
|
||||
SystemApi,
|
||||
@@ -69,6 +72,7 @@ pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
|
||||
OpenApiService::new(
|
||||
(
|
||||
AccessTokenApi,
|
||||
AttachmentApi,
|
||||
AutoConfigApi,
|
||||
AccountApi,
|
||||
SystemApi,
|
||||
|
||||
+17
-13
@@ -27,11 +27,13 @@ use std::sync::LazyLock;
|
||||
|
||||
pub const META_FILE: &str = "meta.db";
|
||||
pub const MAILBOX_FILE: &str = "mailbox.db";
|
||||
const ENVELOPE_DIR: &str = "envelope";
|
||||
const EML_DIR: &str = "bichon-emls";
|
||||
const INDICES: &str = "bichon-indices";
|
||||
const MAIL_METADATA: &str = "mail_metadata";
|
||||
const ATTACHMENT_METADATA: &str = "attachment_metadata";
|
||||
const STORAGE: &str = "bichon-storage";
|
||||
const TMP_DIR: &str = "tmp";
|
||||
const LOG_DIR: &str = "logs";
|
||||
const TANTIVY_DIR: &str = "tantivy";
|
||||
|
||||
const TLS_CERT: &str = "cert.pem";
|
||||
const TLS_KEY: &str = "key.pem";
|
||||
|
||||
@@ -46,8 +48,9 @@ pub struct DataDirManager {
|
||||
pub temp_dir: PathBuf,
|
||||
pub tls_cert: PathBuf,
|
||||
pub tls_key: PathBuf,
|
||||
pub tantivy_dir: PathBuf,
|
||||
pub eml_dir: PathBuf,
|
||||
pub envelope_dir: PathBuf,
|
||||
pub attachment_dir: PathBuf,
|
||||
pub storage_dir: PathBuf,
|
||||
pub log_dir: PathBuf,
|
||||
}
|
||||
|
||||
@@ -59,7 +62,7 @@ impl Initialize for DataDirManager {
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
std::fs::create_dir_all(&DATA_DIR_MANAGER.temp_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
std::fs::create_dir_all(&DATA_DIR_MANAGER.eml_dir)
|
||||
std::fs::create_dir_all(&DATA_DIR_MANAGER.storage_dir)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -68,15 +71,15 @@ impl Initialize for DataDirManager {
|
||||
impl DataDirManager {
|
||||
pub fn new(root_dir: PathBuf) -> Self {
|
||||
let index_dir = if let Some(ref index_dir) = SETTINGS.bichon_index_dir {
|
||||
PathBuf::from(index_dir)
|
||||
PathBuf::from(index_dir).join(INDICES)
|
||||
} else {
|
||||
root_dir.join(ENVELOPE_DIR)
|
||||
root_dir.join(INDICES)
|
||||
};
|
||||
|
||||
let eml_dir = if let Some(ref data_dir) = SETTINGS.bichon_data_dir {
|
||||
PathBuf::from(data_dir).join(EML_DIR)
|
||||
let storage_dir = if let Some(ref data_dir) = SETTINGS.bichon_data_dir {
|
||||
PathBuf::from(data_dir).join(STORAGE)
|
||||
} else {
|
||||
root_dir.join(EML_DIR)
|
||||
root_dir.join(STORAGE)
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -86,9 +89,10 @@ impl DataDirManager {
|
||||
tls_key: root_dir.join(TLS_KEY),
|
||||
tls_cert: root_dir.join(TLS_CERT),
|
||||
log_dir: root_dir.join(LOG_DIR),
|
||||
tantivy_dir: index_dir.join(TANTIVY_DIR),
|
||||
envelope_dir: index_dir.join(MAIL_METADATA),
|
||||
attachment_dir: index_dir.join(ATTACHMENT_METADATA),
|
||||
temp_dir: root_dir.join(TMP_DIR),
|
||||
eml_dir,
|
||||
storage_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ impl BlobManager {
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
let db = Database::builder(&DATA_DIR_MANAGER.eml_dir)
|
||||
let db = Database::builder(&DATA_DIR_MANAGER.storage_dir)
|
||||
.cache_size(64 * 1024 * 1024)
|
||||
.max_cached_files(Some(400))
|
||||
.journal_compression(CompressionType::None)
|
||||
|
||||
@@ -0,0 +1,984 @@
|
||||
//
|
||||
// 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::{
|
||||
modules::{
|
||||
common::signal::SIGNAL_MANAGER,
|
||||
dashboard::{Group, LargestAttachment},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
message::{
|
||||
attachment::AttachmentMetadata,
|
||||
search::{AttachmentSearchFilter, SortBy},
|
||||
tags::{TagAction, TagCount, TagsRequest},
|
||||
},
|
||||
rest::response::DataPage,
|
||||
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::{extract_senders, AttachmentModel},
|
||||
schema::SchemaTools,
|
||||
},
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
|
||||
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(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(10).order_by_fast_field(F_SIZE, Order::Desc),
|
||||
)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||
|
||||
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 att = LargestAttachment::from_tantivy_doc(&doc)?;
|
||||
result.push(att);
|
||||
}
|
||||
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_envelopes(
|
||||
&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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,7 @@ use crate::{
|
||||
dashboard::{DashboardStats, Group, LargestEmail, TimeBucket},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
message::{
|
||||
attachment::AttachmentMetadata,
|
||||
search::{SearchFilter, SortBy},
|
||||
search::{EmailSearchFilter, SortBy},
|
||||
tags::{TagAction, TagCount, TagsRequest},
|
||||
},
|
||||
rest::response::DataPage,
|
||||
@@ -41,9 +40,9 @@ use crate::{
|
||||
envelope::Envelope,
|
||||
storage::BLOB_MANAGER,
|
||||
tantivy::{
|
||||
fatal_commit,
|
||||
fields::{
|
||||
F_ACCOUNT_ID, F_ATTACHMENT_CATEGORY, F_ATTACHMENT_CONTENT_TYPE,
|
||||
F_ATTACHMENT_EXT, F_DATE, F_FROM, F_REGULAR_ATTACHMENT_COUNT, F_SIZE, F_TAGS,
|
||||
F_ACCOUNT_ID, F_DATE, F_FROM, F_REGULAR_ATTACHMENT_COUNT, F_SIZE, F_TAGS,
|
||||
F_THREAD_ID, F_UID,
|
||||
},
|
||||
model::{extract_contacts, EnvelopeWithAttachments},
|
||||
@@ -77,9 +76,10 @@ use tokio::{
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub static INDEX_MANAGER: LazyLock<IndexManager> = LazyLock::new(IndexManager::new);
|
||||
pub static ENVELOPE_MANAGER: LazyLock<IndexManager> = LazyLock::new(IndexManager::new);
|
||||
|
||||
pub struct IndexManager {
|
||||
index: Arc<Index>,
|
||||
index_writer: Arc<Mutex<IndexWriter>>,
|
||||
sender: mpsc::Sender<TantivyDocument>,
|
||||
reader: IndexReader,
|
||||
@@ -95,18 +95,18 @@ impl IndexManager {
|
||||
}
|
||||
}
|
||||
pub fn new() -> Self {
|
||||
let index = Self::open_or_create_index(&DATA_DIR_MANAGER.tantivy_dir);
|
||||
let index = Self::open_or_create_index(&DATA_DIR_MANAGER.envelope_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, 134_217_728)
|
||||
.writer_with_num_threads(4, 67_108_864)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to create IndexWriter with 4 threads and 128MB buffer for {:?}: {}",
|
||||
&DATA_DIR_MANAGER.tantivy_dir, e
|
||||
"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));
|
||||
@@ -114,7 +114,7 @@ impl IndexManager {
|
||||
let reader = index.reader().unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to create IndexReader for {:?}: {}",
|
||||
&DATA_DIR_MANAGER.tantivy_dir, e
|
||||
&DATA_DIR_MANAGER.envelope_dir, e
|
||||
)
|
||||
});
|
||||
let mut query_parser = QueryParser::for_index(&index, SchemaTools::email_default_fields());
|
||||
@@ -125,7 +125,7 @@ impl IndexManager {
|
||||
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(30));
|
||||
let mut commit_interval = tokio::time::interval(Duration::from_secs(60));
|
||||
let mut pending_count = 0;
|
||||
let commit_threshold = 1000;
|
||||
loop {
|
||||
@@ -134,17 +134,33 @@ impl IndexManager {
|
||||
match maybe_msg {
|
||||
Some(doc) => {
|
||||
let mut writer = writer.lock().await;
|
||||
if let Err(e) = writer.add_document(doc) {
|
||||
eprintln!("[ERROR] Failed to add document: {e:?}");
|
||||
tracing::error!("Tantivy: Failed to add document: {e:?}");
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
pending_count += 1;
|
||||
while let Ok(next_doc) = receiver.try_recv() {
|
||||
let _ = writer.add_document(next_doc);
|
||||
pending_count += 1;
|
||||
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 ({}), committing...", pending_count);
|
||||
tracing::info!(
|
||||
"Tantivy: Reached threshold ({} docs), committing...",
|
||||
pending_count
|
||||
);
|
||||
fatal_commit(&mut writer);
|
||||
pending_count = 0;
|
||||
commit_interval.reset();
|
||||
@@ -181,6 +197,7 @@ impl IndexManager {
|
||||
}
|
||||
});
|
||||
Self {
|
||||
index: Arc::new(index),
|
||||
index_writer,
|
||||
sender,
|
||||
reader,
|
||||
@@ -261,7 +278,7 @@ impl IndexManager {
|
||||
fn filter_query(
|
||||
&self,
|
||||
accounts: Option<HashSet<u64>>,
|
||||
filter: SearchFilter,
|
||||
filter: EmailSearchFilter,
|
||||
parser: QueryParser,
|
||||
) -> BichonResult<Box<dyn Query>> {
|
||||
let f = SchemaTools::email_fields();
|
||||
@@ -359,8 +376,15 @@ impl IndexManager {
|
||||
}
|
||||
|
||||
if let Some(ref name) = filter.attachment_name {
|
||||
if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachment_name) {
|
||||
subqueries.push((Occur::Must, Box::new(query)));
|
||||
if name.contains('.') {
|
||||
let term = Term::from_field_text(f.f_attachment_name_exact, name);
|
||||
let query = TermQuery::new(term, IndexRecordOption::Basic);
|
||||
subqueries.push((Occur::Should, Box::new(query)));
|
||||
}
|
||||
|
||||
let query_parser = QueryParser::for_index(&self.index, vec![f.f_attachment_name_text]);
|
||||
if let Ok(q) = query_parser.parse_query(name) {
|
||||
subqueries.push((Occur::Must, q));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -978,7 +1002,7 @@ impl IndexManager {
|
||||
pub async fn search(
|
||||
&self,
|
||||
accounts: Option<HashSet<u64>>,
|
||||
filter: SearchFilter,
|
||||
filter: EmailSearchFilter,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
desc: bool,
|
||||
@@ -1179,102 +1203,6 @@ impl IndexManager {
|
||||
})
|
||||
}
|
||||
|
||||
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::email_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,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_dashboard_stats(
|
||||
&self,
|
||||
accounts: &Option<HashSet<u64>>,
|
||||
@@ -1422,7 +1350,7 @@ impl IndexManager {
|
||||
let account_id = *account_id;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
INDEX_MANAGER.delete_account_envelopes(account_id).await
|
||||
ENVELOPE_MANAGER.delete_account_envelopes(account_id).await
|
||||
{
|
||||
tracing::error!(
|
||||
account_id = account_id,
|
||||
@@ -1469,46 +1397,3 @@ impl IndexManager {
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,24 @@ 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: &str = "attachment_name";
|
||||
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 {
|
||||
@@ -70,7 +81,8 @@ pub struct EmailFields {
|
||||
pub f_attachment_count: Field,
|
||||
pub f_regular_attachment_count: Field,
|
||||
pub f_attachments: Field,
|
||||
pub f_attachment_name: 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,
|
||||
@@ -78,3 +90,30 @@ pub struct EmailFields {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -16,7 +16,53 @@
|
||||
// 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 manager;
|
||||
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);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use tantivy::{schema::Value, TantivyDocument};
|
||||
|
||||
@@ -7,7 +9,19 @@ use crate::{
|
||||
cache::imap::mailbox::MailBox,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
message::content::AttachmentInfo,
|
||||
store::{envelope::Envelope, tantivy::schema::SchemaTools},
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
@@ -56,7 +70,8 @@ impl EnvelopeWithAttachments {
|
||||
for att in atts {
|
||||
if !att.is_inline() {
|
||||
if let Some(ref filename) = att.filename {
|
||||
doc.add_text(fields.f_attachment_name, 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);
|
||||
@@ -85,7 +100,7 @@ impl EnvelopeWithAttachments {
|
||||
pub fn from_tantivy_doc(doc: &TantivyDocument) -> BichonResult<Self> {
|
||||
let fields = SchemaTools::email_fields();
|
||||
|
||||
let attachments_raw = extract_string_field(doc, fields.f_attachments).ok();
|
||||
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());
|
||||
|
||||
@@ -95,35 +110,39 @@ impl EnvelopeWithAttachments {
|
||||
.map(|f| f.to_string())
|
||||
.collect();
|
||||
|
||||
let account_id = extract_u64_field(doc, fields.f_account_id)?;
|
||||
let mailbox_id = extract_u64_field(doc, fields.f_mailbox_id)?;
|
||||
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)?,
|
||||
message_id: extract_string_field(doc, fields.f_message_id)?,
|
||||
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)? as u32,
|
||||
subject: extract_string_field(doc, fields.f_subject)?,
|
||||
preview: extract_string_field(doc, fields.f_preview).unwrap_or_default(),
|
||||
from: extract_string_field(doc, fields.f_from)?,
|
||||
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)?,
|
||||
internal_date: extract_i64_field(doc, fields.f_internal_date)?,
|
||||
size: extract_u64_field(doc, fields.f_size)? as u32,
|
||||
thread_id: extract_string_field(doc, fields.f_thread_id)?,
|
||||
attachment_count: extract_u64_field(doc, fields.f_attachment_count)? as usize,
|
||||
regular_attachment_count: extract_u64_field(doc, fields.f_regular_attachment_count)?
|
||||
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)?,
|
||||
ingest_at: extract_i64_field(doc, fields.f_ingest_at)?,
|
||||
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 {
|
||||
@@ -136,16 +155,37 @@ impl EnvelopeWithAttachments {
|
||||
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", stringify!(field)),
|
||||
format!("miss '{}' field in tantivy document", field_name),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
value.as_u64().ok_or_else(|| {
|
||||
value.as_bool().ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("'{}' field is not a u64", stringify!(field)),
|
||||
format!("'{}' field is not a u64", field_name),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})
|
||||
@@ -154,16 +194,17 @@ fn extract_u64_field(
|
||||
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", stringify!(field)),
|
||||
format!("miss '{}' field in tantivy document", field_name),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
value.as_i64().ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("'{}' field is not a i64", stringify!(field)),
|
||||
format!("'{}' field is not a i64", field_name),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})
|
||||
@@ -172,21 +213,26 @@ fn extract_i64_field(
|
||||
fn extract_string_field(
|
||||
document: &TantivyDocument,
|
||||
field: tantivy::schema::Field,
|
||||
field_name: &str,
|
||||
) -> BichonResult<String> {
|
||||
let value = document.get_first(field).ok_or_else(|| {
|
||||
extract_option_string_field(document, field)?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("'{}' field not found", stringify!(field)),
|
||||
ErrorCode::InternalError
|
||||
)
|
||||
})?;
|
||||
value.as_str().map(|s| s.to_string()).ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("'{}' field is not a string", stringify!(field)),
|
||||
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,
|
||||
@@ -202,7 +248,7 @@ pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<Str
|
||||
let fields = SchemaTools::email_fields();
|
||||
let mut all_contacts = HashSet::new();
|
||||
|
||||
if let Ok(from_val) = extract_string_field(doc, fields.f_from) {
|
||||
if let Ok(from_val) = extract_string_field(doc, fields.f_from, F_FROM) {
|
||||
if !from_val.is_empty() {
|
||||
all_contacts.insert(from_val);
|
||||
}
|
||||
@@ -222,3 +268,141 @@ pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<Str
|
||||
|
||||
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, 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(|f| f.to_string())
|
||||
.collect();
|
||||
|
||||
let auto_tags: Vec<String> = doc
|
||||
.get_all(f.f_auto_tags)
|
||||
.filter_map(|value| value.as_facet())
|
||||
.map(|f| f.to_string())
|
||||
.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)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ use tantivy::schema::{FacetOptions, Field, INDEXED};
|
||||
use tantivy::schema::{Schema, FAST, STORED, STRING, TEXT};
|
||||
|
||||
use crate::modules::store::tantivy::fields::{
|
||||
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, F_BCC,
|
||||
F_BODY, F_CC, F_CONTENT_HASH, F_DATE, F_FROM, F_ID, F_INGEST_AT, F_INTERNAL_DATE, F_MAILBOX_ID,
|
||||
F_MESSAGE_ID, F_PREVIEW, F_REGULAR_ATTACHMENT_COUNT, F_SHARD_ID, F_SIZE, F_SUBJECT, F_TAGS,
|
||||
F_THREAD_ID, F_TO, F_UID,
|
||||
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(|| {
|
||||
@@ -33,6 +35,11 @@ static EMAIL_FIELDS: LazyLock<Arc<EmailFields>> = LazyLock::new(|| {
|
||||
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 {
|
||||
@@ -50,15 +57,35 @@ impl SchemaTools {
|
||||
vec![
|
||||
fields.f_subject,
|
||||
fields.f_body,
|
||||
fields.f_attachment_name,
|
||||
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);
|
||||
@@ -67,7 +94,7 @@ impl SchemaTools {
|
||||
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 | FAST | 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);
|
||||
@@ -80,20 +107,18 @@ impl SchemaTools {
|
||||
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 = builder.add_text_field(F_ATTACHMENT_NAME, TEXT);
|
||||
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 | FAST | STORED);
|
||||
|
||||
let f_attachment_ext = builder.add_text_field(F_ATTACHMENT_EXT, STRING | FAST | STORED);
|
||||
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 | FAST | STORED);
|
||||
builder.add_text_field(F_ATTACHMENT_CATEGORY, STRING | STORED | FAST);
|
||||
let f_attachment_content_type =
|
||||
builder.add_text_field(F_ATTACHMENT_CONTENT_TYPE, STRING | FAST | STORED);
|
||||
|
||||
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,
|
||||
@@ -116,7 +141,8 @@ impl SchemaTools {
|
||||
f_attachment_count,
|
||||
f_regular_attachment_count,
|
||||
f_attachments,
|
||||
f_attachment_name,
|
||||
f_attachment_name_text,
|
||||
f_attachment_name_exact,
|
||||
f_attachment_content_hash,
|
||||
f_attachment_ext,
|
||||
f_attachment_category,
|
||||
@@ -126,4 +152,61 @@ impl SchemaTools {
|
||||
};
|
||||
(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import axiosInstance from "@/api/axiosInstance";
|
||||
import { PaginatedResponse } from "..";
|
||||
import { TagCount } from "../search/api";
|
||||
|
||||
|
||||
export interface AttachmentModel {
|
||||
id: string;
|
||||
envelope_id: string;
|
||||
account_id: number;
|
||||
account_email: string,
|
||||
mailbox_id: number;
|
||||
mailbox_name: string;
|
||||
subject: string;
|
||||
content_hash: string;
|
||||
from: string;
|
||||
date: number;
|
||||
ingest_at: number;
|
||||
size: number;
|
||||
|
||||
ext?: string;
|
||||
category: string;
|
||||
content_type: string;
|
||||
shard_id: number;
|
||||
text?: string;
|
||||
has_text: boolean;
|
||||
is_ocr: boolean;
|
||||
page_count?: number;
|
||||
|
||||
is_indexed: boolean;
|
||||
is_message: boolean;
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
auto_tags?: string[];
|
||||
}
|
||||
|
||||
export const search_attachment = async (payload: Record<string, any>) => {
|
||||
const response = await axiosInstance.post<PaginatedResponse<AttachmentModel>>("api/v1/search-attachment", payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const get_all_attachment_tags = async () => {
|
||||
const response = await axiosInstance.get<TagCount[]>("api/v1/all-attachment-tags");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const update_attachment_tags = async (data: Record<string, any>) => {
|
||||
const response = await axiosInstance.post("api/v1//update-attachment-tags", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
export const get_attachment_senders = async () => {
|
||||
const response = await axiosInstance.get<string[]>("api/v1/attachment-senders");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ export interface EmailEnvelope {
|
||||
message_id: string;
|
||||
account_id: number;
|
||||
mailbox_id: number;
|
||||
account_email?: string;
|
||||
mailbox_name?: string;
|
||||
account_email: string;
|
||||
mailbox_name: string;
|
||||
uid: number;
|
||||
subject: string;
|
||||
preview: string;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
IconLayoutDashboard,
|
||||
IconSettings
|
||||
} from '@tabler/icons-react'
|
||||
import { IdCard, Inbox, Search, Users2 } from 'lucide-react'
|
||||
import { IdCard, Inbox, Paperclip, Search, Users2 } from 'lucide-react'
|
||||
import { type SidebarData } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCurrentUser } from '@/hooks/use-current-user'
|
||||
@@ -56,6 +56,11 @@ export function useSidebarData(): SidebarData {
|
||||
title: t('common.search'),
|
||||
url: '/search',
|
||||
icon: Search,
|
||||
},
|
||||
{
|
||||
title: t('navigation.attachment'),
|
||||
url: '/attachment',
|
||||
icon: Paperclip,
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@ interface PaginationProps {
|
||||
setPageSize: (pageSize: number) => void
|
||||
}
|
||||
|
||||
export function EnvelopeListPagination({
|
||||
export function AttachmentListPagination({
|
||||
totalItems,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import * as React from 'react'
|
||||
import { AtSign, ChevronDown, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
|
||||
export function AccountPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [search, setSearch] = React.useState('')
|
||||
const { minimalList = [] } = useMinimalAccountList()
|
||||
|
||||
const selectedIds: number[] = filter.account_ids ?? []
|
||||
|
||||
const toggleAccount = (id: number) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
const set = new Set<number>(next.account_ids ?? [])
|
||||
|
||||
if (set.has(id)) {
|
||||
set.delete(id)
|
||||
} else {
|
||||
set.add(id)
|
||||
}
|
||||
|
||||
if (set.size === 0) {
|
||||
delete next.account_ids
|
||||
delete next.mailbox_ids
|
||||
} else {
|
||||
next.account_ids = Array.from(set).sort()
|
||||
delete next.mailbox_ids
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearAccounts = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.account_ids
|
||||
delete next.mailbox_ids
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
|
||||
return minimalList
|
||||
.filter(a =>
|
||||
!q ||
|
||||
a.email.toLowerCase().includes(q) ||
|
||||
String(a.id).includes(q)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aSel = selectedIds.includes(a.id)
|
||||
const bSel = selectedIds.includes(b.id)
|
||||
|
||||
if (aSel && !bSel) return -1
|
||||
if (!aSel && bSel) return 1
|
||||
return a.id - b.id
|
||||
})
|
||||
}, [minimalList, search, selectedIds])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-6 gap-1.5 px-3 rounded-none ',
|
||||
selectedIds.length > 0 &&
|
||||
'bg-primary/10 border-primary text-primary'
|
||||
)}
|
||||
>
|
||||
<AtSign className="h-4 w-4" />
|
||||
{t('search_accounts.label')}
|
||||
{selectedIds.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-5 px-1.5 text-xs"
|
||||
>
|
||||
{selectedIds.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="w-96 p-1">
|
||||
<div className="p-1 pb-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('search_accounts.search_placeholder')}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{!search && selectedIds.length > 0 && (
|
||||
<div className="p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAccounts}
|
||||
className="flex h-8 w-full items-center justify-start gap-2 px-2 text-xs font-medium text-destructive hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="flex-1 text-left">
|
||||
{t('search_accounts.clear_accounts')}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60 font-mono">
|
||||
({selectedIds.length})
|
||||
</span>
|
||||
</Button>
|
||||
<div className="my-1 h-px bg-border/60" />
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('search_accounts.no_accounts_found')}
|
||||
</p>
|
||||
) : (
|
||||
filtered.map(account => {
|
||||
const checked = selectedIds.includes(account.id)
|
||||
const id = `account-${account.id}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
onClick={() => toggleAccount(account.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
|
||||
'hover:bg-accent transition-colors'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={() =>
|
||||
toggleAccount(account.id)
|
||||
}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex-1 truncate text-xs cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">
|
||||
{account.email}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
#{account.id}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import { Paperclip, Check } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearchContext } from './context'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AttachmentFilter() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
|
||||
const hasAttachment = filter?.has_attachment === true
|
||||
|
||||
const toggleAttachment = () => {
|
||||
setFilter((prev) => {
|
||||
const next = { ...prev }
|
||||
if (next.has_attachment) {
|
||||
delete next.has_attachment
|
||||
} else {
|
||||
next.has_attachment = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={toggleAttachment}
|
||||
className={cn(
|
||||
"h-8 px-3 gap-2 transition-all rounded-none flex-shrink-0",
|
||||
hasAttachment
|
||||
? "bg-primary/10 border-primary text-primary hover:bg-primary/20 hover:text-primary z-10"
|
||||
: "text-muted-foreground border-r-0"
|
||||
)}
|
||||
>
|
||||
<Paperclip
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
hasAttachment ? "opacity-100" : "opacity-60"
|
||||
)}
|
||||
/>
|
||||
|
||||
<span className="text-xs font-medium">
|
||||
{t('mail.attachments')}
|
||||
</span>
|
||||
|
||||
{hasAttachment && (
|
||||
<Check className="h-3 w-3 ml-0.5 stroke-[3px] animate-in zoom-in duration-200" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
FileText,
|
||||
FileImage,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileArchive,
|
||||
FileCode,
|
||||
FilePlus,
|
||||
Presentation,
|
||||
FileSpreadsheet,
|
||||
FileLock
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AttachmentIconProps {
|
||||
contentType: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AttachmentIcon({ contentType, className }: AttachmentIconProps) {
|
||||
const type = contentType.toLowerCase();
|
||||
|
||||
const getIconConfig = () => {
|
||||
if (type.includes("pdf")) {
|
||||
return { Icon: FileText, color: "text-red-600" };
|
||||
}
|
||||
if (type.includes("word") || type.includes("officedocument.word") || type === "application/msword") {
|
||||
return { Icon: FileText, color: "text-blue-600" };
|
||||
}
|
||||
if (type.includes("presentation") || type.includes("powerpoint")) {
|
||||
return { Icon: Presentation, color: "text-orange-600" };
|
||||
}
|
||||
if (type.includes("spreadsheet") || type.includes("excel") || type.includes("csv")) {
|
||||
return { Icon: FileSpreadsheet, color: "text-green-600" };
|
||||
}
|
||||
|
||||
if (type.startsWith("image/")) {
|
||||
return { Icon: FileImage, color: "text-purple-600" };
|
||||
}
|
||||
|
||||
if (type.startsWith("video/")) {
|
||||
return { Icon: FileVideo, color: "text-pink-600" };
|
||||
}
|
||||
|
||||
if (type.startsWith("audio/")) {
|
||||
return { Icon: FileAudio, color: "text-amber-600" };
|
||||
}
|
||||
|
||||
if (type.includes("zip") || type.includes("tar") || type.includes("rar") || type.includes("7z")) {
|
||||
return { Icon: FileArchive, color: "text-gray-600" };
|
||||
}
|
||||
|
||||
if (type.startsWith("text/") || type.includes("json") || type.includes("javascript") || type.includes("xml")) {
|
||||
return { Icon: FileCode, color: "text-sky-600" };
|
||||
}
|
||||
|
||||
if (type.includes("encrypted") || type.includes("pkcs")) {
|
||||
return { Icon: FileLock, color: "text-yellow-700" };
|
||||
}
|
||||
|
||||
return { Icon: FilePlus, color: "text-muted-foreground" };
|
||||
};
|
||||
|
||||
const { Icon, color } = getIconConfig();
|
||||
|
||||
return (
|
||||
<Icon
|
||||
className={cn("h-4 w-4 shrink-0 opacity-90", color, className)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as React from "react"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearchContext } from "./context"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MetadataSelectorField } from "./attachment-metadata-selector"
|
||||
import { useAttachmentMetadata } from "@/hooks/use-attachment-metadata"
|
||||
|
||||
interface MetaFilterProps {
|
||||
type: 'extension' | 'category' | 'content_type'
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
export function MetadataFilter({ type, icon }: MetaFilterProps) {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const { data: meta, isLoading } = useAttachmentMetadata(open)
|
||||
|
||||
const filterKey = `attachment_${type}` as const
|
||||
const currentValue = filter[filterKey] as string
|
||||
|
||||
const optionsMap = {
|
||||
extension: meta?.extensions || [],
|
||||
category: meta?.categories || [],
|
||||
content_type: meta?.content_types || []
|
||||
}
|
||||
|
||||
const handleSelect = (value: string | undefined) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.attachment_extension
|
||||
delete next.attachment_category
|
||||
delete next.attachment_content_type
|
||||
if (value) {
|
||||
next[filterKey] = value
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next[filterKey]
|
||||
return next
|
||||
})
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-6 rounded-none border-l-0 px-3 gap-1.5 transition-colors",
|
||||
currentValue && "bg-primary/10 text-primary hover:bg-primary/20 border-primary/50"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="max-w-[80px] truncate">
|
||||
{currentValue || t(`search_more.${type}`)}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-2 shadow-xl">
|
||||
<MetadataSelectorField
|
||||
label={t(`search_more.${type}`)}
|
||||
value={currentValue || ''}
|
||||
options={optionsMap[type]}
|
||||
isLoading={isLoading}
|
||||
onSelect={handleSelect}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import * as React from "react"
|
||||
import { Check, X } from "lucide-react"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Group } from "@/api/system/api"
|
||||
|
||||
interface MetadataSelectorFieldProps {
|
||||
label: string
|
||||
value?: string
|
||||
options: Group[]
|
||||
isLoading: boolean
|
||||
onSelect: (val: string | undefined) => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function MetadataSelectorField({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
isLoading,
|
||||
onSelect,
|
||||
onReset
|
||||
}: MetadataSelectorFieldProps) {
|
||||
const { t } = useTranslation()
|
||||
const [searchTerm, setSearchTerm] = React.useState("")
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
return options.filter(opt =>
|
||||
opt.key.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}, [options, searchTerm])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"group flex items-center justify-between w-full px-4 py-2 hover:bg-accent/50 transition-all text-left relative border rounded-md",
|
||||
"min-h-[48px]",
|
||||
value && "bg-accent/30 border-primary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start pr-6 overflow-hidden">
|
||||
<span className="text-[10px] font-bold uppercase opacity-50 tracking-tight leading-none">
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"mt-1 truncate w-full text-xs",
|
||||
value ? "font-semibold text-primary" : "text-muted-foreground/70"
|
||||
)}>
|
||||
{value || t('search_more.any')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{value && (
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); onReset(); }}
|
||||
className="p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{value && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="p-0 w-64 shadow-xl">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t('search_more.search_placeholder', { field: label })}
|
||||
value={searchTerm}
|
||||
onValueChange={setSearchTerm}
|
||||
className="h-8"
|
||||
/>
|
||||
<CommandList className="max-h-[240px]">
|
||||
{isLoading && (
|
||||
<div className="p-4 text-[10px] text-center opacity-50">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
<CommandEmpty className="text-[10px] p-2 text-center">
|
||||
{t('common.noData')}
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{filteredOptions.map((opt) => (
|
||||
<CommandItem
|
||||
key={opt.key}
|
||||
onSelect={() => {
|
||||
value === opt.key ? onReset() : onSelect(opt.key)
|
||||
}}
|
||||
className="flex items-center justify-between py-2 px-3 cursor-pointer text-xs"
|
||||
>
|
||||
<span className="truncate">{opt.key}</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* count */}
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{opt.count}
|
||||
</span>
|
||||
|
||||
{value === opt.key && (
|
||||
<Check className="h-3 w-3 text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { X, TagIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useSearchContext } from './context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type MailBulkActionsProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AttachmentBulkActions({ children }: MailBulkActionsProps) {
|
||||
const { selected, setSelected, setOpen } = useSearchContext()
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const selectedCount = Array.from(selected.values())
|
||||
.reduce((sum, set) => sum + set.size, 0)
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelected(new Map())
|
||||
}
|
||||
|
||||
const handleUpdateTags = () => {
|
||||
setOpen('update-tags')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const buttons = toolbarRef.current?.querySelectorAll('button')
|
||||
if (!buttons || buttons.length === 0) return
|
||||
|
||||
const currentIndex = Array.from(buttons).findIndex(
|
||||
btn => btn === document.activeElement
|
||||
)
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight': {
|
||||
e.preventDefault()
|
||||
const next = (currentIndex + 1) % buttons.length
|
||||
buttons[next]?.focus()
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
e.preventDefault()
|
||||
const prev = currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
|
||||
buttons[prev]?.focus()
|
||||
break
|
||||
}
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
buttons[0]?.focus()
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
buttons[buttons.length - 1]?.focus()
|
||||
break
|
||||
case 'Escape': {
|
||||
const target = e.target as HTMLElement
|
||||
const active = document.activeElement as HTMLElement
|
||||
const isFromDropdown =
|
||||
target.closest('[data-slot="dropdown-menu-trigger"]') ||
|
||||
active.closest('[data-slot="dropdown-menu-trigger"]') ||
|
||||
target.closest('[data-slot="dropdown-menu-content"]') ||
|
||||
active.closest('[data-slot="dropdown-menu-content"]')
|
||||
|
||||
if (!isFromDropdown) {
|
||||
e.preventDefault()
|
||||
handleClearSelection()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCount === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
role="toolbar"
|
||||
aria-label={t('search.bulkActions.ariaLabel', {
|
||||
count: selectedCount,
|
||||
})}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
|
||||
'transition-all delay-100 duration-300 ease-out hover:scale-105',
|
||||
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 shadow-xl rounded-xl border',
|
||||
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
|
||||
'flex items-center gap-x-2'
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleClearSelection}
|
||||
className="size-6 rounded-full"
|
||||
aria-label={t('search.bulkActions.clear')}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<span className="sr-only">{t('search.bulkActions.clear')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<div className="flex items-center gap-x-1 text-sm">
|
||||
<Badge variant="default" className="min-w-8 rounded-lg">
|
||||
{selectedCount}
|
||||
</Badge>{' '}
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleUpdateTags}
|
||||
className="gap-1"
|
||||
>
|
||||
<TagIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('search.bulkActions.manageTags')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Plus, Tag as TagIcon, X, Loader2, Check, AlertTriangle } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAvailableTags } from '@/hooks/use-available-tags';
|
||||
import { TagAction, useUpdateTags } from '@/hooks/use-update-tags';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { validateTag } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchContext } from './context';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function UpdateTagsDialog({ open, onOpenChange }: Props) {
|
||||
const { tags: availableTags } = useAvailableTags();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate, isPending } = useUpdateTags();
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const [action, setAction] = useState<TagAction>('Overwrite');
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { selected } = useSearchContext()
|
||||
|
||||
const handleAddTag = (tag: string) => {
|
||||
const normalized = tag.toLowerCase().trim();
|
||||
const result = validateTag(normalized);
|
||||
if (!result.valid) {
|
||||
toast({
|
||||
title: t('search.updateTags.invalidTitle'),
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (normalized && !selectedTags.includes(normalized)) {
|
||||
setSelectedTags(prev => [...prev, normalized]);
|
||||
}
|
||||
setInputValue('');
|
||||
setCommandOpen(false);
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setSelectedTags(prev => prev.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (inputValue.trim()) {
|
||||
const normalized = inputValue.toLowerCase().trim();
|
||||
const result = validateTag(normalized);
|
||||
|
||||
if (!result.valid) {
|
||||
toast({
|
||||
title: t('search.updateTags.invalidTitle'),
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTags.includes(normalized)) {
|
||||
setSelectedTags(prev => [...prev, normalized]);
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
}
|
||||
|
||||
const updates: Record<number, string[]> = {};
|
||||
|
||||
selected.forEach((tagSet, accountId) => {
|
||||
updates[accountId] = Array.from(tagSet);
|
||||
});
|
||||
|
||||
let finalTags = inputValue.trim()
|
||||
? [...selectedTags, inputValue.toLowerCase().trim()]
|
||||
: selectedTags;
|
||||
|
||||
if (finalTags.length === 0 && action !== 'Overwrite') {
|
||||
return;
|
||||
}
|
||||
|
||||
mutate(
|
||||
{
|
||||
updates,
|
||||
tags: finalTags,
|
||||
action
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t('search.updateTags.updatedTitle'),
|
||||
description: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>{t('search.updateTags.updatedDesc')}</span>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['all-tags'] });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('search.updateTags.updateFailedTitle'),
|
||||
description: error?.message || t('search.updateTags.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const filteredSuggestions = availableTags.filter(
|
||||
tag => !selectedTags.includes(tag) && tag.includes(inputValue.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md min-h-[50vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TagIcon className="h-5 w-5" />
|
||||
{t('search.updateTags.title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs value={action} onValueChange={(v) => setAction(v as TagAction)} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="Add" className="text-xs">{t('search.updateTags.actionAdd', "Add")}</TabsTrigger>
|
||||
<TabsTrigger value="Remove" className="text-xs">{t('search.updateTags.actionRemove', "remove")}</TabsTrigger>
|
||||
<TabsTrigger value="Overwrite" className="text-xs">{t('search.updateTags.actionOverwrite', "overwrite")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTags.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('search.updateTags.none')}</p>
|
||||
) : (
|
||||
selectedTags.map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1 pr-1 h-7">
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="rounded-sm hover:bg-destructive/20 hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Command shouldFilter={false} onKeyDown={(e) => e.stopPropagation()} >
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
placeholder={t('search.updateTags.searchPlaceholder')}
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
onFocus={() => setCommandOpen(true)}
|
||||
className="h-9 pr-10"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && inputValue.trim()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddTag(inputValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{inputValue.trim() && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute right-1 top-1 h-7 w-7 p-0"
|
||||
onClick={() => handleAddTag(inputValue)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{inputValue.trim() && filteredSuggestions.length === 0 && (
|
||||
<div className="px-1 text-xs text-muted-foreground animate-in fade-in duration-200">
|
||||
{t('search.updateTags.createHint', { tag: inputValue })}
|
||||
</div>
|
||||
)}
|
||||
{commandOpen && inputValue && filteredSuggestions.length > 0 && (
|
||||
<CommandList className="max-h-64 overflow-auto rounded-md border bg-popover shadow-md">
|
||||
<CommandGroup>
|
||||
{filteredSuggestions.map(tag => (
|
||||
<CommandItem
|
||||
key={tag}
|
||||
onSelect={() => handleAddTag(tag)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4 opacity-0" />
|
||||
{tag}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('search.updateTags.selectedCount', { count: selectedTags.length })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('search.addTags.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isPending} variant={action === 'Remove' ? 'destructive' : 'default'}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t(`search.updateTags.saving${action}`)}
|
||||
</>
|
||||
) : (
|
||||
t(`search.updateTags.submit${action}`)
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{action == "Overwrite" && <div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-500">
|
||||
<AlertTriangle className="h-5 w-5 shrink-0" />
|
||||
<p className="text-xs leading-relaxed">
|
||||
{t('search.updateTags.overwriteWarning')}
|
||||
</p>
|
||||
</div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import React from 'react'
|
||||
import { SortingState } from '@tanstack/react-table'
|
||||
import { AttachmentModel } from '@/api/attachment/api'
|
||||
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'update-tags' | 'restore' | 'delete-mailbox'
|
||||
|
||||
interface SearchContextType {
|
||||
open: SearchDialogType | null
|
||||
setOpen: (str: SearchDialogType | null) => void
|
||||
currentEnvelope: AttachmentModel | undefined
|
||||
setCurrentEnvelope: React.Dispatch<React.SetStateAction<AttachmentModel | undefined>>
|
||||
selected: Map<number, Set<string>>
|
||||
setSelected: React.Dispatch<React.SetStateAction<Map<number, Set<string>>>>
|
||||
deleteMailboxId: string | undefined
|
||||
setDeleteMailboxId: React.Dispatch<React.SetStateAction<string | undefined>>
|
||||
selectedAccountId: number | undefined
|
||||
setSelectedAccountId: React.Dispatch<React.SetStateAction<number | undefined>>
|
||||
selectedTags: string[]
|
||||
sorting: SortingState
|
||||
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
|
||||
filter: Record<string, any>
|
||||
setFilter: React.Dispatch<React.SetStateAction<Record<string, any>>>
|
||||
handleTagToggle: (tag: string) => void
|
||||
}
|
||||
|
||||
const SearchContext = React.createContext<SearchContextType | null>(null)
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
value: SearchContextType
|
||||
}
|
||||
|
||||
export default function SearchProvider({ children, value }: Props) {
|
||||
return <SearchContext.Provider value={value}>{children}</SearchContext.Provider>
|
||||
}
|
||||
|
||||
export const useSearchContext = () => {
|
||||
const searchContext = React.useContext(SearchContext)
|
||||
|
||||
if (!searchContext) {
|
||||
throw new Error(
|
||||
'useSearchContext has to be used within <SearchContext.Provider>'
|
||||
)
|
||||
}
|
||||
|
||||
return searchContext
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import { IconAlertTriangle } from '@tabler/icons-react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { delete_messages } from '@/api/mailbox/envelope/api'
|
||||
import { useSearchContext } from './context'
|
||||
import { mapToRecordOfArrays } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function EnvelopeDeleteDialog({ open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const { toDelete, setToDelete, setSelected } = useSearchContext()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ payload }: { payload: Record<number, string[]> }) =>
|
||||
delete_messages(payload),
|
||||
retry: false,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['search-messages'], exact: false })
|
||||
queryClient.invalidateQueries({ queryKey: ['all-tags'] })
|
||||
onOpenChange(false)
|
||||
setToDelete(new Map())
|
||||
setSelected(new Map())
|
||||
toast({
|
||||
title: t('search.delete.successTitle'),
|
||||
description: t('search.delete.successDesc'),
|
||||
})
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('search.delete.errorTitle'),
|
||||
description: `${error.message}`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleDelete = () => {
|
||||
const payload = mapToRecordOfArrays(toDelete)
|
||||
deleteMutation.mutate({ payload })
|
||||
}
|
||||
|
||||
const isLoading = deleteMutation.isPending
|
||||
|
||||
const emailCount = Array.from(toDelete.values()).reduce(
|
||||
(sum, set) => sum + set.size,
|
||||
0
|
||||
)
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
handleConfirm={handleDelete}
|
||||
className="max-w-xl"
|
||||
isLoading={isLoading}
|
||||
destructive
|
||||
title={
|
||||
<span className="text-destructive">
|
||||
<IconAlertTriangle
|
||||
className="mr-1 inline-block stroke-destructive"
|
||||
size={18}
|
||||
/>{' '}
|
||||
{t('search.delete.title')}
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<div className="space-y-4">
|
||||
<p className="mb-2">
|
||||
{t('search.delete.confirmPrefix')}{' '}
|
||||
<span className="font-bold">
|
||||
{t('search.delete.countLabel', { count: emailCount })}
|
||||
</span>
|
||||
?
|
||||
<br />
|
||||
{t('search.delete.confirmDetail')}
|
||||
</p>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('search.delete.warningTitle')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('search.delete.warningDesc')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
confirmText={t('search.delete.confirmButton')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { delete_mailbox } from '@/api/mailbox/api';
|
||||
import { useSearchContext } from './context';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useSearchContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ accountId, mailboxId }: { accountId: number; mailboxId: string }) =>
|
||||
delete_mailbox(accountId, mailboxId),
|
||||
retry: false,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['search-mailboxes', selectedAccountId] });
|
||||
onOpenChange(false);
|
||||
setDeleteMailboxId(undefined);
|
||||
toast({
|
||||
title: t('mailbox.deleteMailboxDialog.successTitle'),
|
||||
description: t('mailbox.deleteMailboxDialog.successDesc'),
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('mailbox.deleteMailboxDialog.errorTitle'),
|
||||
description: error.message || "Delete failed",
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedAccountId && deleteMailboxId) {
|
||||
deleteMutation.mutate({
|
||||
accountId: selectedAccountId,
|
||||
mailboxId: deleteMailboxId
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
onOpenChange(isOpen);
|
||||
if (!isOpen) setDeleteMailboxId(undefined);
|
||||
}}
|
||||
handleConfirm={handleDelete}
|
||||
className="max-w-xl"
|
||||
isLoading={isLoading}
|
||||
title={
|
||||
<span className="text-destructive">
|
||||
<IconAlertTriangle
|
||||
className="mr-1 inline-block stroke-destructive"
|
||||
size={18}
|
||||
/>{' '}
|
||||
{t('mailbox.deleteMailboxDialog.title')}
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<div className="space-y-4">
|
||||
<p className="mb-2">
|
||||
{t('mailbox.deleteMailboxDialog.desc')}
|
||||
</p>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('mailbox.deleteMailboxDialog.warningTitle')}</AlertTitle>
|
||||
<AlertDescription>{t('mailbox.deleteMailboxDialog.warningDesc')}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
confirmText={t('mailbox.deleteMailboxDialog.confirm')}
|
||||
destructive
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Plus, Tag as TagIcon, X, Loader2, Check } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAvailableTags } from '@/hooks/use-available-tags';
|
||||
import { useUpdateTags } from '@/hooks/use-update-tags';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { validateTag } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchContext } from './context';
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function EditTagsDialog({ open, onOpenChange }: Props) {
|
||||
const { tags: availableTags } = useAvailableTags();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate, isPending } = useUpdateTags();
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { currentEnvelope } = useSearchContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (open && currentEnvelope) {
|
||||
setSelectedTags(currentEnvelope.tags || []);
|
||||
}
|
||||
}, [open, currentEnvelope]);
|
||||
|
||||
if (!currentEnvelope) return null;
|
||||
|
||||
const handleAddTag = (tag: string) => {
|
||||
const normalized = tag.toLowerCase().trim();
|
||||
const result = validateTag(normalized);
|
||||
if (!result.valid) {
|
||||
toast({
|
||||
title: t('search.addTags.invalidTitle'),
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (normalized && !selectedTags.includes(normalized)) {
|
||||
setSelectedTags(prev => [...prev, normalized]);
|
||||
}
|
||||
setInputValue('');
|
||||
setCommandOpen(false);
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setSelectedTags(prev => prev.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (inputValue.trim()) {
|
||||
const normalized = inputValue.toLowerCase().trim();
|
||||
const result = validateTag(normalized);
|
||||
|
||||
if (!result.valid) {
|
||||
toast({
|
||||
title: t('search.addTags.invalidTitle'),
|
||||
description: result.error,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTags.includes(normalized)) {
|
||||
setSelectedTags(prev => [...prev, normalized]);
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
}
|
||||
|
||||
const updates = {
|
||||
[currentEnvelope.account_id]: [currentEnvelope.id],
|
||||
};
|
||||
|
||||
mutate(
|
||||
{
|
||||
updates,
|
||||
tags: inputValue.trim()
|
||||
? [...selectedTags, inputValue.toLowerCase().trim()]
|
||||
: selectedTags,
|
||||
action: "Overwrite"
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t('search.addTags.updatedTitle'),
|
||||
description: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>{t('search.addTags.updatedDesc')}</span>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['all-tags'] });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('search.addTags.updateFailedTitle'),
|
||||
description: error?.message || t('search.addTags.tryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const filteredSuggestions = availableTags.filter(
|
||||
tag => !selectedTags.includes(tag) && tag.includes(inputValue.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TagIcon className="h-5 w-5" />
|
||||
{t('search.addTags.title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTags.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('search.addTags.none')}</p>
|
||||
) : (
|
||||
selectedTags.map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1 pr-1 h-7">
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="rounded-sm hover:bg-destructive/20 hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Command shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
placeholder={t('search.addTags.searchPlaceholder')}
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
onFocus={() => setCommandOpen(true)}
|
||||
className="h-9 pr-10"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && inputValue.trim()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddTag(inputValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{inputValue.trim() && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="absolute right-1 top-1 h-7 w-7 p-0"
|
||||
onClick={() => handleAddTag(inputValue)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{inputValue.trim() && filteredSuggestions.length === 0 && (
|
||||
<div className="px-1 text-xs text-muted-foreground animate-in fade-in duration-200">
|
||||
{t('search.addTags.createHint', { tag: inputValue })}
|
||||
</div>
|
||||
)}
|
||||
{commandOpen && inputValue && filteredSuggestions.length > 0 && (
|
||||
<CommandList className="max-h-64 overflow-auto rounded-md border bg-popover shadow-md">
|
||||
<CommandGroup>
|
||||
{filteredSuggestions.map(tag => (
|
||||
<CommandItem
|
||||
key={tag}
|
||||
onSelect={() => handleAddTag(tag)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4 opacity-0" />
|
||||
{tag}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('search.addTags.selectedCount', { count: selectedTags.length })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('search.addTags.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('search.addTags.saving')}
|
||||
</>
|
||||
) : (
|
||||
t('search.addTags.save')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import { X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useSearchContext } from "./context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function FilterResetButton() {
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const { t } = useTranslation()
|
||||
const { q, ...restFilters } = filter;
|
||||
|
||||
const activeFiltersCount = Object.keys(restFilters).filter(key => {
|
||||
const value = restFilters[key];
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
return value !== undefined && value !== null && value !== '';
|
||||
}).length;
|
||||
|
||||
if (activeFiltersCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setFilter(q ? { q } : {})}
|
||||
className={cn(
|
||||
"h-6 px-2 text-xs gap-1.5 font-normal",
|
||||
"text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
)}
|
||||
title={t('search_reset.tooltip')}
|
||||
>
|
||||
<span>{t('search_reset.label')}</span>
|
||||
<div className="flex items-center justify-center w-4 h-4 rounded-full bg-muted-foreground/20 text-[10px]">
|
||||
{activeFiltersCount}
|
||||
</div>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FixedHeader } from '@/components/layout/fixed-header';
|
||||
import { Main } from '@/components/layout/main';
|
||||
import { AttachmentListPagination } from '@/components/pagination';
|
||||
import React from 'react';
|
||||
import SearchProvider, { SearchDialogType } from './context';
|
||||
import useDialogState from '@/hooks/use-dialog-state';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AttachmentListTable } from './mail-list-table';
|
||||
import { SortingState } from '@tanstack/react-table';
|
||||
import { useSearchAttachments } from '@/hooks/use-search-attachments';
|
||||
import { AttachmentModel } from '@/api/attachment/api';
|
||||
|
||||
export default function AttachmentSearch() {
|
||||
const { t } = useTranslation()
|
||||
const [selectedAttachment, setSelectedAttachment] = React.useState<AttachmentModel | undefined>(undefined);
|
||||
const [open, setOpen] = useDialogState<SearchDialogType>(null)
|
||||
const [selected, setSelected] = React.useState<Map<number, Set<string>>>(new Map());
|
||||
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
|
||||
const [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
|
||||
const [selectedAccountId, setSelectedAccountId] = React.useState<number | undefined>(undefined);
|
||||
|
||||
const {
|
||||
attachments,
|
||||
total,
|
||||
totalPages,
|
||||
isLoading,
|
||||
page,
|
||||
pageSize,
|
||||
setPage,
|
||||
setSearchPageSize,
|
||||
setSortBy,
|
||||
setSortOrder,
|
||||
filter,
|
||||
setFilter
|
||||
} = useSearchAttachments();
|
||||
|
||||
const handleSetPageSize = (pageSize: number) => {
|
||||
setPage(1);
|
||||
setSearchPageSize(pageSize)
|
||||
}
|
||||
|
||||
const handleTagToggle = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag)
|
||||
? prev.filter(t => t !== tag)
|
||||
: [...prev, tag]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<SearchProvider
|
||||
value={{
|
||||
open,
|
||||
setOpen,
|
||||
currentEnvelope: selectedAttachment,
|
||||
selectedTags,
|
||||
setCurrentEnvelope: setSelectedAttachment,
|
||||
selected,
|
||||
setSelected,
|
||||
sorting,
|
||||
setSorting,
|
||||
filter,
|
||||
setFilter,
|
||||
deleteMailboxId,
|
||||
setDeleteMailboxId,
|
||||
selectedAccountId,
|
||||
setSelectedAccountId,
|
||||
handleTagToggle
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto w-full px-4">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{isLoading && (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent"></div>
|
||||
<p className="text-sm">{t('search.searching')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<AttachmentListTable
|
||||
isLoading={isLoading}
|
||||
items={attachments}
|
||||
onAttachmentChanged={(att) => {
|
||||
setOpen('display');
|
||||
setSelectedAttachment(att);
|
||||
}}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
/>
|
||||
{total > 0 && <AttachmentListPagination
|
||||
totalItems={total}
|
||||
hasNextPage={() => page < totalPages}
|
||||
pageIndex={page - 1}
|
||||
pageSize={pageSize}
|
||||
setPageIndex={(index) => setPage(index + 1)}
|
||||
setPageSize={handleSetPageSize}
|
||||
/>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <MailDisplayDrawer
|
||||
key='search-mail-display'
|
||||
open={open === 'display'}
|
||||
onOpenChange={() => setOpen('display')}
|
||||
/>
|
||||
|
||||
<EnvelopeDeleteDialog
|
||||
key='delete-envelope'
|
||||
open={open === 'delete'}
|
||||
onOpenChange={() => setOpen('delete')}
|
||||
/>
|
||||
|
||||
<EditTagsDialog
|
||||
key='edit-attachment-tags-dialog'
|
||||
open={open === 'edit-tags'}
|
||||
onOpenChange={() => setOpen('edit-tags')}
|
||||
/>
|
||||
|
||||
<UpdateTagsDialog
|
||||
key='update-attachment-tags-dialog'
|
||||
open={open === 'update-tags'}
|
||||
onOpenChange={() => setOpen('update-tags')}
|
||||
/>
|
||||
|
||||
<RestoreMessageDialog
|
||||
key='restore-mail-dialog'
|
||||
open={open === 'restore'}
|
||||
onOpenChange={() => setOpen('restore')}
|
||||
/>
|
||||
|
||||
<MailBoxDeleteDialog
|
||||
key='mailbox-delete'
|
||||
open={open === 'delete-mailbox'}
|
||||
onOpenChange={() => setOpen('delete-mailbox')}
|
||||
/> */}
|
||||
</SearchProvider>
|
||||
</Main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import { useSearchContext } from './context'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { MailMessageView } from './mail-message-view'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function MailDisplayDrawer({ open, onOpenChange }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { currentEnvelope } = useSearchContext()
|
||||
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DialogContent className='w-full md:max-w-6xl mx-auto h-full'>
|
||||
<DialogHeader className="p-4 pb-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{t('mail.emailViewer')}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<ScrollArea>
|
||||
<div className='m-5'>
|
||||
{currentEnvelope ? (
|
||||
<MailMessageView envelope={currentEnvelope} />
|
||||
) : (
|
||||
<div className="p-8 text-center text-muted-foreground">{t('mail.noMessageSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import { dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { useSearchContext } from "./context"
|
||||
import { AttachmentBulkActions } from "./bulk-actions"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import LongText from "@/components/long-text"
|
||||
import { DataTableColumnHeader } from "./table/data-table-column-header"
|
||||
import { SearchTable } from "./table/table"
|
||||
import { DataTableRowActions } from "./table/data-table-row-actions"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { DataTableToolbar } from "./table/toolbar"
|
||||
import { AttachmentModel } from "@/api/attachment/api"
|
||||
import { useSearchAttachments } from "@/hooks/use-search-attachments"
|
||||
import { FileIcon } from "lucide-react"
|
||||
import { AttachmentIcon } from "./attachment-icon"
|
||||
|
||||
interface MailListProps {
|
||||
items: AttachmentModel[]
|
||||
isLoading: boolean
|
||||
onAttachmentChanged: (attachment: AttachmentModel) => void
|
||||
setSortBy: (sortBy: "DATE" | "SIZE") => void
|
||||
setSortOrder: (value: "desc" | "asc") => void
|
||||
}
|
||||
|
||||
export function AttachmentListTable({
|
||||
items,
|
||||
isLoading,
|
||||
onAttachmentChanged,
|
||||
setSortBy,
|
||||
setSortOrder
|
||||
}: MailListProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS
|
||||
const { selected, setSelected } = useSearchContext()
|
||||
|
||||
const columns: ColumnDef<AttachmentModel>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: () => (
|
||||
<Checkbox
|
||||
checked={
|
||||
totalSelected === items.length && items.length > 0
|
||||
? true
|
||||
: totalSelected > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={handleToggleAll}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={hasSelected(row.original.account_id, row.original.id)}
|
||||
onCheckedChange={() => toggleSelected(row.original.account_id, row.original.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
),
|
||||
meta: { className: 'text-left text-sm' },
|
||||
minSize: 25,
|
||||
maxSize: 25,
|
||||
},
|
||||
{
|
||||
accessorKey: "source",
|
||||
header: t('attachment.source'),
|
||||
cell: ({ row }) => {
|
||||
const { from, account_email, mailbox_name, account_id, mailbox_id } = row.original;
|
||||
const { setFilter } = useSearchAttachments();
|
||||
const accountPrefix = account_email.split('@')[0];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col py-1.5 min-w-0 group">
|
||||
<div
|
||||
className="cursor-pointer hover:text-primary transition-colors flex items-center gap-1.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFilter((prev: any) => ({ ...prev, from: from }));
|
||||
}}
|
||||
>
|
||||
<LongText className="text-xs truncate">
|
||||
{from}
|
||||
</LongText>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/70">
|
||||
<span
|
||||
className="truncate max-w-[90px] hover:text-primary cursor-pointer transition-colors"
|
||||
title={account_email}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFilter((prev: any) => ({ ...prev, account_ids: [account_id], mailbox_ids: undefined }));
|
||||
}}
|
||||
>
|
||||
{accountPrefix}
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 opacity-40">/</span>
|
||||
<span
|
||||
className="truncate max-w-[70px] hover:text-primary cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFilter((prev: any) => ({ ...prev, account_ids: [account_id], mailbox_ids: [mailbox_id] }));
|
||||
}}
|
||||
>
|
||||
{mailbox_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { className: 'text-left' }
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: t('attachment.subject'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 300,
|
||||
maxSize: 300,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t('attachment.name'),
|
||||
cell: ({ row }) => {
|
||||
const { name, content_type } = row.original;
|
||||
const safeName = name ?? "n/a";
|
||||
|
||||
const shortContentType = content_type
|
||||
? content_type.split('/').pop()?.toUpperCase().replace('X-', '')
|
||||
: "UNK";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-0 py-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<AttachmentIcon
|
||||
contentType={content_type ?? ""}
|
||||
className="h-4 w-4 mt-0.5"
|
||||
/>
|
||||
<LongText className='text-xs font-medium max-w-[320px] text-foreground/90'>
|
||||
{safeName}
|
||||
</LongText>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-6.5 mt-1">
|
||||
<span className="text-[10px] text-muted-foreground font-mono bg-muted px-1 py-0.5 rounded-sm">
|
||||
{shortContentType}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'size',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('attachment.size')} />
|
||||
),
|
||||
cell: ({ row }) => <span className='text-xs max-w-[40px]'>{formatBytes(row.original.size)}</span>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 80,
|
||||
maxSize: 80,
|
||||
},
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('attachment.date')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.date)
|
||||
const title = format(date, 'yyyy-MM-dd HH:mm:ss')
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='text-xs whitespace-nowrap'>
|
||||
{formatDistanceToNow(date, { addSuffix: true, locale })}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{title}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('users.columns.actions'),
|
||||
cell: DataTableRowActions,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 60,
|
||||
maxSize: 60,
|
||||
},
|
||||
]
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const total = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
|
||||
|
||||
if (total === items.length && items.length > 0) {
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
for (const item of items) {
|
||||
const set = new Set(next.get(item.account_id) || [])
|
||||
set.add(item.id)
|
||||
next.set(item.account_id, set)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelected = (accountId: number, mailId: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
const set = new Set(next.get(accountId) || [])
|
||||
|
||||
if (set.has(mailId)) {
|
||||
set.delete(mailId)
|
||||
if (set.size === 0) next.delete(accountId)
|
||||
else next.set(accountId, set)
|
||||
} else {
|
||||
set.add(mailId)
|
||||
next.set(accountId, set)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const totalSelected = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
|
||||
|
||||
const hasSelected = (accountId: number, mailId: string) => selected.get(accountId)?.has(mailId) ?? false
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<Skeleton className="h-3 w-3" />
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
<Skeleton className="h-2.5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
onRowClick={(e, row) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('input[type="checkbox"], button')) return
|
||||
onAttachmentChanged(row.original)
|
||||
}}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
>
|
||||
{(table) => {
|
||||
return <DataTableToolbar table={table} />
|
||||
}}
|
||||
|
||||
</SearchTable>
|
||||
{totalSelected > 0 && <AttachmentBulkActions />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { EmailEnvelope } from "@/api"
|
||||
import { useSearchContext } from "./context"
|
||||
import { AttachmentBulkActions } from "./bulk-actions"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { enUS } from "date-fns/locale"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
isLoading: boolean
|
||||
onEnvelopeChanged: (envelope: EmailEnvelope) => void
|
||||
}
|
||||
|
||||
export function MailList({
|
||||
items,
|
||||
isLoading,
|
||||
onEnvelopeChanged
|
||||
}: MailListProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
|
||||
const { setOpen, currentEnvelope, setCurrentEnvelope, selected, setSelected, setToDelete } = useSearchContext()
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const total = Array.from(selected.values())
|
||||
.reduce((sum, set) => sum + set.size, 0);
|
||||
|
||||
if (total === items.length && items.length > 0) {
|
||||
setSelected(new Map());
|
||||
} else {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev);
|
||||
for (const item of items) {
|
||||
const set = new Set(next.get(item.account_id) || []);
|
||||
set.add(item.id);
|
||||
next.set(item.account_id, set);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const toggleToDelete = (accountId: number, mailId: string) => {
|
||||
setToDelete(prev => {
|
||||
const next = new Map(prev);
|
||||
const set = new Set(next.get(accountId) || []);
|
||||
|
||||
if (set.has(mailId)) {
|
||||
set.delete(mailId);
|
||||
if (set.size === 0) next.delete(accountId);
|
||||
else next.set(accountId, set);
|
||||
} else {
|
||||
set.add(mailId);
|
||||
next.set(accountId, set);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelected = (accountId: number, mailId: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev);
|
||||
const set = new Set(next.get(accountId) || []);
|
||||
|
||||
if (set.has(mailId)) {
|
||||
set.delete(mailId);
|
||||
if (set.size === 0) next.delete(accountId);
|
||||
else next.set(accountId, set);
|
||||
} else {
|
||||
set.add(mailId);
|
||||
next.set(accountId, set);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const totalSelected = Array.from(selected.values())
|
||||
.reduce((sum, set) => sum + set.size, 0);
|
||||
|
||||
const hasSelected = (accountId: number, mailId: string) => {
|
||||
return selected.get(accountId)?.has(mailId) ?? false;
|
||||
}
|
||||
|
||||
const handleDelete = (envelope: EmailEnvelope) => {
|
||||
setToDelete(new Map());
|
||||
toggleToDelete(envelope.account_id, envelope.id)
|
||||
setOpen("delete")
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<Skeleton className="h-3 w-3" />
|
||||
<Skeleton className="h-3 w-3 rounded-full" />
|
||||
<Skeleton className="h-3 flex-1" />
|
||||
<Skeleton className="h-2.5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{items.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
|
||||
<Checkbox
|
||||
checked={
|
||||
totalSelected === items.length && items.length > 0
|
||||
? true
|
||||
: totalSelected > 0
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={handleToggleAll}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{totalSelected > 0
|
||||
? `${t('search.bulkActions.selected', { count: totalSelected })}`
|
||||
: t('common.selectAll')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map((item, index) => {
|
||||
const hasAttachments = item.regular_attachment_count > 0
|
||||
const isSelectedRow = currentEnvelope?.id === item.id
|
||||
const isChecked = hasSelected(item.account_id, item.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
|
||||
"hover:bg-accent/50",
|
||||
isSelectedRow && "bg-accent"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('input[type="checkbox"], button')) return
|
||||
onEnvelopeChanged(item)
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleSelected(item.account_id, item.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
|
||||
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
|
||||
|
||||
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-0.5">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{item.from}</p>
|
||||
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
|
||||
{item.subject}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/60">
|
||||
<span className="truncate">{item.account_email}</span>
|
||||
<span className="scale-75 opacity-50">•</span>
|
||||
<span className="font-medium text-primary/70">{item.mailbox_name}</span>
|
||||
</div>
|
||||
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
|
||||
{item.subject}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mt-0.25">
|
||||
{item.tags?.map((tag, i) => (
|
||||
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={i}>{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
||||
|
||||
{hasAttachments && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
<span>{item.regular_attachment_count}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="hidden md:inline">{formatBytes(item.size)}</span>
|
||||
|
||||
<span className={cn(isSelectedRow ? "text-foreground font-medium" : "text-muted-foreground")}>
|
||||
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
|
||||
</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentEnvelope(item);
|
||||
setOpen("edit-tags");
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('search.editTag')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentEnvelope(item);
|
||||
setOpen("restore");
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-3.5 w-3.5" />
|
||||
{t('restore_message.restore_to_imap')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="ml-2 h-3.5 w-3.5" />
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{totalSelected > 0 && <AttachmentBulkActions />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import EmailIframe from '@/components/mail-iframe';
|
||||
import {
|
||||
AttachmentInfo,
|
||||
download_attachment,
|
||||
download_message,
|
||||
getContent,
|
||||
load_message,
|
||||
} from '@/api/mailbox/envelope/api';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useSearchContext } from './context';
|
||||
import { MailThreadDialog } from './thread-dialog';
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NestedEmailDialog } from './nested-email-dialog';
|
||||
|
||||
|
||||
interface MailMessageViewProps {
|
||||
envelope: {
|
||||
id: string;
|
||||
account_id: number,
|
||||
from?: string;
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
subject?: string;
|
||||
internal_date?: number;
|
||||
};
|
||||
showActions?: boolean;
|
||||
showHeader?: boolean;
|
||||
showAttachments?: boolean;
|
||||
}
|
||||
|
||||
const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines }) => {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start space-x-2">
|
||||
<span className="font-medium text-gray-400 whitespace-nowrap">{title}:</span>
|
||||
<div className="flex-1">
|
||||
<ul className="list-disc list-inside">
|
||||
{lines.slice(0, expanded ? lines.length : 3).map((ref, i) => (
|
||||
<li key={i} className="line-clamp-1">{ref}</li>
|
||||
))}
|
||||
</ul>
|
||||
{lines.length > 3 && (
|
||||
<button
|
||||
className="text-blue-500 hover:underline text-xs"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? t('common.showLess') : t('common.showMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const getFileConfig = (mimeType: string) => {
|
||||
const type = mimeType.toLowerCase();
|
||||
if (type.includes('pdf')) {
|
||||
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
|
||||
}
|
||||
if (type.includes('image/')) {
|
||||
return { icon: <FileImage className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50 border-blue-100' };
|
||||
}
|
||||
if (type.includes('audio/')) {
|
||||
return { icon: <FileAudio className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50 border-purple-100' };
|
||||
}
|
||||
|
||||
if (type.includes('video/')) {
|
||||
return { icon: <FileVideo className="h-4 w-4" />, color: 'text-indigo-600 bg-indigo-50 border-indigo-100' };
|
||||
}
|
||||
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {
|
||||
return { icon: <FileSpreadsheet className="h-4 w-4" />, color: 'text-green-600 bg-green-50 border-green-100' };
|
||||
}
|
||||
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) {
|
||||
return { icon: <FileArchive className="h-4 w-4" />, color: 'text-orange-600 bg-orange-50 border-orange-100' };
|
||||
}
|
||||
if (type.includes('text/') || type.includes('json') || type.includes('javascript')) {
|
||||
return { icon: <FileCode className="h-4 w-4" />, color: 'text-slate-600 bg-slate-50 border-slate-100' };
|
||||
}
|
||||
|
||||
return { icon: <FileIcon className="h-4 w-4" />, color: 'text-gray-600 bg-gray-50 border-gray-100' };
|
||||
};
|
||||
|
||||
export function MailMessageView({
|
||||
envelope,
|
||||
showActions = true,
|
||||
showAttachments = true,
|
||||
showHeader = true
|
||||
}: MailMessageViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const { setToDelete, setOpen, setSelected } = useSearchContext();
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null);
|
||||
const [attachments, setAttachments] = useState<AttachmentInfo[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [downloadingAttachmentFileName, setDownloadingAttachmentFileName] = useState<string | null>(null);
|
||||
const [nestedEmlFile, setNestedEmlFile] = useState<AttachmentInfo | null>(null);
|
||||
const { getEmailById } = useMinimalAccountList();
|
||||
const [threadOpen, setThreadOpen] = useState(false);
|
||||
|
||||
const downloadAttachmentMutation = useMutation({
|
||||
mutationFn: ({ content_hash }: { content_hash: string }) =>
|
||||
download_attachment(envelope.account_id, envelope.id, content_hash, downloadingAttachmentFileName!),
|
||||
onSuccess: () => setDownloadingAttachmentFileName(null),
|
||||
onError: (error: any) => {
|
||||
setDownloadingAttachmentFileName(null);
|
||||
toast({
|
||||
title: t('mail.failedToDownloadFile'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const loadMessageMutation = useMutation({
|
||||
mutationFn: () => load_message(envelope.account_id, envelope.id),
|
||||
onSuccess: (data) => {
|
||||
setLoading(false);
|
||||
setContent(getContent(data));
|
||||
if (data.attachments) setAttachments(data.attachments);
|
||||
setContentType(data.html ? 'Html' : 'Plain');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setLoading(false);
|
||||
toast({
|
||||
title: t('mail.failedToLoadEmail'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadMessageMutation.mutate();
|
||||
}, [envelope.id]);
|
||||
|
||||
|
||||
const handleViewNestedEml = (attachment: AttachmentInfo) => {
|
||||
setNestedEmlFile(attachment);
|
||||
};
|
||||
|
||||
const toggleToDelete = (accountId: number, mailId: string) => {
|
||||
setToDelete(prev => {
|
||||
const next = new Map(prev);
|
||||
const set = new Set(next.get(accountId) || []);
|
||||
|
||||
if (set.has(mailId)) {
|
||||
set.delete(mailId);
|
||||
if (set.size === 0) next.delete(accountId);
|
||||
else next.set(accountId, set);
|
||||
} else {
|
||||
set.add(mailId);
|
||||
next.set(accountId, set);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (envelope) {
|
||||
toggleToDelete(envelope.account_id, envelope.id)
|
||||
setOpen("delete")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const downloadEmlFile = async () => {
|
||||
try {
|
||||
toast({ title: t('mail.downloadStarted'), description: t('mail.isBeingDownloaded', { id: envelope.id }) });
|
||||
await download_message(envelope.account_id, envelope.id);
|
||||
toast({ title: t('mail.downloadComplete'), description: t('mail.downloaded', { id: envelope.id }) });
|
||||
} catch (error) {
|
||||
let msg = t('mail.downloadFailed');
|
||||
if (error instanceof AxiosError) {
|
||||
msg = error.response?.data?.message || error.response?.data?.error || error.message;
|
||||
if (error.response?.status) msg = `${error.response.status}: ${msg}`;
|
||||
} else if (error instanceof Error) {
|
||||
msg = error.message;
|
||||
}
|
||||
toast({ title: t('mail.downloadFailed'), description: msg, variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{showHeader && <div className="grid gap-1 text-xs">
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">{t('mail.account')}:</span>
|
||||
<span>{getEmailById(envelope.account_id)}</span>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">{t('mail.id')}:</span>
|
||||
<span>{envelope.id}</span>
|
||||
</div>
|
||||
{envelope.from && (
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">{t('mail.from')}:</span>
|
||||
<span>{envelope.from}</span>
|
||||
</div>
|
||||
)}
|
||||
{envelope.to && envelope.to.length > 0 && <Multilines title={t('mail.to')} lines={envelope.to} />}
|
||||
{envelope.cc && envelope.cc.length > 0 && <Multilines title={t('mail.cc')} lines={envelope.cc} />}
|
||||
{envelope.bcc && envelope.bcc.length > 0 && <Multilines title={t('mail.bcc')} lines={envelope.bcc} />}
|
||||
{envelope.subject && (
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">{t('mail.subject')}:</span>
|
||||
<span>{envelope.subject}</span>
|
||||
</div>
|
||||
)}
|
||||
{envelope.internal_date && (
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">{t('mail.date')}:</span>
|
||||
<span>{formatTimestamp(envelope.internal_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{showActions && (
|
||||
<>
|
||||
<div className="flex items-center mt-2 space-x-2">
|
||||
<Separator orientation="horizontal" className="flex-1 bg-border" />
|
||||
</div>
|
||||
<div className="flex items-center justify-start gap-3 text-xs text-gray-500">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={handleDelete} className="hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mail.delete')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={downloadEmlFile}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mail.download')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setThreadOpen(true)}
|
||||
>
|
||||
<MessageSquareMore className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mail.viewThread')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setSelected(new Map())
|
||||
setOpen('restore')
|
||||
}}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('restore_message.restore_to_imap', 'Restore Mail')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showAttachments && <Separator className="my-2" />}
|
||||
{showAttachments && (
|
||||
<div className="mb-2">
|
||||
{loading ? (
|
||||
<span className="text-gray-500 text-xs" />
|
||||
) : attachments && attachments.length > 0 ? (
|
||||
(() => {
|
||||
const nonInline = attachments.filter((a) => !a.inline);
|
||||
|
||||
return nonInline.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{nonInline.map((attachment, i) => {
|
||||
const { icon, color } = getFileConfig(attachment.file_type);
|
||||
const is_message = attachment.is_message;
|
||||
|
||||
return <div key={i} className="flex items-center">
|
||||
<div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
|
||||
<div className={`flex-shrink-0 ${color}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
|
||||
<span
|
||||
className="truncate text-xs font-medium text-foreground/90"
|
||||
title={attachment.filename}
|
||||
>
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase">
|
||||
{attachment.file_type.split('/').pop()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 ml-auto pr-1">
|
||||
{is_message && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
onClick={() => {
|
||||
handleViewNestedEml(attachment);
|
||||
}}
|
||||
>
|
||||
<MessageSquareMore className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mail.viewNestedEmail', 'View Embedded Email')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="text-gray-500 text-xs shrink-0">
|
||||
{formatBytes(attachment.size)}
|
||||
</span>
|
||||
{downloadingAttachmentFileName === attachment.filename ? (
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
setDownloadingAttachmentFileName(attachment.filename);
|
||||
downloadAttachmentMutation.mutate({ content_hash: attachment.content_hash });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 text-xs italic">
|
||||
{t('mail.onlyNonInlineAttachments')}
|
||||
</span>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<span className="text-gray-500 text-xs">{t('mail.noAttachments')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showAttachments && <Separator className="mb-2" />}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader className="w-6 h-6 animate-spin" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">loading...</span>
|
||||
</div>
|
||||
) : content ? (
|
||||
<div className="bg-gray-100 rounded-lg border border-gray-300 p-4">
|
||||
{contentType === 'Html' ? (
|
||||
<EmailIframe emailHtml={content} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-gray-800 text-sm font-sans">{content}</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground text-sm">No content available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MailThreadDialog open={threadOpen} onOpenChange={setThreadOpen} />
|
||||
<NestedEmailDialog
|
||||
open={!!nestedEmlFile}
|
||||
onOpenChange={(open: boolean) => !open && setNestedEmlFile(null)}
|
||||
accountId={envelope.account_id}
|
||||
envelopeId={envelope.id}
|
||||
fileName={nestedEmlFile?.filename || ''}
|
||||
content_hash={nestedEmlFile?.content_hash}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatTimestamp(milliseconds: number): string {
|
||||
const date = new Date(milliseconds);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
const timezoneOffset = date.getTimezoneOffset();
|
||||
const offsetSign = timezoneOffset > 0 ? '-' : '+';
|
||||
const offsetHours = String(Math.floor(Math.abs(timezoneOffset) / 60)).padStart(2, '0');
|
||||
const offsetMinutes = String(Math.abs(timezoneOffset) % 60).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMinutes}`;
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ChevronDown, Folders, X, TreeDeciduous, FolderIcon,
|
||||
MoreVertical, Trash2, Search,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import { TransitionProps } from '@mui/material/transitions';
|
||||
|
||||
import {
|
||||
TreeItemCheckbox,
|
||||
TreeItemContent,
|
||||
TreeItemDragAndDropOverlay,
|
||||
TreeItemIcon,
|
||||
TreeItemIconContainer,
|
||||
TreeItemLabel,
|
||||
TreeItemProvider,
|
||||
TreeItemRoot,
|
||||
useTreeItemModel,
|
||||
} from '@mui/x-tree-view';
|
||||
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
|
||||
import { useTreeItem, UseTreeItemParameters } from '@mui/x-tree-view/useTreeItem';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { list_mailboxes } from '@/api/mailbox/api';
|
||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||
import { useSearchContext } from './context';
|
||||
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree';
|
||||
|
||||
const CustomCollapse = styled(Collapse)({ padding: 0 });
|
||||
const AnimatedCollapse = animated(CustomCollapse);
|
||||
|
||||
function TransitionComponent(props: TransitionProps) {
|
||||
const style = useSpring({
|
||||
to: {
|
||||
opacity: props.in ? 1 : 0,
|
||||
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
|
||||
},
|
||||
});
|
||||
return <AnimatedCollapse style={style} {...props} />;
|
||||
}
|
||||
|
||||
interface CustomTreeItemProps
|
||||
extends Omit<UseTreeItemParameters, 'rootRef'>,
|
||||
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
|
||||
|
||||
interface CustomLabelProps {
|
||||
exists?: number;
|
||||
attributes?: { attr: string; extension: string | null }[],
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
icon?: React.ElementType;
|
||||
expandable?: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function CustomLabel({
|
||||
expandable,
|
||||
exists,
|
||||
attributes,
|
||||
children,
|
||||
id,
|
||||
onDelete,
|
||||
...other
|
||||
}: CustomLabelProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<TreeItemLabel
|
||||
{...other}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-3.5 w-3.5" />
|
||||
<span className="font-medium text-xs text-inherit">
|
||||
{children}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-24">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive flex items-center px-2 py-1 text-[11px] cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onDelete(id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
<span>{t('common.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TreeItemLabel>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailboxPopover() {
|
||||
const { t } = useTranslation();
|
||||
const { filter, setFilter, setOpen, setDeleteMailboxId, setSelectedAccountId } = useSearchContext();
|
||||
const { minimalList = [] } = useMinimalAccountList();
|
||||
|
||||
const [localOpen, setLocalOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState('');
|
||||
|
||||
const accountIds: number[] = filter.account_ids ?? [];
|
||||
const selectedMailboxIds: number[] = filter.mailbox_ids ?? [];
|
||||
|
||||
const [localSelectedIds, setLocalSelectedIds] = React.useState<number[]>([]);
|
||||
const [activeAccountId, setActiveAccountId] = React.useState<number | undefined>(undefined);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (localOpen) {
|
||||
const globalMailboxIds = filter.mailbox_ids ?? [];
|
||||
setLocalSelectedIds(globalMailboxIds);
|
||||
|
||||
const currentAccountIds = filter.account_ids ?? [];
|
||||
if (currentAccountIds.length > 0) {
|
||||
if (!activeAccountId || !currentAccountIds.includes(activeAccountId)) {
|
||||
setActiveAccountId(currentAccountIds[0]);
|
||||
}
|
||||
} else {
|
||||
setActiveAccountId(undefined);
|
||||
}
|
||||
}
|
||||
}, [localOpen, activeAccountId, filter.account_ids, filter.mailbox_ids]);
|
||||
|
||||
const { data: activeMailboxes = [], isLoading: activeIsLoading } = useQuery({
|
||||
queryKey: ['search-mailboxes', activeAccountId],
|
||||
queryFn: () => list_mailboxes(activeAccountId!, false),
|
||||
enabled: !!activeAccountId,
|
||||
});
|
||||
|
||||
const treeData = React.useMemo(() => {
|
||||
const filtered = search.trim()
|
||||
? activeMailboxes.filter(m => m.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: activeMailboxes;
|
||||
return buildTree(filtered);
|
||||
}, [activeMailboxes, search]);
|
||||
|
||||
const disabled = accountIds.length === 0;
|
||||
|
||||
const handleApply = () => {
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
mailbox_ids: localSelectedIds.length > 0 ? localSelectedIds : undefined
|
||||
}));
|
||||
setLocalOpen(false);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setDeleteMailboxId(id);
|
||||
setSelectedAccountId(activeAccountId);
|
||||
setOpen('delete-mailbox');
|
||||
};
|
||||
|
||||
|
||||
const CustomTreeItem = React.forwardRef(function CustomTreeItem(
|
||||
props: CustomTreeItemProps,
|
||||
ref: React.Ref<HTMLLIElement>,
|
||||
) {
|
||||
const { id, itemId, label, disabled, children, ...other } = props;
|
||||
const {
|
||||
getContextProviderProps,
|
||||
getRootProps,
|
||||
getContentProps,
|
||||
getLabelProps,
|
||||
getIconContainerProps,
|
||||
getCheckboxProps,
|
||||
getGroupTransitionProps,
|
||||
getDragAndDropOverlayProps,
|
||||
status,
|
||||
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
|
||||
|
||||
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
|
||||
|
||||
|
||||
return (
|
||||
<TreeItemProvider {...getContextProviderProps()}>
|
||||
<TreeItemRoot {...getRootProps(other)} className="group">
|
||||
<TreeItemContent {...getContentProps()} sx={{ paddingY: '2px' }}>
|
||||
<TreeItemIconContainer {...getIconContainerProps()}>
|
||||
<TreeItemIcon status={status} />
|
||||
</TreeItemIconContainer>
|
||||
<TreeItemCheckbox {...getCheckboxProps()} sx={{
|
||||
color: 'hsl(var(--muted-foreground) / 0.4)',
|
||||
'&.Mui-checked': {
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: '1.3rem'
|
||||
}
|
||||
}} />
|
||||
<CustomLabel
|
||||
{...getLabelProps({
|
||||
exists: item.exists,
|
||||
id: item.id,
|
||||
onDelete: handleDeleteClick,
|
||||
attributes: item.attributes,
|
||||
expandable: status.expandable && status.expanded,
|
||||
})}
|
||||
/>
|
||||
|
||||
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
|
||||
</TreeItemContent>
|
||||
{children && <TransitionComponent {...getGroupTransitionProps()} />}
|
||||
</TreeItemRoot>
|
||||
</TreeItemProvider>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={localOpen} onOpenChange={setLocalOpen} >
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'h-6 rounded-none border-l-0 px-3 gap-1.5 transition-colors',
|
||||
selectedMailboxIds.length > 0 && 'bg-primary/10 text-primary border-primary/20'
|
||||
)}
|
||||
>
|
||||
<Folders className="h-4 w-4" />
|
||||
<span className="max-w-[100px] truncate">{t('search_mailbox.label')}</span>
|
||||
{selectedMailboxIds.length > 0 && (
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
|
||||
{selectedMailboxIds.length}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[740px] max-w-[95vw] p-0 flex flex-col h-[480px] shadow-xl border-muted"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-2 border-b bg-muted/10">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('search_mailbox.search_placeholder')}
|
||||
className="h-9 pl-8 text-xs bg-background"
|
||||
/>
|
||||
</div>
|
||||
{localSelectedIds.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLocalSelectedIds([])}
|
||||
className="h-9 text-xs text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<X className="mr-1.5 h-3 w-3" />
|
||||
{t('common.clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-64 border-r bg-muted/20 flex flex-col">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{accountIds.map(id => {
|
||||
const acc = minimalList.find(a => a.id === id);
|
||||
const isActive = activeAccountId === id;
|
||||
const cachedData = queryClient.getQueryData<any[]>(['search-mailboxes', id]);
|
||||
const count = cachedData?.filter(m => localSelectedIds.includes(m.id)).length ?? 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveAccountId(id)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2 text-left rounded-md transition-all",
|
||||
isActive
|
||||
? "bg-background shadow-sm text-primary ring-1 ring-black/5"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs truncate font-medium">
|
||||
{acc?.email}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<span className="text-[10px] font-bold bg-primary/10 px-1.5 py-0.5 rounded-full">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col bg-background">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3">
|
||||
{activeIsLoading ? (
|
||||
<div className="p-4 space-y-4">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="h-3 bg-muted animate-pulse rounded w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : activeAccountId ? (
|
||||
<RichTreeView
|
||||
multiSelect
|
||||
items={treeData}
|
||||
checkboxSelection
|
||||
expansionTrigger="iconContainer"
|
||||
selectedItems={localSelectedIds.map(String)}
|
||||
onSelectedItemsChange={(_, itemIds) => {
|
||||
setLocalSelectedIds(itemIds.map(id => parseInt(id)).filter(id => !isNaN(id)));
|
||||
}}
|
||||
slots={{ item: CustomTreeItem }}
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground opacity-40">
|
||||
<TreeDeciduous className="h-12 w-12 mb-2 stroke-[1px]" />
|
||||
<p className="text-xs">{t('search_mailbox.select_account_tip')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-3 border-t bg-muted/10 flex items-center justify-between">
|
||||
<div className="text-[10px] text-muted-foreground font-medium">
|
||||
{t('search_mailbox.selected_total')}: <span className="text-foreground">{localSelectedIds.length}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setLocalOpen(false)} className="h-8 px-3 text-xs">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleApply} className="h-8 px-4 text-xs gap-1.5 shadow-sm">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('common.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover >
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import * as React from "react"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Info, ListFilter } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearchContext } from "./context"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
const SIZES = {
|
||||
tiny: { min: undefined, max: 15 * 1024 },
|
||||
small: { min: undefined, max: 2 * 1024 * 1024 },
|
||||
medium: { min: 2 * 1024 * 1024, max: 10 * 1024 * 1024 },
|
||||
large: { min: 10 * 1024 * 1024, max: 20 * 1024 * 1024 },
|
||||
huge: { min: 20 * 1024 * 1024, max: undefined },
|
||||
};
|
||||
|
||||
const getPresetFromSize = (min?: number, max?: number) => {
|
||||
if (min === SIZES.huge.min) return 'huge';
|
||||
if (min === SIZES.large.min && max === SIZES.large.max) return 'large';
|
||||
if (min === SIZES.medium.min && max === SIZES.medium.max) return 'medium';
|
||||
if (!min && max === SIZES.small.max) return 'small';
|
||||
if (!min && max === SIZES.tiny.max) return 'tiny';
|
||||
return 'any';
|
||||
};
|
||||
|
||||
export function MoreFiltersPopover() {
|
||||
const { t } = useTranslation();
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const [localState, setLocalState] = React.useState({
|
||||
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
|
||||
is_message: filter?.is_message || false
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setLocalState({
|
||||
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
|
||||
is_message: filter?.is_message || false
|
||||
});
|
||||
}
|
||||
}, [open, filter]);
|
||||
|
||||
const handleApply = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev };
|
||||
|
||||
if (localState.is_message) next.is_message = true;
|
||||
else delete next.is_message;
|
||||
|
||||
const range = SIZES[localState.size_preset as keyof typeof SIZES] || { min: undefined, max: undefined };
|
||||
if (range.min) next.min_size = range.min; else delete next.min_size;
|
||||
if (range.max) next.max_size = range.max; else delete next.max_size;
|
||||
|
||||
return next;
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const activeCount = [
|
||||
filter?.min_size,
|
||||
filter?.max_size,
|
||||
filter?.is_message,
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-6 gap-2 px-3 rounded-none border-l-0",
|
||||
activeCount > 0 && "bg-primary/10 border-primary text-primary"
|
||||
)}
|
||||
>
|
||||
<ListFilter className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{t('search_more.trigger_label')}</span>
|
||||
{activeCount > 0 && (
|
||||
<Badge className="ml-1 h-4 px-1 text-[10px] bg-primary text-primary-foreground border-none rounded-sm">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" className="w-72 p-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-medium">{t('search_more.title')}</h4>
|
||||
{activeCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto p-0 text-[10px] text-muted-foreground hover:text-destructive"
|
||||
onClick={() => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev };
|
||||
delete next.min_size;
|
||||
delete next.max_size;
|
||||
delete next.is_message;
|
||||
return next;
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('search_more.reset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center space-x-2 px-1">
|
||||
<Checkbox
|
||||
id="is_message"
|
||||
checked={localState.is_message}
|
||||
onCheckedChange={(checked) => {
|
||||
const isChecked = checked as boolean;
|
||||
setLocalState(prev => ({
|
||||
...prev,
|
||||
is_message: isChecked
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="is_message"
|
||||
className="text-xs font-normal cursor-pointer select-none"
|
||||
>
|
||||
{t('search_more.is_message')}
|
||||
</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-3 h-3 ml-1.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">{t('search_more.is_message_desc')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('attachment.size')}</Label>
|
||||
<Select
|
||||
value={localState.size_preset}
|
||||
onValueChange={(v) => setLocalState(prev => ({ ...prev, size_preset: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.keys(SIZES).concat('any').map((key) => (
|
||||
<SelectItem key={key} className="text-xs" value={key}>
|
||||
{t(`search_more.size_presets.${key}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button size="sm" className="w-full h-8 text-xs mt-2" onClick={handleApply}>
|
||||
{t('search_more.apply')}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import { EmailEnvelope } from '@/api';
|
||||
import { AttachmentInfo, download_nested_attachment, load_nested_message } from '@/api/mailbox/envelope/api';
|
||||
import EmailIframe from '@/components/mail-iframe';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { formatBytes, formatTimestamp } from '@/lib/utils';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Download, Loader, Mail } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFileConfig } from './mail-message-view';
|
||||
|
||||
const MessageHeader = ({
|
||||
envelope,
|
||||
attachments,
|
||||
onDownload
|
||||
}: {
|
||||
envelope: EmailEnvelope,
|
||||
attachments?: AttachmentInfo[],
|
||||
onDownload: (nested_content_hash: string) => void
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const displayAttachments = attachments || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mb-4 bg-white p-5 rounded-xl border shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-lg font-bold text-slate-900 leading-snug">
|
||||
{envelope.subject || `(${t('mail.noSubject')})`}
|
||||
</h1>
|
||||
<div className="text-[11px] text-slate-400">
|
||||
{formatTimestamp(envelope.date)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="opacity-50" />
|
||||
<div className="grid grid-cols-1 gap-y-3">
|
||||
{/* From */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="w-12 text-[10px] font-bold uppercase text-slate-400 shrink-0">
|
||||
{t('mail.from')}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-slate-700 truncate">
|
||||
{envelope.from}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{envelope.to && envelope.to.length > 0 && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="w-12 text-[10px] font-bold uppercase text-slate-400 shrink-0">
|
||||
{t('mail.to')}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
||||
{envelope.to.map((addr, i) => (
|
||||
<span key={i} className="text-sm text-slate-600">
|
||||
{addr}{i < envelope.to.length - 1 ? ',' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{envelope.cc && envelope.cc.length > 0 && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="w-12 text-[10px] font-bold uppercase text-slate-400 shrink-0">
|
||||
{t('mail.cc')}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-1 text-slate-500 italic">
|
||||
{envelope.cc.map((addr, i) => (
|
||||
<span key={i} className="text-xs">
|
||||
{addr}{i < envelope.cc.length - 1 ? ',' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{envelope.bcc && envelope.bcc.length > 0 && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="w-12 text-[10px] font-bold uppercase text-slate-400 shrink-0">
|
||||
{t('mail.bcc')}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-1 text-slate-500 italic">
|
||||
{envelope.bcc.map((addr, i) => (
|
||||
<span key={i} className="text-xs">
|
||||
{addr}{i < envelope.bcc.length - 1 ? ',' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayAttachments.length > 0 && (
|
||||
<div className="pt-2 border-t border-dashed">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayAttachments.map((att, i) => {
|
||||
const { icon, color } = getFileConfig(att.file_type);
|
||||
return (
|
||||
<Tooltip key={i}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onDownload(att.content_hash)}
|
||||
className="group flex items-center gap-2 px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg hover:bg-blue-50 hover:border-blue-200 transition-all text-slate-600 hover:text-blue-700"
|
||||
>
|
||||
<span className={`${color} p-0.5 rounded`}>{icon}</span>
|
||||
<span className="text-xs font-medium truncate max-w-[180px]">
|
||||
{att.filename}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 group-hover:text-blue-400">
|
||||
({formatBytes(att.size)})
|
||||
</span>
|
||||
<Download className="h-3 w-3 ml-1 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('mail.clickToDownload')}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
export function NestedEmailDialog({ open, onOpenChange, accountId, envelopeId, fileName, content_hash }: any) {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['nested-message', accountId, envelopeId, content_hash],
|
||||
queryFn: () => load_nested_message(accountId, envelopeId, content_hash),
|
||||
enabled: open && !!content_hash,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 overflow-hidden border-none shadow-2xl">
|
||||
<div className="text-white px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium truncate max-w-[400px] opacity-90">{fileName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto bg-white p-8">
|
||||
{isLoading ? (
|
||||
<div className="h-full flex items-center justify-center"><Loader className="animate-spin" /></div>
|
||||
) : data && (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<MessageHeader
|
||||
envelope={data.envelope}
|
||||
attachments={data.attachments}
|
||||
onDownload={(nested_content_hash) => download_nested_attachment(accountId, envelopeId, content_hash, nested_content_hash)}
|
||||
/>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-slate-100">
|
||||
{data.html ? (
|
||||
<EmailIframe emailHtml={data.html} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-slate-800 leading-relaxed">
|
||||
{data.text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import { restore_message } from '@/api/mailbox/envelope/api'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { AxiosError } from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useSearchContext } from './context'
|
||||
import { EmailEnvelope } from '@/api'
|
||||
|
||||
function MessageSummary({ envelope, t }: { envelope: EmailEnvelope, t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="mt-3 rounded-md border bg-muted/20 p-3 text-sm overflow-hidden">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1.5">
|
||||
|
||||
<span className="font-medium text-muted-foreground">{t("mail.subject")}:</span>
|
||||
<div className="break-words font-medium">
|
||||
{envelope.subject || <em className="italic opacity-70">(No subject)</em>}
|
||||
</div>
|
||||
|
||||
<span className="font-medium text-muted-foreground">{t("mail.from")}:</span>
|
||||
<div className="break-all text-foreground/90">
|
||||
{envelope.from}
|
||||
</div>
|
||||
|
||||
{envelope.to?.length > 0 && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">{t("mail.to")}:</span>
|
||||
<div className="break-all text-foreground/90">
|
||||
{envelope.to.slice(0, 2).join(", ")}
|
||||
{envelope.to.length > 2 && " …"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="font-medium text-muted-foreground">{t("mail.date")}:</span>
|
||||
<div className="text-foreground/90">
|
||||
{new Date(envelope.date).toLocaleString()}
|
||||
</div>
|
||||
|
||||
{envelope.mailbox_name && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">{t("search.mailbox")}:</span>
|
||||
<div className="truncate text-foreground/90" title={envelope.mailbox_name}>
|
||||
{envelope.mailbox_name}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface RestoreMessageDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function RestoreMessageDialog({
|
||||
open,
|
||||
onOpenChange
|
||||
}: RestoreMessageDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentEnvelope, selected } = useSearchContext()
|
||||
|
||||
const accountsWithSelection = Array.from(selected.entries()).filter(([_, ids]) => ids.size > 0);
|
||||
const selectedCount = accountsWithSelection.reduce((sum, [_, set]) => sum + set.size, 0);
|
||||
const accountCount = accountsWithSelection.length;
|
||||
|
||||
const isBulk = selectedCount > 0;
|
||||
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (isBulk) {
|
||||
const promises = accountsWithSelection.map(([accountId, ids]) =>
|
||||
restore_message(accountId, Array.from(ids))
|
||||
);
|
||||
return Promise.all(promises);
|
||||
} else if (currentEnvelope) {
|
||||
return restore_message(currentEnvelope.account_id, [currentEnvelope.id]);
|
||||
}
|
||||
},
|
||||
onSuccess: handleRestoreSuccess,
|
||||
onError: handleRestoreError,
|
||||
});
|
||||
|
||||
function handleRestoreSuccess() {
|
||||
toast({
|
||||
title: t('restore_message.success', 'Messages restored'),
|
||||
description: t(
|
||||
'restore_message.successDesc',
|
||||
'The selected messages have been restored to the IMAP server.'
|
||||
),
|
||||
action: (
|
||||
<ToastAction altText={t('common.close')}>
|
||||
{t('common.close')}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleRestoreError(error: AxiosError) {
|
||||
const errorMessage =
|
||||
(error.response?.data as { message?: string })?.message ||
|
||||
error.message ||
|
||||
t('restore_message.failed', 'Failed to restore messages');
|
||||
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t(
|
||||
'restore_message.failedTitle',
|
||||
'Restore failed'
|
||||
),
|
||||
description: errorMessage,
|
||||
action: (
|
||||
<ToastAction altText={t('common.tryAgain')}>
|
||||
{t('common.tryAgain')}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={isBulk ? t('restore_message.bulkTitle', 'Restore multiple messages') : t('restore_message.title', 'Restore message')}
|
||||
desc={<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'restore_message.desc',
|
||||
'This action will append the selected messages to their corresponding mailboxes on the IMAP server.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
{isBulk ? (
|
||||
<div className="rounded-md bg-primary/5 border border-primary/20 p-3 text-sm">
|
||||
<div className="flex justify-between items-center text-primary font-medium">
|
||||
<span>{t('restore_message.summary', 'Summary')}</span>
|
||||
<span className="bg-primary/10 px-2 py-0.5 rounded text-xs">
|
||||
{selectedCount} {t('restore_message.messages', 'messages')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs space-y-1 text-muted-foreground">
|
||||
<p>• {t('restore_message.accountsInvolved', 'Accounts involved')}: {accountCount}</p>
|
||||
<p>• {t('restore_message.bulkWarning', 'Messages will be restored to their original folders.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
currentEnvelope && <MessageSummary envelope={currentEnvelope} t={t} />
|
||||
)}
|
||||
</div>}
|
||||
confirmText={t('restore_message.confirm', 'Restore')}
|
||||
handleConfirm={() => restoreMutation.mutate()}
|
||||
className="sm:max-w-sm"
|
||||
isLoading={restoreMutation.isPending}
|
||||
disabled={restoreMutation.isPending || (!isBulk && !currentEnvelope)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDown, Mail } from "lucide-react"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearchContext } from "./context"
|
||||
import { userAttachmentSenders } from "@/hooks/use-attachment-senders"
|
||||
import { Group } from "@/api/system/api"
|
||||
import { MetadataSelectorField } from "./attachment-metadata-selector"
|
||||
|
||||
export function SenderFilterPopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const { senders, isLoading } = userAttachmentSenders("")
|
||||
|
||||
const activeCount = filter.from ? 1 : 0
|
||||
const senderOptions: Group[] = React.useMemo(() => {
|
||||
return senders.map(email => ({
|
||||
key: email,
|
||||
count: 0
|
||||
}))
|
||||
}, [senders])
|
||||
|
||||
const updateFilter = (email: string | undefined) => {
|
||||
setFilter(prev => ({
|
||||
...prev,
|
||||
from: email
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-6 rounded-none px-3 gap-1.5 transition-colors border-l-0',
|
||||
activeCount > 0 && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||
)}
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 opacity-60" />
|
||||
<span>
|
||||
{activeCount > 0
|
||||
? t('attachment.sender_with_count', { count: activeCount })
|
||||
: t('attachment.sender')}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-fit min-w-[280px] max-w-[90vw] sm:max-w-[min(90vw,500px)] p-0 flex flex-col divide-y divide-border shadow-xl"
|
||||
>
|
||||
<div className="flex flex-col bg-muted/20">
|
||||
<MetadataSelectorField
|
||||
label={t('attachment.sender')}
|
||||
value={filter.from}
|
||||
options={senderOptions}
|
||||
isLoading={isLoading}
|
||||
onSelect={(val) => updateFilter(val)}
|
||||
onReset={() => updateFilter(undefined)}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CaretSortIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { Column } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className=' h-8 data-[state=open]:bg-accent'
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className='ml-2 h-4 w-4' />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className='ml-2 h-4 w-4' />
|
||||
) : (
|
||||
<CaretSortIcon className='ml-2 h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
{t('table.asc')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
|
||||
{t('table.desc')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import { Row } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MoreVertical, TagIcon } from 'lucide-react'
|
||||
import { useSearchContext } from '../context'
|
||||
import { AttachmentModel } from '@/api/attachment/api'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<AttachmentModel>
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { setOpen, setCurrentEnvelope, setSelected } = useSearchContext()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
|
||||
>
|
||||
<MoreVertical size={10} />
|
||||
<span className='sr-only'>Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[160px]'>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCurrentEnvelope(row.original)
|
||||
setOpen("edit-tags")
|
||||
}}
|
||||
>
|
||||
{t('attachment.editTag')}
|
||||
<DropdownMenuShortcut>
|
||||
<TagIcon size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import { useState, MouseEvent as ReactMouseEvent, useEffect } from 'react'
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
Row,
|
||||
RowData,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import {
|
||||
Table as ShadcnTable,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from '../context'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { AttachmentModel } from '@/api/attachment/api'
|
||||
|
||||
|
||||
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
className: string
|
||||
}
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
columns: ColumnDef<AttachmentModel>[]
|
||||
data: AttachmentModel[]
|
||||
onRowClick: (e: ReactMouseEvent<HTMLTableRowElement, MouseEvent>, row: Row<AttachmentModel>) => void
|
||||
setSortBy: (sortBy: "DATE" | "SIZE") => void
|
||||
setSortOrder: (value: "desc" | "asc") => void
|
||||
children?: (table: Table<AttachmentModel>) => React.ReactNode
|
||||
}
|
||||
|
||||
export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder, children }: DataTableProps) {
|
||||
const { sorting, setSorting } = useSearchContext()
|
||||
const { t } = useTranslation()
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
|
||||
useEffect(() => {
|
||||
const [value] = sorting
|
||||
setSortBy(value.id.toUpperCase() as "DATE" | "SIZE")
|
||||
setSortOrder(value.desc ? "desc" : "asc")
|
||||
}, [sorting])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
{children && (<>{children(table)}</>)}
|
||||
<ScrollArea className='h-[calc(100vh-16rem)] rounded-md border' orientation='both'>
|
||||
<ShadcnTable>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className='group/row'>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={header.column.columnDef.meta?.className ?? ''}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={cn("group/row cursor-pointer transition-colors hover:bg-accent/50")}
|
||||
onClick={(e) => onRowClick(e, row)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cell.column.columnDef.meta?.className ?? ''}
|
||||
style={{
|
||||
width: cell.column.columnDef.size,
|
||||
minWidth: cell.column.columnDef.minSize,
|
||||
maxWidth: cell.column.columnDef.maxSize
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
{t('common.table.noResults')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
</TableBody>
|
||||
</ShadcnTable>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
import { TagFilterPopover } from '../tag-filter-popover'
|
||||
import { TimePopover } from '../time-popover'
|
||||
import { SenderFilterPopover } from '../sender-popover'
|
||||
import { TextSearchInput } from '../text-search-input'
|
||||
import { MoreFiltersPopover } from '../more-filters-popover'
|
||||
import { FilterResetButton } from '../filter-reset'
|
||||
import { MailboxPopover } from '../mailbox-popover'
|
||||
import { AccountPopover } from '../account-popover'
|
||||
import { MetadataFilter } from '../attachment-metadata-filter'
|
||||
import { FileType, Laptop, Tag } from 'lucide-react'
|
||||
|
||||
type DataTableToolbarProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-1 bg-background">
|
||||
<div className="mb-4 flex items-center justify-center w-full">
|
||||
<div className="w-full max-w-3xl">
|
||||
<TextSearchInput />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-1">
|
||||
<div className="flex items-center gap-2 flex-wrap w-full sm:w-auto">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<AccountPopover />
|
||||
<MailboxPopover />
|
||||
<SenderFilterPopover />
|
||||
<TagFilterPopover />
|
||||
|
||||
<MetadataFilter
|
||||
type="extension"
|
||||
icon={<FileType className="h-3.5 w-3.5" />}
|
||||
/>
|
||||
<MetadataFilter
|
||||
type="category"
|
||||
icon={<Tag className="h-3.5 w-3.5" />}
|
||||
/>
|
||||
<MetadataFilter
|
||||
type="content_type"
|
||||
icon={<Laptop className="h-3.5 w-3.5" />}
|
||||
/>
|
||||
|
||||
<MoreFiltersPopover />
|
||||
</div>
|
||||
<FilterResetButton />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<TimePopover />
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
|
||||
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type DataTableViewOptionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
const defaultColumns = (t: (key: string) => string) => [
|
||||
{ label: t('search.account'), value: "account_email" },
|
||||
{ label: t('search.mailbox'), value: "mailbox_name" },
|
||||
{ label: t('search.from'), value: "from" },
|
||||
{ label: t('search.to'), value: "to" },
|
||||
{ label: t('search.subject'), value: "subject" },
|
||||
{ label: t('search.size'), value: "size" },
|
||||
{ label: t('search.date'), value: "date" },
|
||||
]
|
||||
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
const columnLabels = React.useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
defaultColumns(t).map(col => [col.value, col.label])
|
||||
)
|
||||
}, [t]);
|
||||
|
||||
|
||||
const visibleColumnKeys = React.useMemo(() => {
|
||||
return new Set(defaultColumns(t).map(c => c.value))
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='ms-auto hidden h-6 lg:flex rounded-none'
|
||||
>
|
||||
<MixerHorizontalIcon className='size-4' />
|
||||
{t('search_view.button_label')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[150px]'>
|
||||
<DropdownMenuLabel className='text-xs'>{t('search_view.menu_title')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(column => visibleColumnKeys.has(column.id))
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className='capitalize text-xs'
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{columnLabels[column.id] ?? column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import * as React from 'react'
|
||||
import { Tag, ChevronDown, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
import { useAvailableAttachmentTags } from '@/hooks/use-available-attachment-tags'
|
||||
|
||||
export function TagFilterPopover() {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = React.useState('')
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
|
||||
const selectedTags = (filter?.tags as string[]) || []
|
||||
|
||||
const {
|
||||
tagsCount = [],
|
||||
isLoading,
|
||||
} = useAvailableAttachmentTags()
|
||||
|
||||
const handleTagToggle = (tag: string) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
const currentTags = (next.tags as string[]) || []
|
||||
const isSelected = currentTags.includes(tag)
|
||||
|
||||
const nextTags = isSelected
|
||||
? currentTags.filter(t => t !== tag)
|
||||
: [...currentTags, tag]
|
||||
|
||||
if (nextTags.length > 0) {
|
||||
next.tags = nextTags
|
||||
} else {
|
||||
delete next.tags
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearAllTags = () => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
delete next.tags
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const filteredTags = React.useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
|
||||
return tagsCount
|
||||
.filter(t =>
|
||||
!q || t.tag.toLowerCase().includes(q)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aSelected = selectedTags.includes(a.tag)
|
||||
const bSelected = selectedTags.includes(b.tag)
|
||||
if (aSelected && !bSelected) return -1
|
||||
if (!aSelected && bSelected) return 1
|
||||
return b.count - a.count
|
||||
})
|
||||
}, [tagsCount, search, selectedTags])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-6 gap-1.5 px-3 rounded-none border-l-0',
|
||||
selectedTags.length > 0 &&
|
||||
'bg-primary/10 border-primary text-primary'
|
||||
)}
|
||||
>
|
||||
<Tag className="h-4 w-4" />
|
||||
{t('tag.label')}
|
||||
{selectedTags.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-5 px-1.5 text-xs"
|
||||
>
|
||||
{selectedTags.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-96 p-1"
|
||||
>
|
||||
<div className="p-1 pb-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('tag.search_placeholder')}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-96 p-1">
|
||||
{!search && selectedTags.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
onClick={clearAllTags}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center">
|
||||
<X className="h-3 w-3" />
|
||||
</div>
|
||||
<span className="flex-1 text-xs font-medium">
|
||||
{t('tag.clear_all')}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60">({selectedTags.length})</span>
|
||||
</div>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
</>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2 p-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-4 rounded bg-muted animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : filteredTags.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('tag.no_tags_found')}
|
||||
</p>
|
||||
) : (
|
||||
filteredTags.map(({ tag, count }) => {
|
||||
const checked = selectedTags.includes(tag)
|
||||
const id = `tag-${tag}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tag}
|
||||
onClick={() => handleTagToggle(tag)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
|
||||
'hover:bg-accent transition-colors'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={() =>
|
||||
handleTagToggle(tag)
|
||||
}
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex-1 truncate text-xs cursor-pointer"
|
||||
title={tag}
|
||||
>
|
||||
{tag}
|
||||
</Label>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-xs"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Search, X, Clock, Trash2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useSearchContext } from "./context"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
const STORAGE_KEY = "bichon_attachment_search_history"
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
|
||||
type SearchField = "text" | "subject" | "attachment_name" | "from"
|
||||
const SEARCH_FIELDS: SearchField[] = ["text", "subject", "attachment_name", "from"]
|
||||
|
||||
export function TextSearchInput() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
|
||||
const [value, setValue] = useState("")
|
||||
const [field, setField] = useState<SearchField>("text")
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const activeField = SEARCH_FIELDS.find(key => !!filter[key]) || "text"
|
||||
const activeValue = filter[activeField] as string || ""
|
||||
|
||||
setField(activeField)
|
||||
setValue(activeValue)
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) setHistory(JSON.parse(saved))
|
||||
} catch (err) {
|
||||
console.warn("Failed to load attachment search history", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const applyFilter = (currentField: SearchField, searchTerm: string) => {
|
||||
const trimmed = searchTerm.trim()
|
||||
|
||||
setFilter((prev) => {
|
||||
const next = { ...prev }
|
||||
SEARCH_FIELDS.forEach(f => {
|
||||
delete next[f]
|
||||
})
|
||||
if (trimmed) {
|
||||
next[currentField] = trimmed
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
if (trimmed) {
|
||||
saveToHistory(trimmed)
|
||||
}
|
||||
setShowHistory(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
|
||||
const saveToHistory = (term: string) => {
|
||||
setHistory((prev) => {
|
||||
const trimmed = term.trim()
|
||||
const newHistory = [trimmed, ...prev.filter((item) => item !== trimmed)].slice(0, MAX_HISTORY)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory))
|
||||
return newHistory
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearch = () => applyFilter(field, value)
|
||||
|
||||
const handleClear = () => {
|
||||
setValue("")
|
||||
applyFilter(field, "")
|
||||
}
|
||||
|
||||
const handleSelectHistory = (term: string) => {
|
||||
setValue(term)
|
||||
applyFilter(field, term)
|
||||
}
|
||||
|
||||
const handleClearHistory = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setHistory([])
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowHistory(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full max-w-[620px] min-w-[320px]">
|
||||
<div className="flex items-center rounded-md border bg-background focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/30 transition-all">
|
||||
<Select
|
||||
value={field}
|
||||
onValueChange={(val) => {
|
||||
const newField = val as SearchField
|
||||
setField(newField)
|
||||
if (value.trim()) applyFilter(newField, value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-9 w-[110px] md:w-[130px] border-r border-border rounded-r-none",
|
||||
"text-xs md:text-xs bg-transparent focus:ring-0 focus:ring-offset-0 shadow-none border-y-0 border-l-0"
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[240px]">
|
||||
<SelectItem value="text" className="font-medium cursor-pointer text-xs">
|
||||
{t("search_input.all")}
|
||||
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
|
||||
{t("attachment.all_fields_desc")}
|
||||
</p>
|
||||
</SelectItem>
|
||||
<SelectItem value="subject" className="cursor-pointer text-xs">
|
||||
{t("search_input.subject")}
|
||||
</SelectItem>
|
||||
<SelectItem value="body" className="cursor-pointer text-xs">
|
||||
{t("attachment.name")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setShowHistory(true)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder={t("attachment.search_input_placeholder")}
|
||||
className="h-9 border-none shadow-none focus-visible:ring-0 pl-9 pr-10 text-sm bg-transparent w-full"
|
||||
/>
|
||||
{value && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 mr-1.5 px-3 text-xs md:px-5 md:text-sm"
|
||||
onClick={handleSearch}
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
{t("search_input.button")}
|
||||
</Button>
|
||||
</div>
|
||||
{showHistory && (
|
||||
<div className="absolute top-full left-0 w-full mt-1 bg-popover border rounded-md shadow-lg z-50 max-h-[300px] overflow-hidden flex flex-col">
|
||||
<div className="py-2 px-3 text-[10px] uppercase tracking-wider text-muted-foreground font-semibold border-b flex items-center justify-between bg-muted/30">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t("search_input.recent_title")}
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearHistory}
|
||||
className="text-destructive hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("search_input.clear_history")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto py-1">
|
||||
{history.length > 0 ? (
|
||||
history.map((term, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 group"
|
||||
onClick={() => handleSelectHistory(term)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground group-hover:text-primary" />
|
||||
<span className="truncate flex-1 text-xs">{term}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-6 text-sm text-center text-muted-foreground">
|
||||
{t("search_input.no_history")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { ChevronDown, ChevronUp, Loader2, MessageSquareText } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { get_thread_messages } from '@/api/mailbox/envelope/api';
|
||||
import { MailMessageView } from './mail-message-view';
|
||||
import { useSearchContext } from './context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface MailThreadDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps) {
|
||||
const { currentEnvelope } = useSearchContext();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const { t } = useTranslation();
|
||||
|
||||
const threadId = currentEnvelope?.thread_id;
|
||||
const accountId = currentEnvelope?.account_id;
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['thread', accountId, threadId],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
get_thread_messages(accountId!, threadId!, pageParam, 10),
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.current_page && lastPage.total_pages
|
||||
? lastPage.current_page < lastPage.total_pages
|
||||
? lastPage.current_page + 1
|
||||
: undefined
|
||||
: undefined,
|
||||
enabled: open && !!accountId && !!threadId,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
|
||||
const allMessages = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
const totalCount = data?.pages[0]?.total_items ?? 0;
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-width-full p-0 max-h-full flex flex-col md:max-w-3xl lg:max-w-4xl">
|
||||
{/* Header */}
|
||||
<DialogHeader className="p-4 pb-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquareText className="w-5 h-5" />
|
||||
<div className="text-sm">
|
||||
{t('search.thread.title', { count: totalCount })}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{isLoading && <ThreadSkeleton />}
|
||||
|
||||
{isError && (
|
||||
<div className="text-center text-destructive text-sm">
|
||||
{t('search.thread.error')}: {(error as Error)?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && allMessages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm">
|
||||
{t('search.thread.empty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allMessages
|
||||
.sort((a, b) => a.date - b.date)
|
||||
.map((msg) => {
|
||||
const isExpanded = expandedIds.has(msg.id);
|
||||
const preview = msg.preview;
|
||||
const date = new Date(msg.date);
|
||||
const formattedDate = isNaN(date.getTime())
|
||||
? t('search.thread.invalidDate')
|
||||
: format(date, 'yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={msg.id}
|
||||
className={`transition-all ${isExpanded ? 'ring-2 ring-primary' : ''}`}
|
||||
>
|
||||
<CardHeader
|
||||
className="cursor-pointer pb-3"
|
||||
onClick={() => toggleExpand(msg.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium truncate">{msg.from}</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{msg.to.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium mt-1 text-sm">
|
||||
{msg.subject || t('search.thread.noSubject')}
|
||||
</p>
|
||||
{!isExpanded && preview && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formattedDate}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent className="p-0">
|
||||
<div className="h-96 border-t m-5">
|
||||
<MailMessageView
|
||||
envelope={msg}
|
||||
showActions={false}
|
||||
showAttachments={false}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center py-3">
|
||||
<Button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('search.thread.loadingMore')}
|
||||
</>
|
||||
) : (
|
||||
t('search.thread.loadMore')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton
|
||||
function ThreadSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-64 mb-1" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-32 mt-2" />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
import * as React from 'react'
|
||||
import { CalendarRange, ChevronDown, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchContext } from './context'
|
||||
import { DatePicker } from '@/components/date-picker'
|
||||
|
||||
const DAY = 86400000
|
||||
|
||||
export function TimePopover() {
|
||||
const { t } = useTranslation()
|
||||
const { filter, setFilter } = useSearchContext()
|
||||
const [customDays, setCustomDays] = React.useState<string>('')
|
||||
|
||||
const since = filter.since
|
||||
const before = filter.before
|
||||
|
||||
const toDate = (ts: number) => {
|
||||
return format(ts, t('time.format'))
|
||||
}
|
||||
|
||||
const label = (s?: number, b?: number) => {
|
||||
if (!s && !b) return t('time.label')
|
||||
if (s && b) return `${toDate(s)} → ${toDate(b)}`
|
||||
if (s) return `${t('time.since')} ${toDate(s)}`
|
||||
return `${t('time.before')} ${toDate(b!)}`
|
||||
}
|
||||
|
||||
const setRange = (s?: number, b?: number) => {
|
||||
setFilter(prev => {
|
||||
const next = { ...prev }
|
||||
s ? (next.since = s) : delete next.since
|
||||
b ? (next.before = b) : delete next.before
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setSince = (s?: number) => setRange(s, before)
|
||||
const setBefore = (b?: number) => setRange(since, b)
|
||||
|
||||
const handleApplyRecent = () => {
|
||||
const days = parseInt(customDays)
|
||||
if (!isNaN(days) && days > 0) {
|
||||
setRange(Date.now() - days * DAY, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setRange()
|
||||
setCustomDays('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-6 rounded-none px-3 gap-1.5 transition-colors max-w-full',
|
||||
(since || before) && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||
)}
|
||||
>
|
||||
<CalendarRange className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate max-w-[120px] sm:max-w-none">
|
||||
{label(since, before)}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-[92vw] sm:w-[420px] max-w-[420px] p-4 space-y-6"
|
||||
>
|
||||
<Section title={t('time.recent_range')}>
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[1, 7, 30].map(d => (
|
||||
<Quick key={d} onClick={() => setRange(Date.now() - d * DAY, undefined)}>
|
||||
{d === 1 ? t('time.last_day') : t('time.last_days', { count: d })}
|
||||
</Quick>
|
||||
))}
|
||||
{[3, 6].map(m => (
|
||||
<Quick key={m} onClick={() => setRange(Date.now() - m * 30 * DAY, undefined)}>
|
||||
{t('time.last_months', { count: m })}
|
||||
</Quick>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pt-3 border-t border-border/50">
|
||||
<span className="text-[10px] uppercase font-bold opacity-40 shrink-0">
|
||||
{t('time.recent_prefix')}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="10"
|
||||
className="h-8 w-full sm:w-20 text-xs"
|
||||
value={customDays}
|
||||
onChange={e => setCustomDays(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleApplyRecent()}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{t('time.days_ago_to_now')}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 px-3 sm:ml-auto text-xs w-full sm:w-auto"
|
||||
onClick={handleApplyRecent}
|
||||
>
|
||||
{t('time.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('time.historical')}>
|
||||
<div className="flex flex-wrap gap-2 w-full">
|
||||
{[1, 2, 3, 5, 10].map(y => (
|
||||
<Quick
|
||||
key={y}
|
||||
onClick={() => setRange(undefined, Date.now() - y * 365 * DAY)}
|
||||
className="border-orange-200 hover:border-orange-400 hover:text-orange-600"
|
||||
>
|
||||
{t('time.over_years_ago', {
|
||||
count: y,
|
||||
unit: y === 1 ? t('time.year') : t('time.years')
|
||||
})}
|
||||
</Quick>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('time.absolute_range')}>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
|
||||
{t('time.since').toUpperCase()}:
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<DatePicker
|
||||
placeholder={t('time.start_date')}
|
||||
selected={since ? new Date(since) : undefined}
|
||||
onSelect={(date) => setSince(date?.getTime())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
|
||||
{t('time.before').toUpperCase()}:
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<DatePicker
|
||||
placeholder={t('time.end_date')}
|
||||
selected={before ? new Date(before) : undefined}
|
||||
onSelect={(date) => setBefore(date?.getTime())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{(since || before) && (
|
||||
<div className="px-1 pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clear}
|
||||
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<X className="mr-2 h-3.5 w-3.5" />
|
||||
{t('time.clear_filters')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<div className="text-[11px] font-semibold mb-2.5 text-muted-foreground uppercase tracking-wider">
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Quick({
|
||||
children,
|
||||
onClick,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-7 px-2.5 text-xs font-normal hover:bg-primary/5 hover:text-primary shrink-0",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export function AccountPopover() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 gap-1.5 px-3 rounded-none',
|
||||
'h-6 gap-1.5 px-3 rounded-none',
|
||||
selectedIds.length > 0 &&
|
||||
'bg-primary/10 border-primary text-primary'
|
||||
)}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function MailFilterPopover() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 rounded-none px-3 gap-1.5 transition-colors',
|
||||
'h-6 rounded-none px-3 gap-1.5 transition-colors',
|
||||
activeCount > 0 && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function FilterResetButton() {
|
||||
size="sm"
|
||||
onClick={() => setFilter(q ? { q } : {})}
|
||||
className={cn(
|
||||
"h-8 px-2 text-xs gap-1.5 font-normal",
|
||||
"h-6 px-2 text-xs gap-1.5 font-normal",
|
||||
"text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
)}
|
||||
title={t('search_reset.tooltip')}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FixedHeader } from '@/components/layout/fixed-header';
|
||||
import { Main } from '@/components/layout/main';
|
||||
import { useSearchMessages } from '@/hooks/use-search-messages';
|
||||
import { EnvelopeListPagination } from '@/components/pagination';
|
||||
import { AttachmentListPagination } from '@/components/pagination';
|
||||
import React from 'react';
|
||||
import { EmailEnvelope } from '@/api';
|
||||
import { MailDisplayDrawer } from './mail-display-dialog';
|
||||
@@ -36,7 +36,7 @@ import { SortingState } from '@tanstack/react-table';
|
||||
import { MailBoxDeleteDialog } from './delete-mailbox-dialog';
|
||||
import { UpdateTagsDialog } from './bulk-add-tag-dialog';
|
||||
|
||||
export default function Search() {
|
||||
export default function EmailSearch() {
|
||||
const { t } = useTranslation()
|
||||
const [selectedEnvelope, setSelectedEnvelope] = React.useState<EmailEnvelope | undefined>(undefined);
|
||||
const [open, setOpen] = useDialogState<SearchDialogType>(null)
|
||||
@@ -125,7 +125,7 @@ export default function Search() {
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
/>
|
||||
{total > 0 && <EnvelopeListPagination
|
||||
{total > 0 && <AttachmentListPagination
|
||||
totalItems={total}
|
||||
hasNextPage={() => page < totalPages}
|
||||
pageIndex={page - 1}
|
||||
@@ -156,7 +156,7 @@ export default function Search() {
|
||||
/>
|
||||
|
||||
<UpdateTagsDialog
|
||||
key='edit-tags-dialog'
|
||||
key='update-tags-dialog'
|
||||
open={open === 'update-tags'}
|
||||
onOpenChange={() => setOpen('update-tags')}
|
||||
/>
|
||||
|
||||
@@ -86,110 +86,54 @@ export function MailListTable({
|
||||
maxSize: 25,
|
||||
},
|
||||
{
|
||||
accessorKey: "account_email",
|
||||
header: t('search.account'),
|
||||
accessorKey: "source",
|
||||
header: t('search.source'),
|
||||
cell: ({ row }) => {
|
||||
const { from, account_email, mailbox_name, account_id, mailbox_id } = row.original;
|
||||
const { setFilter } = useSearchMessages();
|
||||
const { account_email, account_id } = row.original;
|
||||
const accountPrefix = account_email.split('@')[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex items-center w-full h-full px-2 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFilter((prev: any) => ({
|
||||
...prev,
|
||||
account_ids: [account_id],
|
||||
mailbox_ids: undefined
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
|
||||
<div className="absolute inset-x-2 bottom-0.5 h-[1px] bg-primary scale-x-0 group-hover:scale-x-100 transition-transform duration-200 origin-left" />
|
||||
<span className="truncate flex-1 min-w-0 group-hover:text-primary transition-colors">
|
||||
<LongText className='text-xs'>{account_email}</LongText>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { className: 'w-[150px]' },
|
||||
minSize: 150, maxSize: 150,
|
||||
},
|
||||
{
|
||||
accessorKey: "mailbox_name",
|
||||
header: t('search.mailbox'),
|
||||
cell: ({ row }) => {
|
||||
const { setFilter } = useSearchMessages();
|
||||
const { mailbox_name, mailbox_id, account_id, tags } = row.original;
|
||||
<div className="flex flex-col py-1.5 min-w-0 group">
|
||||
<div
|
||||
className="cursor-pointer hover:text-primary transition-colors flex items-center gap-1.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFilter((prev: any) => ({ ...prev, from: from }));
|
||||
}}
|
||||
>
|
||||
<LongText className="text-xs truncate">
|
||||
{from}
|
||||
</LongText>
|
||||
</div>
|
||||
|
||||
if (!mailbox_name) return null;
|
||||
const safeTags = tags ?? [];
|
||||
const visibleTags = safeTags.slice(0, 2);
|
||||
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/70">
|
||||
<span
|
||||
className="truncate max-w-[90px] hover:text-primary cursor-pointer transition-colors"
|
||||
title={account_email}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFilter((prev: any) => ({ ...prev, account_ids: [account_id], mailbox_ids: undefined }));
|
||||
}}
|
||||
>
|
||||
{accountPrefix}
|
||||
</span>
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex items-center w-full min-w-0 h-full px-2 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFilter((prev: any) => ({
|
||||
...prev,
|
||||
account_ids: [account_id],
|
||||
mailbox_ids: [mailbox_id]
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
|
||||
<div className="absolute inset-x-2 bottom-0.5 h-[1px] bg-primary scale-x-0 group-hover:scale-x-100 transition-transform duration-200 origin-left" />
|
||||
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[11px] truncate font-medium leading-none group-hover:text-primary transition-colors">
|
||||
<span className="shrink-0 opacity-40">/</span>
|
||||
<span
|
||||
className="truncate max-w-[70px] hover:text-primary cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFilter((prev: any) => ({ ...prev, account_ids: [account_id], mailbox_ids: [mailbox_id] }));
|
||||
}}
|
||||
>
|
||||
{mailbox_name}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{visibleTags.map((tag, i) => (
|
||||
<span key={i} className="px-1.5 py-0.5 rounded-sm bg-primary/10 text-primary text-[9px] font-medium leading-none border border-primary/20 whitespace-nowrap">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{safeTags.length > 2 && (
|
||||
<span className="px-1.5 py-0.5 rounded-sm bg-gray-100 text-gray-500 text-[9px] font-medium leading-none border border-gray-200">
|
||||
+{safeTags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { className: 'text-xs' },
|
||||
maxSize: 120,
|
||||
},
|
||||
{
|
||||
accessorKey: "from",
|
||||
header: t('search.from'),
|
||||
cell: ({ row }) => {
|
||||
const fromEmail = row.original.from;
|
||||
const { setFilter } = useSearchMessages();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex items-center w-full min-w-0 h-full px-2 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFilter((prev: Record<string, any>) => ({ ...prev, from: fromEmail }))
|
||||
}}
|
||||
>
|
||||
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
|
||||
<div className="absolute inset-x-2 bottom-0.5 h-[1px] bg-primary scale-x-0 group-hover:scale-x-100 transition-transform duration-200 origin-left" />
|
||||
<LongText className="text-xs flex-1 truncate group-hover:text-primary transition-colors">
|
||||
{fromEmail}
|
||||
</LongText>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 150,
|
||||
maxSize: 300,
|
||||
},
|
||||
{
|
||||
accessorKey: "to",
|
||||
@@ -224,20 +168,17 @@ export function MailListTable({
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 150,
|
||||
maxSize: 200,
|
||||
meta: { className: 'text-left text-xs' }
|
||||
},
|
||||
{
|
||||
accessorKey: "subject",
|
||||
header: t('search.subject'),
|
||||
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
maxSize: 450,
|
||||
meta: { className: 'text-left text-xs' }
|
||||
},
|
||||
{
|
||||
id: "text_preview",
|
||||
header: () => null,
|
||||
header: t('search.preview'),
|
||||
cell: ({ row }) => {
|
||||
const preview = row.original.preview
|
||||
|
||||
@@ -265,18 +206,18 @@ export function MailListTable({
|
||||
</HoverCard>
|
||||
)
|
||||
},
|
||||
meta: { className: "text-center max-w-[80px]" },
|
||||
minSize: 36,
|
||||
maxSize: 36,
|
||||
meta: { className: "text-center text-xs" },
|
||||
enableSorting: false,
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
},
|
||||
{
|
||||
id: "attachment_count",
|
||||
header: () => <Paperclip size={16} />,
|
||||
cell: ({ row }) => <span className='text-xs'>{row.original.regular_attachment_count}</span>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 40,
|
||||
maxSize: 40
|
||||
minSize: 30,
|
||||
maxSize: 30,
|
||||
},
|
||||
{
|
||||
accessorKey: 'size',
|
||||
@@ -285,8 +226,8 @@ export function MailListTable({
|
||||
),
|
||||
cell: ({ row }) => <span className='text-xs max-w-[40px]'>{formatBytes(row.original.size)}</span>,
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
minSize: 80,
|
||||
maxSize: 80,
|
||||
},
|
||||
{
|
||||
accessorKey: 'date',
|
||||
@@ -308,15 +249,15 @@ export function MailListTable({
|
||||
)
|
||||
},
|
||||
meta: { className: 'text-left text-xs' },
|
||||
minSize: 130,
|
||||
maxSize: 130,
|
||||
minSize: 100,
|
||||
maxSize: 100,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('users.columns.actions'),
|
||||
cell: DataTableRowActions,
|
||||
meta: { className: 'text-right text-xs' },
|
||||
minSize: 50,
|
||||
minSize: 60,
|
||||
maxSize: 60,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -271,7 +271,7 @@ export function MailboxPopover() {
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'h-8 rounded-none px-3 gap-1.5 transition-colors',
|
||||
'h-6 rounded-none px-3 gap-1.5 transition-colors',
|
||||
selectedMailboxIds.length > 0 && 'bg-primary/10 text-primary border-primary/20'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -129,7 +129,7 @@ export function MoreFiltersPopover() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 gap-2 px-3 rounded-none border-l-0",
|
||||
"h-6 gap-2 px-3 rounded-none border-l-0",
|
||||
activeCount > 0 && "bg-primary/10 border-primary text-primary"
|
||||
)}
|
||||
>
|
||||
@@ -218,7 +218,7 @@ export function MoreFiltersPopover() {
|
||||
/>
|
||||
|
||||
<MetadataSelectorField
|
||||
label={t('search_more.content_types')}
|
||||
label={t('search_more.content_type')}
|
||||
value={localState.attachment_content_type}
|
||||
options={meta?.content_types || []}
|
||||
isLoading={metaLoading}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
{children && (<>{children(table)}</>)}
|
||||
<ScrollArea className='h-[calc(100vh-13rem)] rounded-md border' orientation='both'>
|
||||
<ScrollArea className='h-[calc(100vh-16rem)] rounded-md border' orientation='both'>
|
||||
<ShadcnTable>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -159,7 +159,6 @@ export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
</TableBody>
|
||||
</ShadcnTable>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -16,27 +16,28 @@ type DataTableToolbarProps<TData> = {
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-1 py-1 lg:flex-row lg:items-center lg:gap-1">
|
||||
<div className="flex-1">
|
||||
<TextSearchInput />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap lg:justify-end">
|
||||
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap">
|
||||
<AccountPopover />
|
||||
<MailboxPopover />
|
||||
<MailFilterPopover />
|
||||
<TagFilterPopover />
|
||||
<TimePopover />
|
||||
<MoreFiltersPopover />
|
||||
<DataTableViewOptions table={table} />
|
||||
<div className="flex flex-col gap-1 p-1 bg-background">
|
||||
<div className="mb-4 flex items-center justify-center w-full">
|
||||
<div className="w-full max-w-3xl">
|
||||
<TextSearchInput />
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-auto lg:ml-0">
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-1">
|
||||
<div className="flex items-center gap-2 flex-wrap w-full sm:w-auto">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<AccountPopover />
|
||||
<MailboxPopover />
|
||||
<MailFilterPopover />
|
||||
<TagFilterPopover />
|
||||
<MoreFiltersPopover />
|
||||
</div>
|
||||
<FilterResetButton />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<TimePopover />
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -50,7 +50,7 @@ export function DataTableViewOptions<TData>({
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='ms-auto hidden h-8 lg:flex rounded-none'
|
||||
className='ms-auto hidden h-6 lg:flex rounded-none'
|
||||
>
|
||||
<MixerHorizontalIcon className='size-4' />
|
||||
{t('search_view.button_label')}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function TagFilterPopover() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 gap-1.5 px-3 rounded-none',
|
||||
'h-6 gap-1.5 px-3 rounded-none',
|
||||
selectedTags.length > 0 &&
|
||||
'bg-primary/10 border-primary text-primary'
|
||||
)}
|
||||
|
||||
@@ -83,17 +83,23 @@ export function TimePopover() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-8 rounded-none px-3 gap-1.5 transition-colors',
|
||||
'h-6 rounded-none px-3 gap-1.5 transition-colors max-w-full',
|
||||
(since || before) && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||
)}
|
||||
>
|
||||
<CalendarRange className="h-4 w-4" />
|
||||
{label(since, before)}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
<CalendarRange className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate max-w-[120px] sm:max-w-none">
|
||||
{label(since, before)}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="w-[530px] p-4 space-y-6">
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-[92vw] sm:w-[420px] max-w-[420px] p-4 space-y-6"
|
||||
>
|
||||
<Section title={t('time.recent_range')}>
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -109,22 +115,26 @@ export function TimePopover() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-border/50">
|
||||
<span className="text-[10px] uppercase font-bold opacity-40 shrink-0">{t('time.recent_prefix')}</span>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pt-3 border-t border-border/50">
|
||||
<span className="text-[10px] uppercase font-bold opacity-40 shrink-0">
|
||||
{t('time.recent_prefix')}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="10"
|
||||
className="h-8 w-20 text-xs"
|
||||
className="h-8 w-full sm:w-20 text-xs"
|
||||
value={customDays}
|
||||
onChange={e => setCustomDays(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleApplyRecent()}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{t('time.days_ago_to_now')}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{t('time.days_ago_to_now')}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 px-3 ml-auto text-xs"
|
||||
className="h-8 px-3 sm:ml-auto text-xs w-full sm:w-auto"
|
||||
onClick={handleApplyRecent}
|
||||
>
|
||||
{t('time.apply')}
|
||||
@@ -132,6 +142,7 @@ export function TimePopover() {
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('time.historical')}>
|
||||
<div className="flex flex-wrap gap-2 w-full">
|
||||
{[1, 2, 3, 5, 10].map(y => (
|
||||
@@ -148,23 +159,33 @@ export function TimePopover() {
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('time.absolute_range')}>
|
||||
<div className="flex gap-3 w-full">
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<span className="text-[10px] pl-1 opacity-50 font-medium">{t('time.since').toUpperCase()}</span>
|
||||
<DatePicker
|
||||
placeholder={t('time.start_date')}
|
||||
selected={since ? new Date(since) : undefined}
|
||||
onSelect={(date) => setSince(date?.getTime())}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
|
||||
{t('time.since').toUpperCase()}:
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<DatePicker
|
||||
placeholder={t('time.start_date')}
|
||||
selected={since ? new Date(since) : undefined}
|
||||
onSelect={(date) => setSince(date?.getTime())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<span className="text-[10px] pl-1 opacity-50 font-medium">{t('time.before').toUpperCase()}</span>
|
||||
<DatePicker
|
||||
placeholder={t('time.end_date')}
|
||||
selected={before ? new Date(before) : undefined}
|
||||
onSelect={(date) => setBefore(date?.getTime())}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
|
||||
{t('time.before').toUpperCase()}:
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<DatePicker
|
||||
placeholder={t('time.end_date')}
|
||||
selected={before ? new Date(before) : undefined}
|
||||
onSelect={(date) => setBefore(date?.getTime())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { get_attachment_senders } from '@/api/attachment/api';
|
||||
|
||||
export const userAttachmentSenders = (searchTerm: string = "") => {
|
||||
const { data: senders = [], isLoading, isError } = useQuery({
|
||||
queryKey: ['attachment-senders', 'all'],
|
||||
queryFn: get_attachment_senders,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchTerm) return senders;
|
||||
const lower = searchTerm.toLowerCase();
|
||||
return senders.filter(email =>
|
||||
email.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [senders, searchTerm]);
|
||||
|
||||
return {
|
||||
senders: filtered,
|
||||
isLoading,
|
||||
isError
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import { get_all_attachment_tags } from '@/api/attachment/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
export interface TagCount {
|
||||
tag: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface UseAvailableTagsResult {
|
||||
tags: string[];
|
||||
tagsCount: TagCount[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
|
||||
export function useAvailableAttachmentTags(): UseAvailableTagsResult {
|
||||
const {
|
||||
data: tagsCount = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<TagCount[]>({
|
||||
queryKey: ['attachment-tags'],
|
||||
queryFn: get_all_attachment_tags,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const tags = React.useMemo(() => {
|
||||
return tagsCount.map(f => f.tag).sort();
|
||||
}, [tagsCount]);
|
||||
|
||||
return {
|
||||
tags,
|
||||
tagsCount,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//
|
||||
// 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/>.
|
||||
|
||||
|
||||
import { PaginatedResponse } from '@/api';
|
||||
import { AttachmentModel, search_attachment } from '@/api/attachment/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getRouteApi } from '@tanstack/react-router';
|
||||
import React from 'react';
|
||||
|
||||
const routeApi = getRouteApi('/_authenticated/attachment/')
|
||||
|
||||
export function useSearchAttachments() {
|
||||
// const queryClient = useQueryClient();
|
||||
const search = routeApi.useSearch()
|
||||
const navigate = routeApi.useNavigate()
|
||||
|
||||
const page = search.page;
|
||||
const pageSize = search.pageSize;
|
||||
const sortBy = search.sortBy;
|
||||
const sortOrder = search.sortOrder;
|
||||
|
||||
const filter = React.useMemo(() => {
|
||||
if (!search.q) return {};
|
||||
try {
|
||||
return JSON.parse(search.q);
|
||||
} catch (e) {
|
||||
console.error("URL 'q' parameter parse error:", e);
|
||||
return {};
|
||||
}
|
||||
}, [search.q]);
|
||||
|
||||
|
||||
|
||||
const updateParams = React.useCallback((newParams: Partial<typeof search>) => {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
...newParams,
|
||||
}),
|
||||
replace: false,
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
|
||||
const setFilter = React.useCallback((val: any | ((prev: any) => any)) => {
|
||||
navigate({
|
||||
search: (prev) => {
|
||||
let currentFilter = {};
|
||||
try {
|
||||
currentFilter = prev.q ? JSON.parse(prev.q) : {};
|
||||
} catch (e) {
|
||||
currentFilter = {};
|
||||
}
|
||||
const nextFilter = typeof val === 'function' ? val(currentFilter) : val;
|
||||
return {
|
||||
...prev,
|
||||
page: 1,
|
||||
q: Object.keys(nextFilter).length > 0 ? JSON.stringify(nextFilter) : undefined
|
||||
};
|
||||
}
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
|
||||
const setPage = (p: number) => updateParams({ page: p });
|
||||
|
||||
const setSearchPageSize = (size: number) => {
|
||||
localStorage.setItem('bichon_search_attachment_page_size', size.toString());
|
||||
updateParams({ pageSize: size, page: 1 });
|
||||
};
|
||||
|
||||
const setSortBy = (val: "DATE" | "SIZE") => updateParams({ sortBy: val });
|
||||
const setSortOrder = (val: "desc" | "asc") => updateParams({ sortOrder: val });
|
||||
|
||||
const onSubmit = (cleaned: Record<string, any>) => {
|
||||
if ('has_attachment' in cleaned && cleaned.has_attachment === false) {
|
||||
delete cleaned.has_attachment;
|
||||
}
|
||||
if (Object.keys(cleaned).length > 0) {
|
||||
const payload = {
|
||||
...cleaned,
|
||||
...(cleaned.since && { since: cleaned.since.getTime() }),
|
||||
...(cleaned.before && { before: cleaned.before.getTime() }),
|
||||
};
|
||||
setFilter(payload);
|
||||
} else {
|
||||
setFilter({});
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFilter({});
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
isFetching,
|
||||
} = useQuery<PaginatedResponse<AttachmentModel>>({
|
||||
queryKey: ['search-attachments', filter, page, pageSize, sortBy, sortOrder],
|
||||
queryFn: () =>
|
||||
search_attachment({
|
||||
filter: filter,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
sort_by: sortBy,
|
||||
desc: sortOrder === "desc"
|
||||
}),
|
||||
staleTime: 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return {
|
||||
attachments: data?.items ?? [],
|
||||
total: data?.total_items ?? 0,
|
||||
totalPages: data?.total_pages ?? 1,
|
||||
pageSize: data?.page_size ?? pageSize,
|
||||
setSearchPageSize,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isLoading,
|
||||
isError,
|
||||
error: error as Error | null,
|
||||
isFetching,
|
||||
page,
|
||||
setPage,
|
||||
onSubmit,
|
||||
reset,
|
||||
filter,
|
||||
setFilter
|
||||
};
|
||||
}
|
||||
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1478
-1462
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1609
-1593
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1480
-1464
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1613
-1597
File diff suppressed because it is too large
Load Diff
+1479
-1463
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import { Route as AuthenticatedIndexImport } from './routes/_authenticated/index
|
||||
import { Route as authSignInImport } from './routes/(auth)/sign-in'
|
||||
import { Route as auth500Import } from './routes/(auth)/500'
|
||||
import { Route as AuthenticatedSearchIndexImport } from './routes/_authenticated/search/index'
|
||||
import { Route as AuthenticatedAttachmentIndexImport } from './routes/_authenticated/attachment/index'
|
||||
|
||||
// Create Virtual Routes
|
||||
|
||||
@@ -217,6 +218,13 @@ const AuthenticatedSearchIndexRoute = AuthenticatedSearchIndexImport.update({
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
|
||||
const AuthenticatedAttachmentIndexRoute =
|
||||
AuthenticatedAttachmentIndexImport.update({
|
||||
id: '/attachment/',
|
||||
path: '/attachment/',
|
||||
getParentRoute: () => AuthenticatedRouteRoute,
|
||||
} as any)
|
||||
|
||||
const AuthenticatedUsersRolesLazyRoute =
|
||||
AuthenticatedUsersRolesLazyImport.update({
|
||||
id: '/roles',
|
||||
@@ -420,6 +428,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedUsersRolesLazyImport
|
||||
parentRoute: typeof AuthenticatedUsersRouteLazyImport
|
||||
}
|
||||
'/_authenticated/attachment/': {
|
||||
id: '/_authenticated/attachment/'
|
||||
path: '/attachment'
|
||||
fullPath: '/attachment'
|
||||
preLoaderRoute: typeof AuthenticatedAttachmentIndexImport
|
||||
parentRoute: typeof AuthenticatedRouteImport
|
||||
}
|
||||
'/_authenticated/search/': {
|
||||
id: '/_authenticated/search/'
|
||||
path: '/search'
|
||||
@@ -524,6 +539,7 @@ interface AuthenticatedRouteRouteChildren {
|
||||
AuthenticatedSettingsRouteLazyRoute: typeof AuthenticatedSettingsRouteLazyRouteWithChildren
|
||||
AuthenticatedUsersRouteLazyRoute: typeof AuthenticatedUsersRouteLazyRouteWithChildren
|
||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||
AuthenticatedAttachmentIndexRoute: typeof AuthenticatedAttachmentIndexRoute
|
||||
AuthenticatedSearchIndexRoute: typeof AuthenticatedSearchIndexRoute
|
||||
AuthenticatedAccountsIndexLazyRoute: typeof AuthenticatedAccountsIndexLazyRoute
|
||||
AuthenticatedApiDocsIndexLazyRoute: typeof AuthenticatedApiDocsIndexLazyRoute
|
||||
@@ -537,6 +553,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
||||
AuthenticatedUsersRouteLazyRoute:
|
||||
AuthenticatedUsersRouteLazyRouteWithChildren,
|
||||
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||
AuthenticatedAttachmentIndexRoute: AuthenticatedAttachmentIndexRoute,
|
||||
AuthenticatedSearchIndexRoute: AuthenticatedSearchIndexRoute,
|
||||
AuthenticatedAccountsIndexLazyRoute: AuthenticatedAccountsIndexLazyRoute,
|
||||
AuthenticatedApiDocsIndexLazyRoute: AuthenticatedApiDocsIndexLazyRoute,
|
||||
@@ -566,6 +583,7 @@ export interface FileRoutesByFullPath {
|
||||
'/settings/proxy': typeof AuthenticatedSettingsProxyLazyRoute
|
||||
'/users/api-tokens': typeof AuthenticatedUsersApiTokensLazyRoute
|
||||
'/users/roles': typeof AuthenticatedUsersRolesLazyRoute
|
||||
'/attachment': typeof AuthenticatedAttachmentIndexRoute
|
||||
'/search': typeof AuthenticatedSearchIndexRoute
|
||||
'/accounts': typeof AuthenticatedAccountsIndexLazyRoute
|
||||
'/api-docs': typeof AuthenticatedApiDocsIndexLazyRoute
|
||||
@@ -590,6 +608,7 @@ export interface FileRoutesByTo {
|
||||
'/settings/proxy': typeof AuthenticatedSettingsProxyLazyRoute
|
||||
'/users/api-tokens': typeof AuthenticatedUsersApiTokensLazyRoute
|
||||
'/users/roles': typeof AuthenticatedUsersRolesLazyRoute
|
||||
'/attachment': typeof AuthenticatedAttachmentIndexRoute
|
||||
'/search': typeof AuthenticatedSearchIndexRoute
|
||||
'/accounts': typeof AuthenticatedAccountsIndexLazyRoute
|
||||
'/api-docs': typeof AuthenticatedApiDocsIndexLazyRoute
|
||||
@@ -619,6 +638,7 @@ export interface FileRoutesById {
|
||||
'/_authenticated/settings/proxy': typeof AuthenticatedSettingsProxyLazyRoute
|
||||
'/_authenticated/users/api-tokens': typeof AuthenticatedUsersApiTokensLazyRoute
|
||||
'/_authenticated/users/roles': typeof AuthenticatedUsersRolesLazyRoute
|
||||
'/_authenticated/attachment/': typeof AuthenticatedAttachmentIndexRoute
|
||||
'/_authenticated/search/': typeof AuthenticatedSearchIndexRoute
|
||||
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexLazyRoute
|
||||
'/_authenticated/api-docs/': typeof AuthenticatedApiDocsIndexLazyRoute
|
||||
@@ -648,6 +668,7 @@ export interface FileRouteTypes {
|
||||
| '/settings/proxy'
|
||||
| '/users/api-tokens'
|
||||
| '/users/roles'
|
||||
| '/attachment'
|
||||
| '/search'
|
||||
| '/accounts'
|
||||
| '/api-docs'
|
||||
@@ -671,6 +692,7 @@ export interface FileRouteTypes {
|
||||
| '/settings/proxy'
|
||||
| '/users/api-tokens'
|
||||
| '/users/roles'
|
||||
| '/attachment'
|
||||
| '/search'
|
||||
| '/accounts'
|
||||
| '/api-docs'
|
||||
@@ -698,6 +720,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/settings/proxy'
|
||||
| '/_authenticated/users/api-tokens'
|
||||
| '/_authenticated/users/roles'
|
||||
| '/_authenticated/attachment/'
|
||||
| '/_authenticated/search/'
|
||||
| '/_authenticated/accounts/'
|
||||
| '/_authenticated/api-docs/'
|
||||
@@ -756,6 +779,7 @@ export const routeTree = rootRoute
|
||||
"/_authenticated/settings",
|
||||
"/_authenticated/users",
|
||||
"/_authenticated/",
|
||||
"/_authenticated/attachment/",
|
||||
"/_authenticated/search/",
|
||||
"/_authenticated/accounts/",
|
||||
"/_authenticated/api-docs/",
|
||||
@@ -837,6 +861,10 @@ export const routeTree = rootRoute
|
||||
"filePath": "_authenticated/users/roles.lazy.tsx",
|
||||
"parent": "/_authenticated/users"
|
||||
},
|
||||
"/_authenticated/attachment/": {
|
||||
"filePath": "_authenticated/attachment/index.tsx",
|
||||
"parent": "/_authenticated"
|
||||
},
|
||||
"/_authenticated/search/": {
|
||||
"filePath": "_authenticated/search/index.tsx",
|
||||
"parent": "/_authenticated"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { z } from 'zod'
|
||||
import AttachmentSearch from '@/features/attachment'
|
||||
|
||||
const searchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
pageSize: z.number().optional(),
|
||||
sortBy: z.enum(['DATE', 'SIZE']).catch('DATE'),
|
||||
sortOrder: z.enum(['asc', 'desc']).catch('desc'),
|
||||
q: z.string().optional(),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/attachment/')({
|
||||
component: AttachmentSearch,
|
||||
validateSearch: (search) => {
|
||||
const result = searchSchema.parse(search);
|
||||
return {
|
||||
...result,
|
||||
page: result.page ?? 1,
|
||||
pageSize: result.pageSize ?? (Number(localStorage.getItem('bichon_search_attachment_page_size')) || 30),
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import Search from '@/features/search'
|
||||
import EmailSearch from '@/features/search'
|
||||
import { z } from 'zod'
|
||||
|
||||
const searchSchema = z.object({
|
||||
@@ -30,7 +30,7 @@ const searchSchema = z.object({
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/search/')({
|
||||
component: Search,
|
||||
component: EmailSearch,
|
||||
validateSearch: (search) => {
|
||||
const result = searchSchema.parse(search);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user