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",
"tracing",
"tracing-appender",
"tracing-log",
"tracing-subscriber",
"url",
"urlencoding",
+1
View File
@@ -124,6 +124,7 @@ blake3 = "1.8.4"
uuid = { version = "1.23.0", features = ["v4", "serde"] }
fjall = { version = "3.1.3", features = ["lz4", "metrics", "bytes_1"] }
tantivy = { version = "0.26.0", features = ["zstd-compression"] }
tracing-log = "0.2.0"
[dev-dependencies]
#bincode = "1.3.3"
#secret-lib = "1.0.0"
+2
View File
@@ -19,6 +19,7 @@
use crate::modules::logger::file::setup_file_logger;
use crate::modules::settings::cli::SETTINGS;
use chrono::Local;
use tracing_log::LogTracer;
use std::process;
use tracing::Level;
use tracing_subscriber::fmt::{format::Writer, time::FormatTime};
@@ -34,6 +35,7 @@ impl FormatTime for LocalTimer {
}
pub fn initialize_logging() {
LogTracer::init().unwrap();
if SETTINGS.bichon_log_to_file {
setup_file_logger().unwrap();
} else {
+14 -10
View File
@@ -1,7 +1,8 @@
use std::{collections::HashSet, io::Cursor};
use std::io::Cursor;
use crate::{
modules::{
dashboard::Group,
envelope::extractor::reattach_eml_content,
error::{code::ErrorCode, BichonResult},
utils::compute_content_hash,
@@ -15,17 +16,20 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct AttachmentMetadata {
/// A collection of unique file extensions found in attachments.
/// Example: ["pdf", "docx", "png"]
pub extensions: HashSet<String>,
/// Statistics of attachment file extensions (key + count).
/// Each item represents a file extension and its occurrence count.
/// Example: [{ key: "pdf", count: 10 }, { key: "png", count: 5 }]
pub extensions: Vec<Group>,
/// A collection of high-level attachment categories.
/// Example: ["document", "image", "archive"]
pub categories: HashSet<String>,
/// Statistics of attachment categories (key + count).
/// Each item represents a high-level category and its occurrence count.
/// Example: [{ key: "document", count: 8 }, { key: "image", count: 6 }]
pub categories: Vec<Group>,
/// A collection of unique MIME types (Content-Type) for the attachments.
/// Example: ["application/pdf", "image/jpeg"]
pub content_types: HashSet<String>,
/// Statistics of attachment MIME types (Content-Type) (key + count).
/// Each item represents a MIME type and its occurrence count.
/// Example: [{ key: "application/pdf", count: 10 }, { key: "image/jpeg", count: 5 }]
pub content_types: Vec<Group>,
}
pub async fn retrieve_attachment_content(
+98 -63
View File
@@ -126,36 +126,54 @@ impl IndexManager {
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 pending_count = 0;
let commit_threshold = 1000;
loop {
tokio::select! {
maybe_msg = receiver.recv() => {
match maybe_msg {
Some(doc) => {
let writer = writer.lock().await;
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:?}");
}
pending_count += 1;
while let Ok(next_doc) = receiver.try_recv() {
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 => {
tracing::info!("Tantivy: Receiver closed. Finalizing...");
let mut writer = writer.lock().await;
fatal_commit(&mut writer);
if pending_count > 0 {
let mut writer = writer.lock().await;
fatal_commit(&mut writer);
}
break;
},
}
}
_ = commit_interval.tick() => {
let mut writer = writer.lock().await;
fatal_commit(&mut writer);
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...");
let mut writer = writer.lock().await;
fatal_commit(&mut writer);
if pending_count > 0 {
let mut writer = writer.lock().await;
fatal_commit(&mut writer);
}
tracing::info!("Tantivy: Shutdown cleanup complete.");
break;
}
@@ -347,22 +365,21 @@ impl IndexManager {
}
if let Some(ref extension) = filter.attachment_extension {
if let Ok(query) = RegexQuery::from_pattern(extension.as_str(), f.f_attachment_glue) {
subqueries.push((Occur::Must, Box::new(query)));
}
let term = Term::from_field_text(f.f_attachment_ext, extension);
let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query)));
}
if let Some(ref category) = filter.attachment_category {
if let Ok(query) = RegexQuery::from_pattern(category.as_str(), f.f_attachment_glue) {
subqueries.push((Occur::Must, Box::new(query)));
}
let term = Term::from_field_text(f.f_attachment_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 {
if let Ok(query) = RegexQuery::from_pattern(content_type.as_str(), f.f_attachment_glue)
{
subqueries.push((Occur::Must, Box::new(query)));
}
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)));
}
let start_bound = if let Some(from) = filter.since {
@@ -1168,6 +1185,28 @@ impl IndexManager {
) -> 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();
@@ -1184,50 +1223,56 @@ impl IndexManager {
None => Box::new(AllQuery),
};
// Extension collector
let mut ext_collector = FacetCollector::for_field(F_ATTACHMENT_EXT);
ext_collector.add_facet("/");
// 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)
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 metadata = AttachmentMetadata {
extensions: HashSet::new(),
categories: HashSet::new(),
content_types: HashSet::new(),
};
for (facet, _) in ext_counts.get("/") {
let val = facet.to_path_string().trim_start_matches('/').to_string();
metadata.extensions.insert(val);
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,
});
}
}
}
for (facet, _) in cat_counts.get("/") {
let val = facet.to_path_string().trim_start_matches('/').to_string();
metadata.categories.insert(val);
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,
});
}
}
}
for (facet, _) in type_counts.get("/") {
let val = facet.to_path_string().trim_start_matches('/').to_string();
metadata.content_types.insert(val);
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(metadata)
Ok(AttachmentMetadata {
extensions: exts,
categories: cats,
content_types: ctypes,
})
}
pub async fn get_dashboard_stats(
@@ -1385,16 +1430,6 @@ impl IndexManager {
"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 tantivy::{
schema::{Facet, Value},
TantivyDocument,
};
use tantivy::{schema::Value, TantivyDocument};
use crate::{
modules::{
@@ -66,21 +62,15 @@ impl EnvelopeWithAttachments {
if let Some(ext) = att.get_extension() {
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();
search_terms.push(category.clone());
let file_type = att.file_type.to_lowercase();
search_terms.push(file_type.clone());
doc.add_facet(
fields.f_attachment_category,
Facet::from(&format!("/{}", category)),
);
doc.add_facet(
fields.f_attachment_content_type,
Facet::from(&format!("/{}", file_type)),
);
doc.add_text(fields.f_attachment_category, category);
doc.add_text(fields.f_attachment_content_type, file_type);
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_attachment_content_hash =
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 =
builder.add_facet_field(F_ATTACHMENT_CATEGORY, FacetOptions::default().set_stored());
let f_attachment_content_type = builder.add_facet_field(
F_ATTACHMENT_CONTENT_TYPE,
FacetOptions::default().set_stored(),
);
builder.add_text_field(F_ATTACHMENT_CATEGORY, STRING | FAST | STORED);
let f_attachment_content_type =
builder.add_text_field(F_ATTACHMENT_CONTENT_TYPE, STRING | FAST | 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);
+10 -9
View File
@@ -19,6 +19,7 @@
import { EmailEnvelope, PaginatedResponse } from "@/api";
import axiosInstance from "@/api/axiosInstance";
import { Group } from "@/api/system/api";
import { saveAs } from 'file-saver';
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 {
/**
* A collection of unique file extensions found in attachments.
* @example ["pdf", "docx", "png"]
* Statistics of attachment file extensions (key + count).
* @example [{ key: "pdf", count: 10 }, { key: "png", count: 5 }]
*/
extensions: string[];
extensions: Group[];
/**
* A collection of high-level attachment categories.
* @example ["document", "image", "archive"]
* Statistics of attachment categories (key + count).
* @example [{ key: "document", count: 8 }, { key: "image", count: 6 }]
*/
categories: string[];
categories: Group[];
/**
* A collection of unique MIME types (Content-Type) for the attachments.
* @example ["application/pdf", "image/jpeg"]
* Statistics of attachment MIME types (Content-Type) (key + count).
* @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 { AtSign, ChevronDown, X } from 'lucide-react'
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 { useTranslation } from 'react-i18next'
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 { 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: string[]
options: Group[]
isLoading: boolean
onSelect: (val: string | undefined) => void
onReset: () => void
@@ -27,7 +47,7 @@ export function MetadataSelectorField({
const filteredOptions = React.useMemo(() => {
return options.filter(opt =>
opt.toLowerCase().includes(searchTerm.toLowerCase())
opt.key.toLowerCase().includes(searchTerm.toLowerCase())
)
}, [options, searchTerm])
@@ -76,19 +96,36 @@ export function MetadataSelectorField({
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>
{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={opt.key}
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"
>
<span className="truncate">{opt}</span>
{value === opt && <Check className="h-3 w-3 text-primary shrink-0" />}
<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>
@@ -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 { useSearchContext } from "./context"
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 { Button } from "@/components/ui/button"
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 {
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 { Label } from "@/components/ui/label"
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 { AttachmentInfo, download_nested_attachment, load_nested_message } from '@/api/mailbox/envelope/api';
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 { Tag, ChevronDown, X } from 'lucide-react'
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 { Input } from "@/components/ui/input"
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 { CalendarRange, ChevronDown, X } from 'lucide-react'
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 { useForm } from 'react-hook-form'
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'
export function SettingsAppearance() {