feat(search): add advanced attachment filters for extension, category and mime type

This commit is contained in:
rustmailer
2026-03-19 20:23:20 +08:00
parent 884fdeba10
commit c19f3977ba
31 changed files with 466 additions and 40 deletions
+28
View File
@@ -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<AttachmentMetadata>("api/v1/attachment_metadata");
return response.data;
};
@@ -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 (
<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}
onSelect={() => {
value === opt ? onReset() : onSelect(opt);
}}
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" />}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
+1 -1
View File
@@ -66,7 +66,7 @@ export function MailFilterPopover() {
{fields.map((field) => (
<ContactSelectorField
key={field}
label={field}
label={t(`search.${field}`)}
value={filter[field] as string | undefined}
onSelect={(email) => updateFilter(field, email)}
onReset={() => updateFilter(field, undefined)}
@@ -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() {
<Checkbox
id="has_attachment"
checked={localState.has_attachment}
onCheckedChange={(checked) =>
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: ''
})
}));
}}
/>
<Label
htmlFor="has_attachment"
@@ -144,15 +179,46 @@ export function MoreFiltersPopover() {
</Label>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('search_more.attachment_name_label')}</Label>
<Input
className="h-8 text-xs"
value={localState.attachment_name}
onChange={(e) => setLocalState(prev => ({ ...prev, attachment_name: e.target.value }))}
placeholder={t('search_more.attachment_name_placeholder')}
/>
</div>
{localState.has_attachment && (
<div className="space-y-3 p-2 bg-muted/30 rounded-lg border border-dashed border-border animate-in fade-in slide-in-from-top-1">
<MetadataSelectorField
label={t('search_more.extension')}
value={localState.attachment_extension}
options={meta?.extensions || []}
isLoading={metaLoading}
onSelect={(v) => setLocalState(p => ({ ...p, attachment_extension: v, attachment_category: '', attachment_content_type: '' }))}
onReset={() => setLocalState(p => ({ ...p, attachment_extension: '' }))}
/>
<MetadataSelectorField
label={t('search_more.category')}
value={localState.attachment_category}
options={meta?.categories || []}
isLoading={metaLoading}
onSelect={(v) => setLocalState(p => ({ ...p, attachment_category: v, attachment_extension: '', attachment_content_type: '' }))}
onReset={() => setLocalState(p => ({ ...p, attachment_category: '' }))}
/>
<MetadataSelectorField
label={t('search_more.content_types')}
value={localState.attachment_content_type}
options={meta?.content_types || []}
isLoading={metaLoading}
onSelect={(v) => setLocalState(p => ({ ...p, attachment_content_type: v, attachment_extension: '', attachment_category: '' }))}
onReset={() => setLocalState(p => ({ ...p, attachment_content_type: '' }))}
/>
<div className="space-y-1 px-1">
<Label className="text-xs text-muted-foreground">{t('search_more.attachment_name_label')}</Label>
<Input
className="h-8 text-xs"
value={localState.attachment_name}
onChange={(e) => setLocalState(prev => ({ ...prev, attachment_name: e.target.value }))}
placeholder={t('search_more.attachment_name_placeholder')}
/>
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('search_more.message_size_label')}</Label>
+14
View File
@@ -0,0 +1,14 @@
import { AttachmentMetadata, get_attachment_meta } from '@/api/mailbox/envelope/api';
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
export const ATTACHMENT_METADATA_KEY = ['attachment_metadata'] as const;
export const useAttachmentMetadata = (options?: Partial<UseQueryOptions<AttachmentMetadata>>) => {
return useQuery({
queryKey: ATTACHMENT_METADATA_KEY,
queryFn: get_attachment_meta,
staleTime: 1000 * 60 * 10,
gcTime: 1000 * 60 * 30,
...options,
});
};
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "متوسط (2 10 ميغابايت)",
"large": "كبير (10 20 ميغابايت)",
"huge": "ضخم (> 20 ميغابايت)"
}
},
"any": "الكل",
"search_placeholder": "بحث {{field}}...",
"extension": "امتداد الملف",
"category": "فئة المرفق",
"content_types": "نوع محدد"
},
"search_view": {
"button_label": "العرض",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Mellem (2 10 MB)",
"large": "Stor (10 20 MB)",
"huge": "Meget stor (> 20 MB)"
}
},
"any": "Alle",
"search_placeholder": "Søg {{field}}...",
"extension": "Filendelse",
"category": "Vedhæftningskategori",
"content_types": "Specifik type"
},
"search_view": {
"button_label": "Visning",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Mittel (2 10 MB)",
"large": "Groß (10 20 MB)",
"huge": "Sehr groß (> 20 MB)"
}
},
"any": "Beliebig",
"search_placeholder": "{{field}} suchen...",
"extension": "Dateiendung",
"category": "Anhangskategorie",
"content_types": "Spezifischer Typ"
},
"search_view": {
"button_label": "Ansicht",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Medium (2 10 MB)",
"large": "Large (10 20 MB)",
"huge": "Huge (> 20 MB)"
}
},
"any": "Any",
"search_placeholder": "Search {{field}}...",
"extension": "Extension",
"category": "Category",
"content_types": "Specific Type"
},
"search_view": {
"button_label": "View",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Mediano (2 10 MB)",
"large": "Grande (10 20 MB)",
"huge": "Muy grande (> 20 MB)"
}
},
"any": "Cualquiera",
"search_placeholder": "Buscar {{field}}...",
"extension": "Extensión de archivo",
"category": "Categoría de adjunto",
"content_types": "Tipo específico"
},
"search_view": {
"button_label": "Vista",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Keskikokoinen (2 10 MB)",
"large": "Suuri (10 20 MB)",
"huge": "Erittäin suuri (> 20 MB)"
}
},
"any": "Mikä tahansa",
"search_placeholder": "Etsi {{field}}...",
"extension": "Tiedostopääte",
"category": "Liitekategoria",
"content_types": "Tarkka tyyppi"
},
"search_view": {
"button_label": "Näkymä",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Moyenne (2 10 Mo)",
"large": "Grande (10 20 Mo)",
"huge": "Très grande (> 20 Mo)"
}
},
"any": "Tous",
"search_placeholder": "Rechercher {{field}}...",
"extension": "Extension de fichier",
"category": "Catégorie de pièce jointe",
"content_types": "Type spécifique"
},
"search_view": {
"button_label": "Affichage",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Media (2 10 MB)",
"large": "Grande (10 20 MB)",
"huge": "Molto grande (> 20 MB)"
}
},
"any": "Qualsiasi",
"search_placeholder": "Cerca {{field}}...",
"extension": "Estensione file",
"category": "Categoria allegato",
"content_types": "Tipo specifico"
},
"search_view": {
"button_label": "Vista",
+8 -3
View File
@@ -456,8 +456,8 @@
"before": "以前:",
"selectDate": "日付を選択",
"pleaseSelectAtLeastOne": "少なくとも1つの検索条件を選択してください",
"cc": "CC",
"bcc": "BCC",
"cc": "CC(写し)",
"bcc": "BCC(ブラインド写し)",
"attachmentName": "添付ファイル名",
"messageId": "メッセージID",
"minSize": "最小サイズ",
@@ -1539,7 +1539,12 @@
"medium": "中 (2 10 MB)",
"large": "大 (10 20 MB)",
"huge": "特大 (> 20 MB)"
}
},
"any": "制限なし",
"search_placeholder": "{{field}}を検索...",
"extension": "拡張子",
"category": "添付ファイルカテゴリ",
"content_types": "具体的な形式"
},
"search_view": {
"button_label": "表示",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "보통 (2 10 MB)",
"large": "큼 (10 20 MB)",
"huge": "매우 큼 (> 20 MB)"
}
},
"any": "제한 없음",
"search_placeholder": "{{field}} 검색...",
"extension": "파일 확장자",
"category": "첨부 파일 카테고리",
"content_types": "구체적인 형식"
},
"search_view": {
"button_label": "보기",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Gemiddeld (2 10 MB)",
"large": "Groot (10 20 MB)",
"huge": "Zeer groot (> 20 MB)"
}
},
"any": "Alle",
"search_placeholder": "Zoek {{field}}...",
"extension": "Bestandsextensie",
"category": "Bijlagecategorie",
"content_types": "Specifiek type"
},
"search_view": {
"button_label": "Weergave",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Middels (2 10 MB)",
"large": "Stor (10 20 MB)",
"huge": "Svært stor (> 20 MB)"
}
},
"any": "Alle",
"search_placeholder": "Søk {{field}}...",
"extension": "Filendelse",
"category": "Vedleggskategori",
"content_types": "Spesifikk type"
},
"search_view": {
"button_label": "Visning",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Średni (2 10 MB)",
"large": "Duży (10 20 MB)",
"huge": "Bardzo duży (> 20 MB)"
}
},
"any": "Dowolne",
"search_placeholder": "Szukaj {{field}}...",
"extension": "Rozszerzenie pliku",
"category": "Kategoria załącznika",
"content_types": "Konkretny typ"
},
"search_view": {
"button_label": "Widok",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Médio (2 10 MB)",
"large": "Grande (10 20 MB)",
"huge": "Muito grande (> 20 MB)"
}
},
"any": "Qualquer",
"search_placeholder": "Buscar {{field}}...",
"extension": "Extensão de arquivo",
"category": "Categoria de anexo",
"content_types": "Tipo específico"
},
"search_view": {
"button_label": "Visualização",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Средний (210 МБ)",
"large": "Большой (1020 МБ)",
"huge": "Очень большой (> 20 МБ)"
}
},
"any": "Любой",
"search_placeholder": "Поиск {{field}}...",
"extension": "Расширение файла",
"category": "Категория вложения",
"content_types": "Конкретный тип"
},
"search_view": {
"button_label": "Вид",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "Mellan (210 MB)",
"large": "Stor (1020 MB)",
"huge": "Mycket stor (> 20 MB)"
}
},
"any": "Alla",
"search_placeholder": "Sök {{field}}...",
"extension": "Filändelse",
"category": "Bilagskategori",
"content_types": "Specifik typ"
},
"search_view": {
"button_label": "Vy",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "一般(210 MB",
"large": "較大(1020 MB",
"huge": "極大(> 20 MB"
}
},
"any": "不限",
"search_placeholder": "搜尋{{field}}...",
"extension": "檔案副檔名",
"category": "附件分類",
"content_types": "具體類型"
},
"search_view": {
"button_label": "檢視",
+6 -1
View File
@@ -1539,7 +1539,12 @@
"medium": "普通 (2 - 10 MB)",
"large": "较大 (10 - 20 MB)",
"huge": "极大 (> 20 MB)"
}
},
"any": "不限",
"search_placeholder": "搜索{{field}}...",
"extension": "文件后缀",
"category": "附件分类",
"content_types": "具体类型"
},
"search_view": {
"button_label": "视图",