From c19f3977bab97895d2bd01cd2650e6bfe67bb390 Mon Sep 17 00:00:00 2001 From: rustmailer Date: Thu, 19 Mar 2026 20:23:20 +0800 Subject: [PATCH] feat(search): add advanced attachment filters for extension, category and mime type --- src/modules/duckdb/init.rs | 74 ++++++++++++- src/modules/envelope/extractor.rs | 2 +- src/modules/indexer/manager.rs | 13 ++- src/modules/message/attachment.rs | 19 ++++ src/modules/message/content.rs | 2 +- src/modules/message/mod.rs | 1 + src/modules/message/search.rs | 3 + src/modules/rest/api/message.rs | 26 +++++ web/src/api/mailbox/envelope/api.ts | 28 +++++ .../search/attachment-metadata-selector.tsx | 100 ++++++++++++++++++ web/src/features/search/contact-popover.tsx | 2 +- .../features/search/more-filters-popover.tsx | 92 +++++++++++++--- web/src/hooks/use-attachment-metadata.ts | 14 +++ web/src/locales/ar.json | 7 +- web/src/locales/da.json | 7 +- web/src/locales/de.json | 7 +- web/src/locales/en.json | 7 +- web/src/locales/es.json | 7 +- web/src/locales/fi.json | 7 +- web/src/locales/fr.json | 7 +- web/src/locales/it.json | 7 +- web/src/locales/jp.json | 11 +- web/src/locales/ko.json | 7 +- web/src/locales/nl.json | 7 +- web/src/locales/no.json | 7 +- web/src/locales/pl.json | 7 +- web/src/locales/pt.json | 7 +- web/src/locales/ru.json | 7 +- web/src/locales/sv.json | 7 +- web/src/locales/zh-tw.json | 7 +- web/src/locales/zh.json | 7 +- 31 files changed, 466 insertions(+), 40 deletions(-) create mode 100644 src/modules/message/attachment.rs create mode 100644 web/src/features/search/attachment-metadata-selector.tsx create mode 100644 web/src/hooks/use-attachment-metadata.ts diff --git a/src/modules/duckdb/init.rs b/src/modules/duckdb/init.rs index 8a5e598..809d143 100644 --- a/src/modules/duckdb/init.rs +++ b/src/modules/duckdb/init.rs @@ -37,6 +37,7 @@ use crate::{ manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER}, }, message::{ + attachment::AttachmentMetadata, content::AttachmentInfo, search::{SearchFilter, SortBy}, tags::TagCount, @@ -246,9 +247,9 @@ impl DuckDBManager { att.filename, att.get_extension(), att.get_category(), - att.file_type, + att.file_type.to_ascii_lowercase(), att.size as u64, - env.content_hash.clone(), //这里是错误的,应该保存附件的content_hash + env.content_hash.clone(), // It's the hash of the attachment content itself, not the hash of the full email. 0, ]) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; @@ -550,6 +551,55 @@ impl DuckDBManager { Ok(()) } + pub fn get_attachment_metadata( + &self, + accounts: Option>, + ) -> BichonResult { + let conn = self.conn()?; + let mut sql = r#" + SELECT + CAST(array_agg(DISTINCT extension) AS JSON) AS extensions, + CAST(array_agg(DISTINCT ext_category) AS JSON) AS categories, + CAST(array_agg(DISTINCT content_type) AS JSON) AS content_types + FROM envelope_attachments + "# + .to_string(); + + let mut params_vec: Vec = Vec::new(); + if let Some(ref acc_set) = accounts { + if !acc_set.is_empty() { + let placeholders = vec!["?"; acc_set.len()].join(", "); + sql.push_str(&format!(" WHERE account_id IN ({})", placeholders)); + + for &id in acc_set { + params_vec.push(id.into()); + } + } + } + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + + let result = stmt + .query_row(duckdb::params_from_iter(params_vec), |row| { + let exts_raw: String = row.get(0)?; + let cats_raw: String = row.get(1)?; + let ctypes_raw: String = row.get(2)?; + let exts: Vec = serde_json::from_str(&exts_raw).unwrap_or_default(); + let cats: Vec = serde_json::from_str(&cats_raw).unwrap_or_default(); + let ctypes: Vec = serde_json::from_str(&ctypes_raw).unwrap_or_default(); + + Ok(AttachmentMetadata { + extensions: exts.into_iter().collect(), + categories: cats.into_iter().collect(), + content_types: ctypes.into_iter().collect(), + }) + }) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + Ok(result) + } + pub fn delete_envelopes_multi_account( &self, deletes: HashMap>, @@ -1122,7 +1172,10 @@ impl DuckDBManager { let mut base_sql = String::from(" FROM envelopes e "); - let need_join_attachment = filter.attachment_name.is_some(); + let need_join_attachment = filter.attachment_name.is_some() + || filter.attachment_extension.is_some() + || filter.attachment_category.is_some() + || filter.attachment_content_type.is_some(); if need_join_attachment { base_sql.push_str( @@ -1290,6 +1343,21 @@ impl DuckDBManager { args.push(format!("%{}%", name).into()); } + if let Some(ext) = filter.attachment_extension { + base_sql.push_str(" AND a.extension ILIKE ? "); + args.push(format!("%{}%", ext).into()); + } + + if let Some(cat) = filter.attachment_category { + base_sql.push_str(" AND a.ext_category ILIKE ? "); + args.push(format!("%{}%", cat).into()); + } + + if let Some(ctype) = filter.attachment_content_type { + base_sql.push_str(" AND a.content_type ILIKE ? "); + args.push(format!("%{}%", ctype).into()); + } + let count_sql = if need_join_attachment { format!("SELECT COUNT(DISTINCT e.id) {}", base_sql) } else { diff --git a/src/modules/envelope/extractor.rs b/src/modules/envelope/extractor.rs index 5e03bb6..8804b49 100644 --- a/src/modules/envelope/extractor.rs +++ b/src/modules/envelope/extractor.rs @@ -160,7 +160,7 @@ fn extract_envelope_core( ) }) .unwrap_or_else(|| "application/octet-stream".to_string()); - + //注意:有些附件是没有名字的,这样extension也就不存在,那么在获取附件的时候,就不能通过name定位 Some(AttachmentInfo { filename: attachment .attachment_name() diff --git a/src/modules/indexer/manager.rs b/src/modules/indexer/manager.rs index 2824ba0..f69f10a 100644 --- a/src/modules/indexer/manager.rs +++ b/src/modules/indexer/manager.rs @@ -25,7 +25,9 @@ use std::{ use crate::modules::{ duckdb::init::duckdb, - message::{content::AttachmentInfo, search::SortBy, tags::TagCount}, + message::{ + attachment::AttachmentMetadata, content::AttachmentInfo, search::SortBy, tags::TagCount, + }, settings::cli::SETTINGS, }; use crate::{ @@ -192,6 +194,15 @@ impl EnvelopeIndexManager { .map_err(|e| raise_error!(format!("{:?}", e), ErrorCode::InternalError))? } + pub async fn get_attachment_metadata( + &self, + accounts: Option>, + ) -> BichonResult { + tokio::task::spawn_blocking(move || duckdb()?.get_attachment_metadata(accounts)) + .await + .map_err(|e| raise_error!(format!("{:?}", e), ErrorCode::InternalError))? + } + pub async fn delete_envelopes_multi_account( &self, deletes: HashMap>, // HashMap diff --git a/src/modules/message/attachment.rs b/src/modules/message/attachment.rs new file mode 100644 index 0000000..9c8ed16 --- /dev/null +++ b/src/modules/message/attachment.rs @@ -0,0 +1,19 @@ +use std::collections::HashSet; + +use poem_openapi::Object; +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, + + /// A collection of high-level attachment categories. + /// Example: ["document", "image", "archive"] + pub categories: HashSet, + + /// A collection of unique MIME types (Content-Type) for the attachments. + /// Example: ["application/pdf", "image/jpeg"] + pub content_types: HashSet, +} diff --git a/src/modules/message/content.rs b/src/modules/message/content.rs index 5c2bbb2..c97dedc 100644 --- a/src/modules/message/content.rs +++ b/src/modules/message/content.rs @@ -51,7 +51,7 @@ impl AttachmentInfo { std::path::Path::new(&self.filename) .extension() .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_lowercase()) + .map(|ext| ext.to_ascii_lowercase()) .unwrap_or_default() } diff --git a/src/modules/message/mod.rs b/src/modules/message/mod.rs index cc43244..1169cb3 100644 --- a/src/modules/message/mod.rs +++ b/src/modules/message/mod.rs @@ -17,6 +17,7 @@ // along with this program. If not, see . pub mod append; +pub mod attachment; pub mod contacts; pub mod content; pub mod delete; diff --git a/src/modules/message/search.rs b/src/modules/message/search.rs index c3766ad..405ee35 100644 --- a/src/modules/message/search.rs +++ b/src/modules/message/search.rs @@ -50,6 +50,9 @@ pub struct SearchFilter { pub has_attachment: Option, pub attachment_name: Option, pub tags: Option>, + pub attachment_extension: Option, + pub attachment_category: Option, + pub attachment_content_type: Option, } #[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Enum)] diff --git a/src/modules/rest/api/message.rs b/src/modules/rest/api/message.rs index 4a6e58d..0057c70 100644 --- a/src/modules/rest/api/message.rs +++ b/src/modules/rest/api/message.rs @@ -23,6 +23,7 @@ use crate::modules::indexer::manager::EML_INDEX_MANAGER; use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER; use crate::modules::message::append::restore_emails; use crate::modules::message::append::RestoreMessagesRequest; +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}; @@ -411,4 +412,29 @@ impl MessageApi { .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> { + let authorized_ids: Option> = if context + .has_permission(None, Permission::DATA_READ_ALL) + .await + { + None + } else { + Some(context.user.account_access_map.keys().cloned().collect()) + }; + Ok(Json( + ENVELOPE_INDEX_MANAGER + .get_attachment_metadata(authorized_ids) + .await?, + )) + } } diff --git a/web/src/api/mailbox/envelope/api.ts b/web/src/api/mailbox/envelope/api.ts index 718d7a5..473650a 100644 --- a/web/src/api/mailbox/envelope/api.ts +++ b/web/src/api/mailbox/envelope/api.ts @@ -105,4 +105,32 @@ export const restore_message = async (accountId: number, envelopeIds: string[]) envelope_ids: envelopeIds, }); return response.data; +}; + + + +export interface AttachmentMetadata { + /** + * A collection of unique file extensions found in attachments. + * @example ["pdf", "docx", "png"] + */ + extensions: string[]; + + /** + * A collection of high-level attachment categories. + * @example ["document", "image", "archive"] + */ + categories: string[]; + + /** + * A collection of unique MIME types (Content-Type) for the attachments. + * @example ["application/pdf", "image/jpeg"] + */ + content_types: string[]; +} + + +export const get_attachment_meta = async () => { + const response = await axiosInstance.get("api/v1/attachment_metadata"); + return response.data; }; \ No newline at end of file diff --git a/web/src/features/search/attachment-metadata-selector.tsx b/web/src/features/search/attachment-metadata-selector.tsx new file mode 100644 index 0000000..c6e7531 --- /dev/null +++ b/web/src/features/search/attachment-metadata-selector.tsx @@ -0,0 +1,100 @@ +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" + +interface MetadataSelectorFieldProps { + label: string + value?: string + options: string[] + 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.toLowerCase().includes(searchTerm.toLowerCase()) + ) + }, [options, searchTerm]) + + return ( + + + + + + + + + + {isLoading &&
{t('common.loading')}
} + {t('common.noData')} + + {filteredOptions.map((opt) => ( + { + value === opt ? onReset() : onSelect(opt); + }} + className="flex items-center justify-between py-2 px-3 cursor-pointer text-xs" + > + {opt} + {value === opt && } + + ))} + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/web/src/features/search/contact-popover.tsx b/web/src/features/search/contact-popover.tsx index 1e7a36b..0727244 100644 --- a/web/src/features/search/contact-popover.tsx +++ b/web/src/features/search/contact-popover.tsx @@ -66,7 +66,7 @@ export function MailFilterPopover() { {fields.map((field) => ( updateFilter(field, email)} onReset={() => updateFilter(field, undefined)} diff --git a/web/src/features/search/more-filters-popover.tsx b/web/src/features/search/more-filters-popover.tsx index 8569dea..745eaca 100644 --- a/web/src/features/search/more-filters-popover.tsx +++ b/web/src/features/search/more-filters-popover.tsx @@ -11,6 +11,8 @@ import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" import { cn } from "@/lib/utils" +import { useAttachmentMetadata } from "@/hooks/use-attachment-metadata" +import { MetadataSelectorField } from "./attachment-metadata-selector" const SIZES = { tiny: { min: undefined, max: 15 * 1024 }, @@ -34,8 +36,13 @@ export function MoreFiltersPopover() { const { filter, setFilter } = useSearchContext(); const [open, setOpen] = React.useState(false); + const { data: meta, isLoading: metaLoading } = useAttachmentMetadata(); + const [localState, setLocalState] = React.useState({ attachment_name: filter?.attachment_name || '', + attachment_extension: filter?.attachment_extension || '', + attachment_category: filter?.attachment_category || '', + attachment_content_type: filter?.attachment_content_type || '', message_id: filter?.message_id || '', size_preset: getPresetFromSize(filter?.min_size, filter?.max_size), has_attachment: filter?.has_attachment || false @@ -45,6 +52,9 @@ export function MoreFiltersPopover() { if (open) { setLocalState({ attachment_name: filter?.attachment_name || '', + attachment_extension: filter?.attachment_extension || '', + attachment_category: filter?.attachment_category || '', + attachment_content_type: filter?.attachment_content_type || '', message_id: filter?.message_id || '', size_preset: getPresetFromSize(filter?.min_size, filter?.max_size), has_attachment: filter?.has_attachment || false @@ -59,6 +69,15 @@ export function MoreFiltersPopover() { if (localState.attachment_name) next.attachment_name = localState.attachment_name; else delete next.attachment_name; + if (localState.attachment_extension) next.attachment_extension = localState.attachment_extension; + else delete next.attachment_extension; + + if (localState.attachment_category) next.attachment_category = localState.attachment_category; + else delete next.attachment_category; + + if (localState.attachment_content_type) next.attachment_content_type = localState.attachment_content_type; + else delete next.attachment_content_type; + if (localState.message_id) next.message_id = localState.message_id; else delete next.message_id; @@ -79,7 +98,10 @@ export function MoreFiltersPopover() { filter?.min_size, filter?.max_size, filter?.message_id, - filter?.has_attachment + filter?.has_attachment, + filter?.attachment_extension, + filter?.attachment_category, + filter?.attachment_content_type ].filter(Boolean).length; return ( @@ -118,6 +140,9 @@ export function MoreFiltersPopover() { delete next.max_size; delete next.message_id; delete next.has_attachment; + delete next.attachment_extension; + delete next.attachment_category; + delete next.attachment_content_type; return next; }); setOpen(false); @@ -132,9 +157,19 @@ export function MoreFiltersPopover() { - setLocalState(prev => ({ ...prev, has_attachment: checked as boolean })) - } + onCheckedChange={(checked) => { + const isChecked = checked as boolean; + setLocalState(prev => ({ + ...prev, + has_attachment: isChecked, + ...(isChecked ? {} : { + attachment_name: '', + attachment_extension: '', + attachment_category: '', + attachment_content_type: '' + }) + })); + }} />