adjust the indexing strategy for attachment attributes

This commit is contained in:
rustmailer
2026-04-10 02:08:30 +08:00
parent 61161b5f3b
commit 6d5953c73b
21 changed files with 401 additions and 111 deletions
Generated
+1
View File
@@ -549,6 +549,7 @@ dependencies = [
"toml", "toml",
"tracing", "tracing",
"tracing-appender", "tracing-appender",
"tracing-log",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
"urlencoding", "urlencoding",
+1
View File
@@ -124,6 +124,7 @@ blake3 = "1.8.4"
uuid = { version = "1.23.0", features = ["v4", "serde"] } uuid = { version = "1.23.0", features = ["v4", "serde"] }
fjall = { version = "3.1.3", features = ["lz4", "metrics", "bytes_1"] } fjall = { version = "3.1.3", features = ["lz4", "metrics", "bytes_1"] }
tantivy = { version = "0.26.0", features = ["zstd-compression"] } tantivy = { version = "0.26.0", features = ["zstd-compression"] }
tracing-log = "0.2.0"
[dev-dependencies] [dev-dependencies]
#bincode = "1.3.3" #bincode = "1.3.3"
#secret-lib = "1.0.0" #secret-lib = "1.0.0"
+2
View File
@@ -19,6 +19,7 @@
use crate::modules::logger::file::setup_file_logger; use crate::modules::logger::file::setup_file_logger;
use crate::modules::settings::cli::SETTINGS; use crate::modules::settings::cli::SETTINGS;
use chrono::Local; use chrono::Local;
use tracing_log::LogTracer;
use std::process; use std::process;
use tracing::Level; use tracing::Level;
use tracing_subscriber::fmt::{format::Writer, time::FormatTime}; use tracing_subscriber::fmt::{format::Writer, time::FormatTime};
@@ -34,6 +35,7 @@ impl FormatTime for LocalTimer {
} }
pub fn initialize_logging() { pub fn initialize_logging() {
LogTracer::init().unwrap();
if SETTINGS.bichon_log_to_file { if SETTINGS.bichon_log_to_file {
setup_file_logger().unwrap(); setup_file_logger().unwrap();
} else { } else {
+14 -10
View File
@@ -1,7 +1,8 @@
use std::{collections::HashSet, io::Cursor}; use std::io::Cursor;
use crate::{ use crate::{
modules::{ modules::{
dashboard::Group,
envelope::extractor::reattach_eml_content, envelope::extractor::reattach_eml_content,
error::{code::ErrorCode, BichonResult}, error::{code::ErrorCode, BichonResult},
utils::compute_content_hash, utils::compute_content_hash,
@@ -15,17 +16,20 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct AttachmentMetadata { pub struct AttachmentMetadata {
/// A collection of unique file extensions found in attachments. /// Statistics of attachment file extensions (key + count).
/// Example: ["pdf", "docx", "png"] /// Each item represents a file extension and its occurrence count.
pub extensions: HashSet<String>, /// Example: [{ key: "pdf", count: 10 }, { key: "png", count: 5 }]
pub extensions: Vec<Group>,
/// A collection of high-level attachment categories. /// Statistics of attachment categories (key + count).
/// Example: ["document", "image", "archive"] /// Each item represents a high-level category and its occurrence count.
pub categories: HashSet<String>, /// Example: [{ key: "document", count: 8 }, { key: "image", count: 6 }]
pub categories: Vec<Group>,
/// A collection of unique MIME types (Content-Type) for the attachments. /// Statistics of attachment MIME types (Content-Type) (key + count).
/// Example: ["application/pdf", "image/jpeg"] /// Each item represents a MIME type and its occurrence count.
pub content_types: HashSet<String>, /// Example: [{ key: "application/pdf", count: 10 }, { key: "image/jpeg", count: 5 }]
pub content_types: Vec<Group>,
} }
pub async fn retrieve_attachment_content( pub async fn retrieve_attachment_content(
+98 -63
View File
@@ -126,36 +126,54 @@ impl IndexManager {
let handler = task::spawn(async move { let handler = task::spawn(async move {
let mut shutdown = SIGNAL_MANAGER.subscribe(); 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(30));
let mut pending_count = 0;
let commit_threshold = 1000;
loop { loop {
tokio::select! { tokio::select! {
maybe_msg = receiver.recv() => { maybe_msg = receiver.recv() => {
match maybe_msg { match maybe_msg {
Some(doc) => { Some(doc) => {
let writer = writer.lock().await; let mut writer = writer.lock().await;
if let Err(e) = writer.add_document(doc) { if let Err(e) = writer.add_document(doc) {
eprintln!("[ERROR] Failed to add document: {e:?}"); eprintln!("[ERROR] Failed to add document: {e:?}");
tracing::error!("Tantivy: Failed to add document: {e:?}"); tracing::error!("Tantivy: Failed to add document: {e:?}");
} }
pending_count += 1;
while let Ok(next_doc) = receiver.try_recv() { while let Ok(next_doc) = receiver.try_recv() {
let _ = writer.add_document(next_doc); let _ = writer.add_document(next_doc);
pending_count += 1;
}
if pending_count >= commit_threshold {
tracing::info!("Tantivy: Reached threshold ({}), committing...", pending_count);
fatal_commit(&mut writer);
pending_count = 0;
commit_interval.reset();
} }
} }
None => { None => {
tracing::info!("Tantivy: Receiver closed. Finalizing..."); tracing::info!("Tantivy: Receiver closed. Finalizing...");
let mut writer = writer.lock().await; if pending_count > 0 {
fatal_commit(&mut writer); let mut writer = writer.lock().await;
fatal_commit(&mut writer);
}
break; break;
}, },
} }
} }
_ = commit_interval.tick() => { _ = commit_interval.tick() => {
let mut writer = writer.lock().await; if pending_count > 0 {
fatal_commit(&mut writer); let mut writer = writer.lock().await;
fatal_commit(&mut writer);
pending_count = 0;
tracing::debug!("Tantivy: Periodic commit finished.");
}
} }
_ = shutdown.recv() => { _ = shutdown.recv() => {
tracing::info!("Tantivy: Shutdown signal received. Performing final commit..."); tracing::info!("Tantivy: Shutdown signal received. Performing final commit...");
let mut writer = writer.lock().await; if pending_count > 0 {
fatal_commit(&mut writer); let mut writer = writer.lock().await;
fatal_commit(&mut writer);
}
tracing::info!("Tantivy: Shutdown cleanup complete."); tracing::info!("Tantivy: Shutdown cleanup complete.");
break; break;
} }
@@ -347,22 +365,21 @@ impl IndexManager {
} }
if let Some(ref extension) = filter.attachment_extension { if let Some(ref extension) = filter.attachment_extension {
if let Ok(query) = RegexQuery::from_pattern(extension.as_str(), f.f_attachment_glue) { let term = Term::from_field_text(f.f_attachment_ext, extension);
subqueries.push((Occur::Must, Box::new(query))); let query = TermQuery::new(term, IndexRecordOption::Basic);
} subqueries.push((Occur::Must, Box::new(query)));
} }
if let Some(ref category) = filter.attachment_category { if let Some(ref category) = filter.attachment_category {
if let Ok(query) = RegexQuery::from_pattern(category.as_str(), f.f_attachment_glue) { let term = Term::from_field_text(f.f_attachment_category, category);
subqueries.push((Occur::Must, Box::new(query))); let query = TermQuery::new(term, IndexRecordOption::Basic);
} subqueries.push((Occur::Must, Box::new(query)));
} }
if let Some(ref content_type) = filter.attachment_content_type { if let Some(ref content_type) = filter.attachment_content_type {
if let Ok(query) = RegexQuery::from_pattern(content_type.as_str(), f.f_attachment_glue) let term = Term::from_field_text(f.f_attachment_content_type, content_type);
{ let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query))); subqueries.push((Occur::Must, Box::new(query)));
}
} }
let start_bound = if let Some(from) = filter.since { let start_bound = if let Some(from) = filter.since {
@@ -1168,6 +1185,28 @@ impl IndexManager {
) -> BichonResult<AttachmentMetadata> { ) -> BichonResult<AttachmentMetadata> {
let searcher = self.create_searcher()?; 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 { let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => { Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new(); let mut subqueries = Vec::new();
@@ -1184,50 +1223,56 @@ impl IndexManager {
None => Box::new(AllQuery), None => Box::new(AllQuery),
}; };
// Extension collector let agg_collector = AggregationCollector::from_aggs(aggregations, Default::default());
let mut ext_collector = FacetCollector::for_field(F_ATTACHMENT_EXT); let agg_results = searcher
ext_collector.add_facet("/"); .search(&query, &agg_collector)
// Category collector
let mut cat_collector = FacetCollector::for_field(F_ATTACHMENT_CATEGORY);
cat_collector.add_facet("/");
// Content-Type collector
let mut type_collector = FacetCollector::for_field(F_ATTACHMENT_CONTENT_TYPE);
type_collector.add_facet("/");
let ext_counts = searcher
.search(&query, &ext_collector)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let cat_counts = searcher
.search(&query, &cat_collector)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let type_counts = searcher
.search(&query, &type_collector)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut metadata = AttachmentMetadata { let mut exts = Vec::with_capacity(20);
extensions: HashSet::new(), let extensions = agg_results.0.get("exts").unwrap();
categories: HashSet::new(), if let AggregationResult::BucketResult(BucketResult::Terms { buckets, .. }) = extensions {
content_types: HashSet::new(), for entry in buckets {
}; if let Key::Str(ext) = &entry.key {
exts.push(Group {
for (facet, _) in ext_counts.get("/") { key: ext.clone(),
let val = facet.to_path_string().trim_start_matches('/').to_string(); count: entry.doc_count,
metadata.extensions.insert(val); });
}
}
} }
for (facet, _) in cat_counts.get("/") { let mut cats = Vec::with_capacity(20);
let val = facet.to_path_string().trim_start_matches('/').to_string(); let categories = agg_results.0.get("cats").unwrap();
metadata.categories.insert(val); 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,
});
}
}
} }
for (facet, _) in type_counts.get("/") { let mut ctypes = Vec::with_capacity(20);
let val = facet.to_path_string().trim_start_matches('/').to_string(); let content_types = agg_results.0.get("content_types").unwrap();
metadata.content_types.insert(val); 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(metadata) Ok(AttachmentMetadata {
extensions: exts,
categories: cats,
content_types: ctypes,
})
} }
pub async fn get_dashboard_stats( pub async fn get_dashboard_stats(
@@ -1385,16 +1430,6 @@ impl IndexManager {
"failed to cleanup envelope index" "failed to cleanup envelope index"
); );
} }
//这里要删除eml和attachments, 要先得到content_hash, 似乎有点麻烦,因为id有点多
// if let Err(e) =
// INDEX_MANAGER.delete_account_envelopes(account_id).await
// {
// tracing::error!(
// account_id = account_id,
// error = %e,
// "failed to cleanup eml index"
// );
// }
}); });
} }
} }
+4 -14
View File
@@ -1,9 +1,5 @@
use std::collections::HashSet; use std::collections::HashSet;
use tantivy::{schema::Value, TantivyDocument};
use tantivy::{
schema::{Facet, Value},
TantivyDocument,
};
use crate::{ use crate::{
modules::{ modules::{
@@ -66,21 +62,15 @@ impl EnvelopeWithAttachments {
if let Some(ext) = att.get_extension() { if let Some(ext) = att.get_extension() {
search_terms.push(ext.clone()); search_terms.push(ext.clone());
doc.add_facet(fields.f_attachment_ext, Facet::from(&format!("/{}", ext))); doc.add_text(fields.f_attachment_ext, ext);
} }
let category = att.get_category().to_string(); let category = att.get_category().to_string();
search_terms.push(category.clone()); search_terms.push(category.clone());
let file_type = att.file_type.to_lowercase(); let file_type = att.file_type.to_lowercase();
search_terms.push(file_type.clone()); search_terms.push(file_type.clone());
doc.add_facet( doc.add_text(fields.f_attachment_category, category);
fields.f_attachment_category, doc.add_text(fields.f_attachment_content_type, file_type);
Facet::from(&format!("/{}", category)),
);
doc.add_facet(
fields.f_attachment_content_type,
Facet::from(&format!("/{}", file_type)),
);
doc.add_text(fields.f_attachment_content_hash, &att.content_hash); doc.add_text(fields.f_attachment_content_hash, &att.content_hash);
} }
+6 -7
View File
@@ -84,14 +84,13 @@ impl SchemaTools {
let f_attachments = builder.add_text_field(F_ATTACHMENTS, STORED); let f_attachments = builder.add_text_field(F_ATTACHMENTS, STORED);
let f_attachment_content_hash = let f_attachment_content_hash =
builder.add_text_field(F_ATTACHMENT_CONTENT_HASH, STRING | FAST | STORED); builder.add_text_field(F_ATTACHMENT_CONTENT_HASH, STRING | FAST | STORED);
let f_attachment_ext =
builder.add_facet_field(F_ATTACHMENT_EXT, FacetOptions::default().set_stored()); let f_attachment_ext = builder.add_text_field(F_ATTACHMENT_EXT, STRING | FAST | STORED);
let f_attachment_category = let f_attachment_category =
builder.add_facet_field(F_ATTACHMENT_CATEGORY, FacetOptions::default().set_stored()); builder.add_text_field(F_ATTACHMENT_CATEGORY, STRING | FAST | STORED);
let f_attachment_content_type = builder.add_facet_field( let f_attachment_content_type =
F_ATTACHMENT_CONTENT_TYPE, builder.add_text_field(F_ATTACHMENT_CONTENT_TYPE, STRING | FAST | STORED);
FacetOptions::default().set_stored(),
);
let f_tags = builder.add_facet_field(F_TAGS, FacetOptions::default().set_stored()); let f_tags = builder.add_facet_field(F_TAGS, FacetOptions::default().set_stored());
let f_shard_id = builder.add_u64_field(F_SHARD_ID, INDEXED | STORED | FAST); let f_shard_id = builder.add_u64_field(F_SHARD_ID, INDEXED | STORED | FAST);
+10 -9
View File
@@ -19,6 +19,7 @@
import { EmailEnvelope, PaginatedResponse } from "@/api"; import { EmailEnvelope, PaginatedResponse } from "@/api";
import axiosInstance from "@/api/axiosInstance"; import axiosInstance from "@/api/axiosInstance";
import { Group } from "@/api/system/api";
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
export const get_thread_messages = async (accountId: number, thread_id: string, page: number, page_size: number) => { export const get_thread_messages = async (accountId: number, thread_id: string, page: number, page_size: number) => {
@@ -114,22 +115,22 @@ export const restore_message = async (accountId: number, envelopeIds: string[])
export interface AttachmentMetadata { export interface AttachmentMetadata {
/** /**
* A collection of unique file extensions found in attachments. * Statistics of attachment file extensions (key + count).
* @example ["pdf", "docx", "png"] * @example [{ key: "pdf", count: 10 }, { key: "png", count: 5 }]
*/ */
extensions: string[]; extensions: Group[];
/** /**
* A collection of high-level attachment categories. * Statistics of attachment categories (key + count).
* @example ["document", "image", "archive"] * @example [{ key: "document", count: 8 }, { key: "image", count: 6 }]
*/ */
categories: string[]; categories: Group[];
/** /**
* A collection of unique MIME types (Content-Type) for the attachments. * Statistics of attachment MIME types (Content-Type) (key + count).
* @example ["application/pdf", "image/jpeg"] * @example [{ key: "application/pdf", count: 10 }, { key: "image/jpeg", count: 5 }]
*/ */
content_types: string[]; content_types: Group[];
} }
@@ -1,3 +1,21 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import * as React from 'react' import * as React from 'react'
import { AtSign, ChevronDown, X } from 'lucide-react' import { AtSign, ChevronDown, X } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -1,3 +1,22 @@
//
// 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 { Paperclip, Check } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSearchContext } from './context' import { useSearchContext } from './context'
@@ -1,14 +1,34 @@
//
// 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 * as React from "react"
import { Check, X } from "lucide-react" import { Check, X } from "lucide-react"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Group } from "@/api/system/api"
interface MetadataSelectorFieldProps { interface MetadataSelectorFieldProps {
label: string label: string
value?: string value?: string
options: string[] options: Group[]
isLoading: boolean isLoading: boolean
onSelect: (val: string | undefined) => void onSelect: (val: string | undefined) => void
onReset: () => void onReset: () => void
@@ -27,7 +47,7 @@ export function MetadataSelectorField({
const filteredOptions = React.useMemo(() => { const filteredOptions = React.useMemo(() => {
return options.filter(opt => return options.filter(opt =>
opt.toLowerCase().includes(searchTerm.toLowerCase()) opt.key.toLowerCase().includes(searchTerm.toLowerCase())
) )
}, [options, searchTerm]) }, [options, searchTerm])
@@ -76,19 +96,36 @@ export function MetadataSelectorField({
className="h-8" className="h-8"
/> />
<CommandList className="max-h-[240px]"> <CommandList className="max-h-[240px]">
{isLoading && <div className="p-4 text-[10px] text-center opacity-50">{t('common.loading')}</div>} {isLoading && (
<CommandEmpty className="text-[10px] p-2 text-center">{t('common.noData')}</CommandEmpty> <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> <CommandGroup>
{filteredOptions.map((opt) => ( {filteredOptions.map((opt) => (
<CommandItem <CommandItem
key={opt} key={opt.key}
onSelect={() => { onSelect={() => {
value === opt ? onReset() : onSelect(opt); value === opt.key ? onReset() : onSelect(opt.key)
}} }}
className="flex items-center justify-between py-2 px-3 cursor-pointer text-xs" className="flex items-center justify-between py-2 px-3 cursor-pointer text-xs"
> >
<span className="truncate">{opt}</span> <span className="truncate">{opt.key}</span>
{value === opt && <Check className="h-3 w-3 text-primary shrink-0" />}
<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> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
@@ -1,3 +1,21 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { useSearchContext } from "./context" import { useSearchContext } from "./context"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
+19
View File
@@ -1,3 +1,22 @@
//
// 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 { X } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useSearchContext } from "./context" import { useSearchContext } from "./context"
@@ -1,3 +1,22 @@
//
// 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 * as React from 'react';
import { import {
ChevronDown, Folders, X, TreeDeciduous, FolderIcon, ChevronDown, Folders, X, TreeDeciduous, FolderIcon,
@@ -1,3 +1,21 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import * as React from "react" import * as React from "react"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -1,3 +1,21 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { EmailEnvelope } from '@/api'; import { EmailEnvelope } from '@/api';
import { AttachmentInfo, download_nested_attachment, load_nested_message } from '@/api/mailbox/envelope/api'; import { AttachmentInfo, download_nested_attachment, load_nested_message } from '@/api/mailbox/envelope/api';
import EmailIframe from '@/components/mail-iframe'; import EmailIframe from '@/components/mail-iframe';
@@ -1,3 +1,21 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import * as React from 'react' import * as React from 'react'
import { Tag, ChevronDown, X } from 'lucide-react' import { Tag, ChevronDown, X } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -1,3 +1,21 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import React, { useState, useEffect, useRef } from "react" import React, { useState, useEffect, useRef } from "react"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
+18
View File
@@ -1,3 +1,21 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import * as React from 'react' import * as React from 'react'
import { CalendarRange, ChevronDown, X } from 'lucide-react' import { CalendarRange, ChevronDown, X } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -1,3 +1,22 @@
//
// 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 { z } from 'zod' import { z } from 'zod'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons' import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons'
@@ -1,3 +1,21 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { AppearanceForm } from './appearance-form' import { AppearanceForm } from './appearance-form'
export function SettingsAppearance() { export function SettingsAppearance() {