feat(search): expand default search scope and support specific field filtering

This commit is contained in:
rustmailer
2026-03-19 15:44:20 +08:00
parent 2228e98410
commit 884fdeba10
24 changed files with 236 additions and 150 deletions
+16
View File
@@ -1168,6 +1168,18 @@ impl DuckDBManager {
}
}
if let Some(subject) = filter.subject {
let pattern = format!("(?i){}", subject);
base_sql.push_str(" AND regexp_matches(coalesce(e.subject, ''), ?)");
args.push(pattern.into());
}
if let Some(body) = filter.body {
let pattern = format!("(?i){}", body);
base_sql.push_str(" AND regexp_matches(coalesce(e.body, ''), ?)");
args.push(pattern.into());
}
if let Some(text) = filter.text {
let pattern = format!("(?i){}", text);
base_sql.push_str(
@@ -1175,10 +1187,14 @@ impl DuckDBManager {
AND (
regexp_matches(coalesce(e.subject, ''), ?)
OR regexp_matches(coalesce(e.body, ''), ?)
OR regexp_matches(coalesce(e.sender, ''), ?)
OR regexp_matches(array_to_string(e.recipients, ','), ?)
)
",
);
args.push(pattern.clone().into());
args.push(pattern.clone().into());
args.push(pattern.clone().into());
args.push(pattern.into());
}
-2
View File
@@ -16,8 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::io::Write;
use mail_parser::{parsers::MessageStream, MessageParser, MimeHeaders};
use crate::{
+18 -8
View File
@@ -34,6 +34,8 @@ use crate::{
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct SearchFilter {
pub text: Option<String>,
pub subject: Option<String>,
pub body: Option<String>,
pub from: Option<String>,
pub to: Option<String>,
pub cc: Option<String>,
@@ -80,15 +82,23 @@ impl SearchRequest {
));
}
if let Some(ref pattern) = self.filter.text {
if let Err(_) = duckdb()?.validate_regex(pattern) {
return Err(raise_error!(
"Invalid search pattern: The regular expression is not supported by DuckDB."
.into(),
ErrorCode::InvalidParameter
));
let validate = |pattern: &Option<String>| -> BichonResult<()> {
if let Some(ref p) = pattern {
if duckdb()?.validate_regex(p).is_err() {
return Err(raise_error!(
"Invalid search pattern: The regular expression is not supported by DuckDB.".into(),
ErrorCode::InvalidParameter
));
}
}
}
Ok(())
};
validate(&self.filter.text)?;
validate(&self.filter.subject)?;
validate(&self.filter.body)?;
validate(&self.filter.from)?;
validate(&self.filter.to)?;
Ok(())
}
+2 -2
View File
@@ -8,7 +8,7 @@ export function FilterResetButton() {
const { filter, setFilter } = useSearchContext();
const { t } = useTranslation()
const { q, ...restFilters } = filter;
const activeFiltersCount = Object.keys(restFilters).filter(key => {
const value = restFilters[key];
if (Array.isArray(value)) return value.length > 0;
@@ -19,7 +19,7 @@ export function FilterResetButton() {
return (
<Button
variant="ghost"
variant="default"
size="sm"
onClick={() => setFilter(q ? { q } : {})}
className={cn(
+2 -2
View File
@@ -32,10 +32,10 @@ export function DataTableToolbar<TData>({
<TagFilterPopover />
<TimePopover />
<MoreFiltersPopover />
<FilterResetButton />
<DataTableViewOptions table={table} />
</div>
<div className="flex-shrink-0 ml-auto lg:ml-0">
<DataTableViewOptions table={table} />
<FilterResetButton />
</div>
</div>
</div>
+126 -118
View File
@@ -1,63 +1,61 @@
import React, { useState, useEffect, useRef } from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Search, X, Clock, Trash2, Info } from "lucide-react"
import { Search, X, Clock, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { useSearchContext } from "./context"
import { useTranslation } from "react-i18next"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const STORAGE_KEY = "mail_search_history"
const STORAGE_KEY = "bichon_mail_search_history"
const MAX_HISTORY = 20
type SearchField = "text" | "subject" | "body"
const SEARCH_FIELDS: SearchField[] = ["text", "subject", "body"]
export function TextSearchInput() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const [value, setValue] = useState(filter.text || "")
const [value, setValue] = useState("")
const [field, setField] = useState<SearchField>("text")
const [history, setHistory] = useState<string[]>([])
const [showHistory, setShowHistory] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const activeField = SEARCH_FIELDS.find(key => !!filter[key]) || "text"
const activeValue = filter[activeField] as string || ""
setField(activeField)
setValue(activeValue)
}, [filter])
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
setHistory(JSON.parse(saved))
}
if (saved) setHistory(JSON.parse(saved))
} catch (err) {
console.warn("Failed to load search history", err)
}
}, [])
useEffect(() => {
setValue(filter.text || "")
}, [filter.text])
const applyFilter = (currentField: SearchField, searchTerm: string) => {
const trimmed = searchTerm.trim()
const saveToHistory = (term: string) => {
if (!term.trim()) return
setHistory(prev => {
const trimmed = term.trim()
const withoutCurrent = prev.filter(item => item !== trimmed)
const newHistory = [trimmed, ...withoutCurrent].slice(0, MAX_HISTORY)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory))
} catch (err) {
console.warn("Failed to save search history", err)
setFilter((prev) => {
const next = { ...prev }
SEARCH_FIELDS.forEach(f => {
delete next[f]
})
if (trimmed) {
next[currentField] = trimmed
}
return newHistory
return next
})
}
const handleSearch = () => {
const trimmed = value.trim()
setFilter(prev => ({
...prev,
text: trimmed || undefined
}))
if (trimmed) {
saveToHistory(trimmed)
}
@@ -65,37 +63,31 @@ export function TextSearchInput() {
inputRef.current?.blur()
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault()
handleSearch()
}
const saveToHistory = (term: string) => {
setHistory((prev) => {
const trimmed = term.trim()
const newHistory = [trimmed, ...prev.filter((item) => item !== trimmed)].slice(0, MAX_HISTORY)
localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory))
return newHistory
})
}
const handleSearch = () => applyFilter(field, value)
const handleClear = () => {
setValue("")
setFilter(prev => {
const next = { ...prev }
delete next.text
return next
})
setShowHistory(false)
applyFilter(field, "")
}
const handleSelectHistory = (term: string) => {
setValue(term)
setShowHistory(false)
// 如果需要点击历史立即搜索,可以在这里调用 handleSearch()
applyFilter(field, term)
}
const handleClearHistory = () => {
const handleClearHistory = (e: React.MouseEvent) => {
e.stopPropagation()
setHistory([])
try {
localStorage.removeItem(STORAGE_KEY)
} catch (err) {
console.warn("Failed to clear search history", err)
}
setShowHistory(false)
localStorage.removeItem(STORAGE_KEY)
}
useEffect(() => {
@@ -108,91 +100,107 @@ export function TextSearchInput() {
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
const isActive = !!filter.text?.trim()
return (
<div ref={containerRef} className="relative w-full max-w-[550px] min-w-[280px]">
<div className="flex flex-col gap-1.5">
<div className="relative flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onFocus={() => setShowHistory(true)}
onKeyDown={handleKeyDown}
placeholder={t('search_input.placeholder')}
className={cn(
"h-9 pl-9 pr-9 text-sm",
isActive && "border-primary/50 focus-visible:ring-primary/30"
)}
/>
{value && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={handleClear}
>
<X className="h-4 w-4" />
</Button>
<div ref={containerRef} className="relative w-full max-w-[620px] min-w-[320px]">
<div className="flex items-center rounded-md border bg-background focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/30 transition-all">
<Select
value={field}
onValueChange={(val) => {
const newField = val as SearchField
setField(newField)
if (value.trim()) applyFilter(newField, value)
}}
>
<SelectTrigger
className={cn(
"h-9 w-[110px] md:w-[130px] border-r border-border rounded-r-none",
"text-xs md:text-sm bg-transparent focus:ring-0 focus:ring-offset-0 shadow-none border-y-0 border-l-0"
)}
</div>
<Button
size="sm"
className="h-9 px-5"
onClick={handleSearch}
disabled={!value.trim()}
>
{t('search_input.button')}
</Button>
</div>
{/* 搜索范围提示 */}
<div className="flex items-center gap-1 px-1 opacity-60">
<Info className="h-3 w-3 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">
{t('search_input.hint')}
</span>
<SelectValue />
</SelectTrigger>
<SelectContent className="min-w-[240px]">
<SelectItem value="text" className="font-medium cursor-pointer text-xs">
{t("search_input.all")}
<p className="text-[11px] text-muted-foreground/60 leading-relaxed">
{t("search_input.all_fields_desc")}
</p>
</SelectItem>
<SelectItem value="subject" className="cursor-pointer text-xs">
{t("search_input.subject")}
</SelectItem>
<SelectItem value="body" className="cursor-pointer text-xs">
{t("search_input.body")}
</SelectItem>
</SelectContent>
</Select>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onFocus={() => setShowHistory(true)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder={t("search_input.placeholder")}
className="h-9 border-none shadow-none focus-visible:ring-0 pl-9 pr-10 text-sm bg-transparent w-full"
/>
{value && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={handleClear}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<Button
size="sm"
className="h-7 mr-1.5 px-3 text-xs md:px-5 md:text-sm"
onClick={handleSearch}
disabled={!value.trim()}
>
{t("search_input.button")}
</Button>
</div>
{showHistory && (
<div className="absolute top-10 left-0 w-full mt-1 bg-popover border rounded-md shadow-md z-50 max-h-[280px] overflow-auto">
<div className="py-1.5 px-3 text-xs text-muted-foreground font-medium border-b flex items-center justify-between sticky top-0 bg-popover z-10">
<div className="absolute top-full left-0 w-full mt-1 bg-popover border rounded-md shadow-lg z-50 max-h-[300px] overflow-hidden flex flex-col">
<div className="py-2 px-3 text-[10px] uppercase tracking-wider text-muted-foreground font-semibold border-b flex items-center justify-between bg-muted/30">
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3" />
{t('search_input.recent_title')}
{t("search_input.recent_title")}
</div>
{history.length > 0 && (
<button
onClick={handleClearHistory}
className="text-xs text-destructive hover:text-destructive/80 flex items-center gap-1 hover:underline"
className="text-destructive hover:underline flex items-center gap-1"
>
<Trash2 className="h-3 w-3" />
{t('search_input.clear_history')}
{t("search_input.clear_history")}
</button>
)}
</div>
{history.length > 0 ? (
history.map((term, idx) => (
<button
key={idx}
className="w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors flex items-center gap-2"
onClick={() => handleSelectHistory(term)}
>
<Search className="h-3.5 w-3.5 text-muted-foreground" />
{term}
</button>
))
) : (
<div className="px-3 py-4 text-xs text-center text-muted-foreground">
{t('search_input.no_history')}
</div>
)}
<div className="overflow-auto py-1">
{history.length > 0 ? (
history.map((term, idx) => (
<button
key={idx}
className="w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 group"
onClick={() => handleSelectHistory(term)}
>
<Search className="h-3.5 w-3.5 text-muted-foreground group-hover:text-primary" />
<span className="truncate flex-1">{term}</span>
</button>
))
) : (
<div className="px-3 py-6 text-sm text-center text-muted-foreground">
{t("search_input.no_history")}
</div>
)}
</div>
</div>
)}
</div>
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "عمليات البحث الأخيرة",
"clear_history": "مسح السجل",
"no_history": "لا يوجد سجل بحث",
"hint": "نطاق البحث الافتراضي: العنوان، المحتوى، وأسماء المرفقات"
"all": "الكل",
"all_fields_desc": "يطابق موضوع الرسالة، النص، المرسل والمستلمين",
"subject": "موضوع الرسالة",
"body": "نص الرسالة"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Seneste søgninger",
"clear_history": "Ryd historik",
"no_history": "Ingen søgehistorik",
"hint": "Standard søgeområde: emne, indhold og vedhæftningsnavne"
"all": "Alle",
"all_fields_desc": "Matcher emne, indhold, afsender og modtagere",
"subject": "Emne",
"body": "Indhold"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Letzte Suchanfragen",
"clear_history": "Verlauf löschen",
"no_history": "Kein Suchverlauf",
"hint": "Standard-Suchbereich: Betreff, Inhalt und Anhangnamen"
"all": "Alle Felder",
"all_fields_desc": "Matcht Betreff, Text, Absender und Empfänger",
"subject": "Betreff",
"body": "Inhalt"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Recent searches",
"clear_history": "Clear history",
"no_history": "No search history",
"hint": "Default scope: subject, body and attachment names"
"all": "All Fields",
"all_fields_desc": "Matches subject, body, sender, and recipients",
"subject": "Subject",
"body": "Body"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Búsquedas recientes",
"clear_history": "Borrar historial",
"no_history": "Sin historial de búsqueda",
"hint": "Ámbito de búsqueda predeterminado: asunto, contenido y nombres de archivos adjuntos"
"all": "Todo",
"all_fields_desc": "Coincide con asunto, cuerpo, remitente y destinatarios",
"subject": "Asunto",
"body": "Cuerpo del mensaje"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Viimeisimmät haut",
"clear_history": "Tyhjennä historia",
"no_history": "Ei hakuhistoriaa",
"hint": "Oletushakualue: otsikko, sisältö ja liitteiden nimet"
"all": "Kaikki",
"all_fields_desc": "Hakee aiheesta, tekstistä, lähettäjästä ja vastaanottajista",
"subject": "Aihe",
"body": "Viestiosa"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Recherches récentes",
"clear_history": "Effacer lhistorique",
"no_history": "Aucun historique de recherche",
"hint": "Portée par défaut : objet, contenu et noms des pièces jointes"
"all": "Tous les champs",
"all_fields_desc": "Recherche dans l'objet, le corps, l'expéditeur et les destinataires",
"subject": "Objet",
"body": "Corps du message"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Ricerche recenti",
"clear_history": "Cancella cronologia",
"no_history": "Nessuna cronologia di ricerca",
"hint": "Ambito predefinito: oggetto, contenuto e nomi degli allegati"
"all": "Tutti i campi",
"all_fields_desc": "Cerca in oggetto, testo, mittente e destinatari",
"subject": "Oggetto",
"body": "Corpo del messaggio"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "最近の検索",
"clear_history": "履歴をクリア",
"no_history": "履歴なし",
"hint": "検索対象:件名・本文・添付ファイル名"
"all": "すべて",
"all_fields_desc": "件名、本文、送信者、受信者に一致します",
"subject": "件名",
"body": "本文"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "최근 검색",
"clear_history": "기록 지우기",
"no_history": "검색 기록 없음",
"hint": "기본 검색 범위: 제목, 본문 및 첨부파일 이름"
"all": "전체",
"all_fields_desc": "제목, 본문, 발신자 및 수신자를 검색합니다",
"subject": "제목",
"body": "본문"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Recente zoekopdrachten",
"clear_history": "Geschiedenis wissen",
"no_history": "Geen zoekgeschiedenis",
"hint": "Standaard zoekbereik: onderwerp, inhoud en bijlagenamen"
"all": "Alle velden",
"all_fields_desc": "Zoekt in onderwerp, tekst, afzender en ontvangers",
"subject": "Onderwerp",
"body": "Inhoud"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Nylige søk",
"clear_history": "Tøm historikk",
"no_history": "Ingen søkehistorikk",
"hint": "Standard søkeområde: emne, innhold og vedleggsnavn"
"all": "Alle",
"all_fields_desc": "Søker i emne, tekst, avsender og mottakere",
"subject": "Emne",
"body": "Innhold"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Ostatnie wyszukiwania",
"clear_history": "Wyczyść historię",
"no_history": "Brak historii wyszukiwania",
"hint": "Domyślny zakres wyszukiwania: temat, treść i nazwy załączników"
"all": "Wszystkie",
"all_fields_desc": "Dopasowuje temat, treść, nadawcę i odbiorców",
"subject": "Temat",
"body": "Treść"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Pesquisas recentes",
"clear_history": "Limpar histórico",
"no_history": "Nenhum histórico de pesquisa",
"hint": "Escopo padrão: assunto, conteúdo e nomes dos anexos"
"all": "Todos os campos",
"all_fields_desc": "Corresponde ao assunto, corpo, remetente e destinatários",
"subject": "Assunto",
"body": "Corpo da mensagem"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Недавние поиски",
"clear_history": "Очистить историю",
"no_history": "История поиска пуста",
"hint": "По умолчанию поиск выполняется по теме, содержимому и именам вложений"
"all": "Все поля",
"all_fields_desc": "Поиск по теме, тексту, отправителю и получателям",
"subject": "Тема",
"body": "Текст письма"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "Senaste sökningar",
"clear_history": "Rensa historik",
"no_history": "Ingen sökhistorik",
"hint": "Standardomfattning: ämne, innehåll och bilagenamn"
"all": "Alla",
"all_fields_desc": "Matchar ämne, innehåll, avsändare och mottagare",
"subject": "Ämne",
"body": "Innehåll"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "最近搜尋",
"clear_history": "清除紀錄",
"no_history": "尚無搜尋紀錄",
"hint": "預設搜尋範圍:郵件標題、內容與附件名稱"
"all": "全部",
"all_fields_desc": "匹配郵件主題、正文、發件人及收件人",
"subject": "郵件主題",
"body": "郵件正文"
}
}
+4 -1
View File
@@ -1555,6 +1555,9 @@
"recent_title": "最近搜索",
"clear_history": "清空记录",
"no_history": "暂无搜索记录",
"hint": "默认搜索范围:邮件标题、正文及附件名称"
"all": "全部",
"all_fields_desc": "匹配邮件主题、正文、发件人及收件人",
"subject": "邮件主题",
"body": "邮件正文"
}
}