feat: add attachment search view

This commit is contained in:
rustmailer
2026-04-19 01:01:46 +08:00
parent 19b5168960
commit 7dd722f9b8
96 changed files with 36331 additions and 28970 deletions
@@ -0,0 +1,203 @@
//
// 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'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { cn } from '@/lib/utils'
import { useSearchContext } from './context'
export function AccountPopover() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const [search, setSearch] = React.useState('')
const { minimalList = [] } = useMinimalAccountList()
const selectedIds: number[] = filter.account_ids ?? []
const toggleAccount = (id: number) => {
setFilter(prev => {
const next = { ...prev }
const set = new Set<number>(next.account_ids ?? [])
if (set.has(id)) {
set.delete(id)
} else {
set.add(id)
}
if (set.size === 0) {
delete next.account_ids
delete next.mailbox_ids
} else {
next.account_ids = Array.from(set).sort()
delete next.mailbox_ids
}
return next
})
}
const clearAccounts = () => {
setFilter(prev => {
const next = { ...prev }
delete next.account_ids
delete next.mailbox_ids
return next
})
}
const filtered = React.useMemo(() => {
const q = search.toLowerCase()
return minimalList
.filter(a =>
!q ||
a.email.toLowerCase().includes(q) ||
String(a.id).includes(q)
)
.sort((a, b) => {
const aSel = selectedIds.includes(a.id)
const bSel = selectedIds.includes(b.id)
if (aSel && !bSel) return -1
if (!aSel && bSel) return 1
return a.id - b.id
})
}, [minimalList, search, selectedIds])
return (
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
className={cn(
'h-6 gap-1.5 px-3 rounded-none ',
selectedIds.length > 0 &&
'bg-primary/10 border-primary text-primary'
)}
>
<AtSign className="h-4 w-4" />
{t('search_accounts.label')}
{selectedIds.length > 0 && (
<Badge
variant="secondary"
className="ml-1 h-5 px-1.5 text-xs"
>
{selectedIds.length}
</Badge>
)}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-96 p-1">
<div className="p-1 pb-2">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('search_accounts.search_placeholder')}
className="h-8 text-sm"
/>
</div>
{!search && selectedIds.length > 0 && (
<div className="p-1">
<Button
variant="ghost"
size="sm"
onClick={clearAccounts}
className="flex h-8 w-full items-center justify-start gap-2 px-2 text-xs font-medium text-destructive hover:bg-destructive/10 hover:text-destructive transition-colors"
>
<div className="flex h-4 w-4 items-center justify-center">
<X className="h-3.5 w-3.5" />
</div>
<span className="flex-1 text-left">
{t('search_accounts.clear_accounts')}
</span>
<span className="text-[10px] opacity-60 font-mono">
({selectedIds.length})
</span>
</Button>
<div className="my-1 h-px bg-border/60" />
</div>
)}
<ScrollArea className="h-96 p-1">
{filtered.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t('search_accounts.no_accounts_found')}
</p>
) : (
filtered.map(account => {
const checked = selectedIds.includes(account.id)
const id = `account-${account.id}`
return (
<div
key={account.id}
onClick={() => toggleAccount(account.id)}
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
'hover:bg-accent transition-colors'
)}
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={() =>
toggleAccount(account.id)
}
onClick={e => e.stopPropagation()}
/>
<Label
htmlFor={id}
className="flex-1 truncate text-xs cursor-pointer"
>
<div className="flex items-center gap-2">
<span className="truncate">
{account.email}
</span>
<span className="text-[10px] text-muted-foreground">
#{account.id}
</span>
</div>
</Label>
</div>
)
})
)}
</ScrollArea>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,72 @@
//
// 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'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export function AttachmentFilter() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const hasAttachment = filter?.has_attachment === true
const toggleAttachment = () => {
setFilter((prev) => {
const next = { ...prev }
if (next.has_attachment) {
delete next.has_attachment
} else {
next.has_attachment = true
}
return next
})
}
return (
<Button
size="sm"
variant="outline"
onClick={toggleAttachment}
className={cn(
"h-8 px-3 gap-2 transition-all rounded-none flex-shrink-0",
hasAttachment
? "bg-primary/10 border-primary text-primary hover:bg-primary/20 hover:text-primary z-10"
: "text-muted-foreground border-r-0"
)}
>
<Paperclip
className={cn(
"h-3.5 w-3.5",
hasAttachment ? "opacity-100" : "opacity-60"
)}
/>
<span className="text-xs font-medium">
{t('mail.attachments')}
</span>
{hasAttachment && (
<Check className="h-3 w-3 ml-0.5 stroke-[3px] animate-in zoom-in duration-200" />
)}
</Button>
)
}
@@ -0,0 +1,72 @@
import {
FileText,
FileImage,
FileVideo,
FileAudio,
FileArchive,
FileCode,
FilePlus,
Presentation,
FileSpreadsheet,
FileLock
} from "lucide-react";
import { cn } from "@/lib/utils";
interface AttachmentIconProps {
contentType: string;
className?: string;
}
export function AttachmentIcon({ contentType, className }: AttachmentIconProps) {
const type = contentType.toLowerCase();
const getIconConfig = () => {
if (type.includes("pdf")) {
return { Icon: FileText, color: "text-red-600" };
}
if (type.includes("word") || type.includes("officedocument.word") || type === "application/msword") {
return { Icon: FileText, color: "text-blue-600" };
}
if (type.includes("presentation") || type.includes("powerpoint")) {
return { Icon: Presentation, color: "text-orange-600" };
}
if (type.includes("spreadsheet") || type.includes("excel") || type.includes("csv")) {
return { Icon: FileSpreadsheet, color: "text-green-600" };
}
if (type.startsWith("image/")) {
return { Icon: FileImage, color: "text-purple-600" };
}
if (type.startsWith("video/")) {
return { Icon: FileVideo, color: "text-pink-600" };
}
if (type.startsWith("audio/")) {
return { Icon: FileAudio, color: "text-amber-600" };
}
if (type.includes("zip") || type.includes("tar") || type.includes("rar") || type.includes("7z")) {
return { Icon: FileArchive, color: "text-gray-600" };
}
if (type.startsWith("text/") || type.includes("json") || type.includes("javascript") || type.includes("xml")) {
return { Icon: FileCode, color: "text-sky-600" };
}
if (type.includes("encrypted") || type.includes("pkcs")) {
return { Icon: FileLock, color: "text-yellow-700" };
}
return { Icon: FilePlus, color: "text-muted-foreground" };
};
const { Icon, color } = getIconConfig();
return (
<Icon
className={cn("h-4 w-4 shrink-0 opacity-90", color, className)}
strokeWidth={2}
/>
);
}
@@ -0,0 +1,85 @@
import * as React from "react"
import { ChevronDown } from "lucide-react"
import { useTranslation } from "react-i18next"
import { useSearchContext } from "./context"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { MetadataSelectorField } from "./attachment-metadata-selector"
import { useAttachmentMetadata } from "@/hooks/use-attachment-metadata"
interface MetaFilterProps {
type: 'extension' | 'category' | 'content_type'
icon: React.ReactNode
}
export function MetadataFilter({ type, icon }: MetaFilterProps) {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const [open, setOpen] = React.useState(false)
const { data: meta, isLoading } = useAttachmentMetadata(open)
const filterKey = `attachment_${type}` as const
const currentValue = filter[filterKey] as string
const optionsMap = {
extension: meta?.extensions || [],
category: meta?.categories || [],
content_type: meta?.content_types || []
}
const handleSelect = (value: string | undefined) => {
setFilter(prev => {
const next = { ...prev }
delete next.attachment_extension
delete next.attachment_category
delete next.attachment_content_type
if (value) {
next[filterKey] = value
}
return next
})
setOpen(false)
}
const handleReset = () => {
setFilter(prev => {
const next = { ...prev }
delete next[filterKey]
return next
})
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-6 rounded-none border-l-0 px-3 gap-1.5 transition-colors",
currentValue && "bg-primary/10 text-primary hover:bg-primary/20 border-primary/50"
)}
>
{icon}
<span className="max-w-[80px] truncate">
{currentValue || t(`search_more.${type}`)}
</span>
<ChevronDown className="h-3 w-3 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2 shadow-xl">
<MetadataSelectorField
label={t(`search_more.${type}`)}
value={currentValue || ''}
options={optionsMap[type]}
isLoading={isLoading}
onSelect={handleSelect}
onReset={handleReset}
/>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,137 @@
//
// 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: Group[]
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.key.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.key}
onSelect={() => {
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.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>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,169 @@
//
// 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 { useRef } from 'react'
import { X, TagIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from '@/components/ui/tooltip'
import { useSearchContext } from './context'
import { useTranslation } from 'react-i18next'
type MailBulkActionsProps = {
children?: React.ReactNode
}
export function AttachmentBulkActions({ children }: MailBulkActionsProps) {
const { selected, setSelected, setOpen } = useSearchContext()
const toolbarRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation()
const selectedCount = Array.from(selected.values())
.reduce((sum, set) => sum + set.size, 0)
const handleClearSelection = () => {
setSelected(new Map())
}
const handleUpdateTags = () => {
setOpen('update-tags')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
const buttons = toolbarRef.current?.querySelectorAll('button')
if (!buttons || buttons.length === 0) return
const currentIndex = Array.from(buttons).findIndex(
btn => btn === document.activeElement
)
switch (e.key) {
case 'ArrowRight': {
e.preventDefault()
const next = (currentIndex + 1) % buttons.length
buttons[next]?.focus()
break
}
case 'ArrowLeft': {
e.preventDefault()
const prev = currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
buttons[prev]?.focus()
break
}
case 'Home':
e.preventDefault()
buttons[0]?.focus()
break
case 'End':
e.preventDefault()
buttons[buttons.length - 1]?.focus()
break
case 'Escape': {
const target = e.target as HTMLElement
const active = document.activeElement as HTMLElement
const isFromDropdown =
target.closest('[data-slot="dropdown-menu-trigger"]') ||
active.closest('[data-slot="dropdown-menu-trigger"]') ||
target.closest('[data-slot="dropdown-menu-content"]') ||
active.closest('[data-slot="dropdown-menu-content"]')
if (!isFromDropdown) {
e.preventDefault()
handleClearSelection()
}
break
}
}
}
if (selectedCount === 0) return null
return (
<>
<div
ref={toolbarRef}
role="toolbar"
aria-label={t('search.bulkActions.ariaLabel', {
count: selectedCount,
})}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cn(
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
'transition-all delay-100 duration-300 ease-out hover:scale-105',
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
)}
>
<div
className={cn(
'p-2 shadow-xl rounded-xl border',
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
'flex items-center gap-x-2'
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={handleClearSelection}
className="size-6 rounded-full"
aria-label={t('search.bulkActions.clear')}
>
<X className="h-3 w-3" />
<span className="sr-only">{t('search.bulkActions.clear')}</span>
</Button>
</TooltipTrigger>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<div className="flex items-center gap-x-1 text-sm">
<Badge variant="default" className="min-w-8 rounded-lg">
{selectedCount}
</Badge>{' '}
</div>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="sm"
onClick={handleUpdateTags}
className="gap-1"
>
<TagIcon className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t('search.bulkActions.manageTags')}
</TooltipContent>
</Tooltip>
{children}
</div>
</div>
</>
)
}
@@ -0,0 +1,259 @@
//
// 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 { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Plus, Tag as TagIcon, X, Loader2, Check, AlertTriangle } from 'lucide-react';
import { useState } from 'react';
import { useAvailableTags } from '@/hooks/use-available-tags';
import { TagAction, useUpdateTags } from '@/hooks/use-update-tags';
import { toast } from '@/hooks/use-toast';
import { validateTag } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useSearchContext } from './context';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
}
export function UpdateTagsDialog({ open, onOpenChange }: Props) {
const { tags: availableTags } = useAvailableTags();
const queryClient = useQueryClient();
const { mutate, isPending } = useUpdateTags();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
const [commandOpen, setCommandOpen] = useState(false);
const [action, setAction] = useState<TagAction>('Overwrite');
const { t } = useTranslation();
const { selected } = useSearchContext()
const handleAddTag = (tag: string) => {
const normalized = tag.toLowerCase().trim();
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: t('search.updateTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
return;
}
if (normalized && !selectedTags.includes(normalized)) {
setSelectedTags(prev => [...prev, normalized]);
}
setInputValue('');
setCommandOpen(false);
};
const handleRemoveTag = (tag: string) => {
setSelectedTags(prev => prev.filter(t => t !== tag));
};
const handleSubmit = () => {
if (inputValue.trim()) {
const normalized = inputValue.toLowerCase().trim();
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: t('search.updateTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
return;
}
if (!selectedTags.includes(normalized)) {
setSelectedTags(prev => [...prev, normalized]);
}
setInputValue('');
}
const updates: Record<number, string[]> = {};
selected.forEach((tagSet, accountId) => {
updates[accountId] = Array.from(tagSet);
});
let finalTags = inputValue.trim()
? [...selectedTags, inputValue.toLowerCase().trim()]
: selectedTags;
if (finalTags.length === 0 && action !== 'Overwrite') {
return;
}
mutate(
{
updates,
tags: finalTags,
action
},
{
onSuccess: () => {
toast({
title: t('search.updateTags.updatedTitle'),
description: (
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-500" />
<span>{t('search.updateTags.updatedDesc')}</span>
</div>
),
});
queryClient.invalidateQueries({ queryKey: ['all-tags'] });
onOpenChange(false);
},
onError: (error: any) => {
toast({
title: t('search.updateTags.updateFailedTitle'),
description: error?.message || t('search.updateTags.tryAgain'),
variant: 'destructive',
});
},
}
);
};
const filteredSuggestions = availableTags.filter(
tag => !selectedTags.includes(tag) && tag.includes(inputValue.toLowerCase())
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md min-h-[50vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TagIcon className="h-5 w-5" />
{t('search.updateTags.title')}
</DialogTitle>
</DialogHeader>
<Tabs value={action} onValueChange={(v) => setAction(v as TagAction)} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="Add" className="text-xs">{t('search.updateTags.actionAdd', "Add")}</TabsTrigger>
<TabsTrigger value="Remove" className="text-xs">{t('search.updateTags.actionRemove', "remove")}</TabsTrigger>
<TabsTrigger value="Overwrite" className="text-xs">{t('search.updateTags.actionOverwrite', "overwrite")}</TabsTrigger>
</TabsList>
</Tabs>
<div className="space-y-5 py-4">
<div className="flex flex-wrap gap-2">
{selectedTags.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('search.updateTags.none')}</p>
) : (
selectedTags.map(tag => (
<Badge key={tag} variant="secondary" className="gap-1 pr-1 h-7">
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="rounded-sm hover:bg-destructive/20 hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</button>
</Badge>
))
)}
</div>
<Command shouldFilter={false} onKeyDown={(e) => e.stopPropagation()} >
<div className="space-y-2">
<div className="relative">
<CommandInput
placeholder={t('search.updateTags.searchPlaceholder')}
value={inputValue}
onValueChange={setInputValue}
onFocus={() => setCommandOpen(true)}
className="h-9 pr-10"
onKeyDown={(e) => {
if (e.key === 'Enter' && inputValue.trim()) {
e.preventDefault();
e.stopPropagation();
handleAddTag(inputValue);
}
}}
/>
{inputValue.trim() && (
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1 h-7 w-7 p-0"
onClick={() => handleAddTag(inputValue)}
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
</div>
{inputValue.trim() && filteredSuggestions.length === 0 && (
<div className="px-1 text-xs text-muted-foreground animate-in fade-in duration-200">
{t('search.updateTags.createHint', { tag: inputValue })}
</div>
)}
{commandOpen && inputValue && filteredSuggestions.length > 0 && (
<CommandList className="max-h-64 overflow-auto rounded-md border bg-popover shadow-md">
<CommandGroup>
{filteredSuggestions.map(tag => (
<CommandItem
key={tag}
onSelect={() => handleAddTag(tag)}
className="cursor-pointer"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
{tag}
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
</div>
</Command>
</div>
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">
{t('search.updateTags.selectedCount', { count: selectedTags.length })}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('search.addTags.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={isPending} variant={action === 'Remove' ? 'destructive' : 'default'}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t(`search.updateTags.saving${action}`)}
</>
) : (
t(`search.updateTags.submit${action}`)
)}
</Button>
</div>
</div>
{action == "Overwrite" && <div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-500">
<AlertTriangle className="h-5 w-5 shrink-0" />
<p className="text-xs leading-relaxed">
{t('search.updateTags.overwriteWarning')}
</p>
</div>}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,66 @@
//
// 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 from 'react'
import { SortingState } from '@tanstack/react-table'
import { AttachmentModel } from '@/api/attachment/api'
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'update-tags' | 'restore' | 'delete-mailbox'
interface SearchContextType {
open: SearchDialogType | null
setOpen: (str: SearchDialogType | null) => void
currentEnvelope: AttachmentModel | undefined
setCurrentEnvelope: React.Dispatch<React.SetStateAction<AttachmentModel | undefined>>
selected: Map<number, Set<string>>
setSelected: React.Dispatch<React.SetStateAction<Map<number, Set<string>>>>
deleteMailboxId: string | undefined
setDeleteMailboxId: React.Dispatch<React.SetStateAction<string | undefined>>
selectedAccountId: number | undefined
setSelectedAccountId: React.Dispatch<React.SetStateAction<number | undefined>>
selectedTags: string[]
sorting: SortingState
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
filter: Record<string, any>
setFilter: React.Dispatch<React.SetStateAction<Record<string, any>>>
handleTagToggle: (tag: string) => void
}
const SearchContext = React.createContext<SearchContextType | null>(null)
interface Props {
children: React.ReactNode
value: SearchContextType
}
export default function SearchProvider({ children, value }: Props) {
return <SearchContext.Provider value={value}>{children}</SearchContext.Provider>
}
export const useSearchContext = () => {
const searchContext = React.useContext(SearchContext)
if (!searchContext) {
throw new Error(
'useSearchContext has to be used within <SearchContext.Provider>'
)
}
return searchContext
}
@@ -0,0 +1,115 @@
//
// 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 { IconAlertTriangle } from '@tabler/icons-react'
import { toast } from '@/hooks/use-toast'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { delete_messages } from '@/api/mailbox/envelope/api'
import { useSearchContext } from './context'
import { mapToRecordOfArrays } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
}
export function EnvelopeDeleteDialog({ open, onOpenChange }: Props) {
const queryClient = useQueryClient()
const { toDelete, setToDelete, setSelected } = useSearchContext()
const { t } = useTranslation()
const deleteMutation = useMutation({
mutationFn: ({ payload }: { payload: Record<number, string[]> }) =>
delete_messages(payload),
retry: false,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['search-messages'], exact: false })
queryClient.invalidateQueries({ queryKey: ['all-tags'] })
onOpenChange(false)
setToDelete(new Map())
setSelected(new Map())
toast({
title: t('search.delete.successTitle'),
description: t('search.delete.successDesc'),
})
},
onError: (error: any) => {
toast({
title: t('search.delete.errorTitle'),
description: `${error.message}`,
variant: 'destructive',
})
},
})
const handleDelete = () => {
const payload = mapToRecordOfArrays(toDelete)
deleteMutation.mutate({ payload })
}
const isLoading = deleteMutation.isPending
const emailCount = Array.from(toDelete.values()).reduce(
(sum, set) => sum + set.size,
0
)
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
handleConfirm={handleDelete}
className="max-w-xl"
isLoading={isLoading}
destructive
title={
<span className="text-destructive">
<IconAlertTriangle
className="mr-1 inline-block stroke-destructive"
size={18}
/>{' '}
{t('search.delete.title')}
</span>
}
desc={
<div className="space-y-4">
<p className="mb-2">
{t('search.delete.confirmPrefix')}{' '}
<span className="font-bold">
{t('search.delete.countLabel', { count: emailCount })}
</span>
?
<br />
{t('search.delete.confirmDetail')}
</p>
<Alert variant="destructive">
<AlertTitle>{t('search.delete.warningTitle')}</AlertTitle>
<AlertDescription>
{t('search.delete.warningDesc')}
</AlertDescription>
</Alert>
</div>
}
confirmText={t('search.delete.confirmButton')}
/>
)
}
@@ -0,0 +1,105 @@
//
// 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 { IconAlertTriangle } from '@tabler/icons-react';
import { toast } from '@/hooks/use-toast';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { delete_mailbox } from '@/api/mailbox/api';
import { useSearchContext } from './context';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
const queryClient = useQueryClient();
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useSearchContext();
const { t } = useTranslation();
const deleteMutation = useMutation({
mutationFn: ({ accountId, mailboxId }: { accountId: number; mailboxId: string }) =>
delete_mailbox(accountId, mailboxId),
retry: false,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['search-mailboxes', selectedAccountId] });
onOpenChange(false);
setDeleteMailboxId(undefined);
toast({
title: t('mailbox.deleteMailboxDialog.successTitle'),
description: t('mailbox.deleteMailboxDialog.successDesc'),
});
},
onError: (error: any) => {
toast({
title: t('mailbox.deleteMailboxDialog.errorTitle'),
description: error.message || "Delete failed",
variant: 'destructive',
});
},
});
const handleDelete = () => {
if (selectedAccountId && deleteMailboxId) {
deleteMutation.mutate({
accountId: selectedAccountId,
mailboxId: deleteMailboxId
});
}
};
const isLoading = deleteMutation.isPending;
return (
<ConfirmDialog
open={open}
onOpenChange={(isOpen) => {
onOpenChange(isOpen);
if (!isOpen) setDeleteMailboxId(undefined);
}}
handleConfirm={handleDelete}
className="max-w-xl"
isLoading={isLoading}
title={
<span className="text-destructive">
<IconAlertTriangle
className="mr-1 inline-block stroke-destructive"
size={18}
/>{' '}
{t('mailbox.deleteMailboxDialog.title')}
</span>
}
desc={
<div className="space-y-4">
<p className="mb-2">
{t('mailbox.deleteMailboxDialog.desc')}
</p>
<Alert variant="destructive">
<AlertTitle>{t('mailbox.deleteMailboxDialog.warningTitle')}</AlertTitle>
<AlertDescription>{t('mailbox.deleteMailboxDialog.warningDesc')}</AlertDescription>
</Alert>
</div>
}
confirmText={t('mailbox.deleteMailboxDialog.confirm')}
destructive
/>
);
}
@@ -0,0 +1,245 @@
//
// 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 { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Plus, Tag as TagIcon, X, Loader2, Check } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useAvailableTags } from '@/hooks/use-available-tags';
import { useUpdateTags } from '@/hooks/use-update-tags';
import { toast } from '@/hooks/use-toast';
import { validateTag } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useSearchContext } from './context';
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
}
export function EditTagsDialog({ open, onOpenChange }: Props) {
const { tags: availableTags } = useAvailableTags();
const queryClient = useQueryClient();
const { mutate, isPending } = useUpdateTags();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
const [commandOpen, setCommandOpen] = useState(false);
const { t } = useTranslation();
const { currentEnvelope } = useSearchContext()
useEffect(() => {
if (open && currentEnvelope) {
setSelectedTags(currentEnvelope.tags || []);
}
}, [open, currentEnvelope]);
if (!currentEnvelope) return null;
const handleAddTag = (tag: string) => {
const normalized = tag.toLowerCase().trim();
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: t('search.addTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
return;
}
if (normalized && !selectedTags.includes(normalized)) {
setSelectedTags(prev => [...prev, normalized]);
}
setInputValue('');
setCommandOpen(false);
};
const handleRemoveTag = (tag: string) => {
setSelectedTags(prev => prev.filter(t => t !== tag));
};
const handleSave = () => {
if (inputValue.trim()) {
const normalized = inputValue.toLowerCase().trim();
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: t('search.addTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
return;
}
if (!selectedTags.includes(normalized)) {
setSelectedTags(prev => [...prev, normalized]);
}
setInputValue('');
}
const updates = {
[currentEnvelope.account_id]: [currentEnvelope.id],
};
mutate(
{
updates,
tags: inputValue.trim()
? [...selectedTags, inputValue.toLowerCase().trim()]
: selectedTags,
action: "Overwrite"
},
{
onSuccess: () => {
toast({
title: t('search.addTags.updatedTitle'),
description: (
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-500" />
<span>{t('search.addTags.updatedDesc')}</span>
</div>
),
});
queryClient.invalidateQueries({ queryKey: ['all-tags'] });
onOpenChange(false);
},
onError: (error: any) => {
toast({
title: t('search.addTags.updateFailedTitle'),
description: error?.message || t('search.addTags.tryAgain'),
variant: 'destructive',
});
},
}
);
};
const filteredSuggestions = availableTags.filter(
tag => !selectedTags.includes(tag) && tag.includes(inputValue.toLowerCase())
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TagIcon className="h-5 w-5" />
{t('search.addTags.title')}
</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-4">
<div className="flex flex-wrap gap-2">
{selectedTags.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('search.addTags.none')}</p>
) : (
selectedTags.map(tag => (
<Badge key={tag} variant="secondary" className="gap-1 pr-1 h-7">
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="rounded-sm hover:bg-destructive/20 hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</button>
</Badge>
))
)}
</div>
<Command shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="relative">
<CommandInput
placeholder={t('search.addTags.searchPlaceholder')}
value={inputValue}
onValueChange={setInputValue}
onFocus={() => setCommandOpen(true)}
className="h-9 pr-10"
onKeyDown={(e) => {
if (e.key === 'Enter' && inputValue.trim()) {
e.preventDefault();
e.stopPropagation();
handleAddTag(inputValue);
}
}}
/>
{inputValue.trim() && (
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1 h-7 w-7 p-0"
onClick={() => handleAddTag(inputValue)}
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
</div>
{inputValue.trim() && filteredSuggestions.length === 0 && (
<div className="px-1 text-xs text-muted-foreground animate-in fade-in duration-200">
{t('search.addTags.createHint', { tag: inputValue })}
</div>
)}
{commandOpen && inputValue && filteredSuggestions.length > 0 && (
<CommandList className="max-h-64 overflow-auto rounded-md border bg-popover shadow-md">
<CommandGroup>
{filteredSuggestions.map(tag => (
<CommandItem
key={tag}
onSelect={() => handleAddTag(tag)}
className="cursor-pointer"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
{tag}
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
</div>
</Command>
</div>
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">
{t('search.addTags.selectedCount', { count: selectedTags.length })}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('search.addTags.cancel')}
</Button>
<Button onClick={handleSave} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('search.addTags.saving')}
</>
) : (
t('search.addTags.save')
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,57 @@
//
// 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"
import { cn } from "@/lib/utils"
import { useTranslation } from "react-i18next";
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;
return value !== undefined && value !== null && value !== '';
}).length;
if (activeFiltersCount === 0) return null;
return (
<Button
variant="default"
size="sm"
onClick={() => setFilter(q ? { q } : {})}
className={cn(
"h-6 px-2 text-xs gap-1.5 font-normal",
"text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
)}
title={t('search_reset.tooltip')}
>
<span>{t('search_reset.label')}</span>
<div className="flex items-center justify-center w-4 h-4 rounded-full bg-muted-foreground/20 text-[10px]">
{activeFiltersCount}
</div>
<X className="h-3 w-3" />
</Button>
);
}
+170
View File
@@ -0,0 +1,170 @@
//
// 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 { Card, CardContent } from '@/components/ui/card';
import { FixedHeader } from '@/components/layout/fixed-header';
import { Main } from '@/components/layout/main';
import { AttachmentListPagination } from '@/components/pagination';
import React from 'react';
import SearchProvider, { SearchDialogType } from './context';
import useDialogState from '@/hooks/use-dialog-state';
import { useTranslation } from 'react-i18next';
import { AttachmentListTable } from './mail-list-table';
import { SortingState } from '@tanstack/react-table';
import { useSearchAttachments } from '@/hooks/use-search-attachments';
import { AttachmentModel } from '@/api/attachment/api';
export default function AttachmentSearch() {
const { t } = useTranslation()
const [selectedAttachment, setSelectedAttachment] = React.useState<AttachmentModel | undefined>(undefined);
const [open, setOpen] = useDialogState<SearchDialogType>(null)
const [selected, setSelected] = React.useState<Map<number, Set<string>>>(new Map());
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
const [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
const [selectedAccountId, setSelectedAccountId] = React.useState<number | undefined>(undefined);
const {
attachments,
total,
totalPages,
isLoading,
page,
pageSize,
setPage,
setSearchPageSize,
setSortBy,
setSortOrder,
filter,
setFilter
} = useSearchAttachments();
const handleSetPageSize = (pageSize: number) => {
setPage(1);
setSearchPageSize(pageSize)
}
const handleTagToggle = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag)
? prev.filter(t => t !== tag)
: [...prev, tag]
);
};
return (
<>
<FixedHeader />
<Main>
<SearchProvider
value={{
open,
setOpen,
currentEnvelope: selectedAttachment,
selectedTags,
setCurrentEnvelope: setSelectedAttachment,
selected,
setSelected,
sorting,
setSorting,
filter,
setFilter,
deleteMailboxId,
setDeleteMailboxId,
selectedAccountId,
setSelectedAccountId,
handleTagToggle
}}
>
<div className="mx-auto w-full px-4">
<div className="flex gap-6">
<div className="flex-1 min-w-0 space-y-4">
{isLoading && (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent"></div>
<p className="text-sm">{t('search.searching')}</p>
</div>
</CardContent>
</Card>
)}
<AttachmentListTable
isLoading={isLoading}
items={attachments}
onAttachmentChanged={(att) => {
setOpen('display');
setSelectedAttachment(att);
}}
setSortBy={setSortBy}
setSortOrder={setSortOrder}
/>
{total > 0 && <AttachmentListPagination
totalItems={total}
hasNextPage={() => page < totalPages}
pageIndex={page - 1}
pageSize={pageSize}
setPageIndex={(index) => setPage(index + 1)}
setPageSize={handleSetPageSize}
/>}
</div>
</div>
</div>
{/* <MailDisplayDrawer
key='search-mail-display'
open={open === 'display'}
onOpenChange={() => setOpen('display')}
/>
<EnvelopeDeleteDialog
key='delete-envelope'
open={open === 'delete'}
onOpenChange={() => setOpen('delete')}
/>
<EditTagsDialog
key='edit-attachment-tags-dialog'
open={open === 'edit-tags'}
onOpenChange={() => setOpen('edit-tags')}
/>
<UpdateTagsDialog
key='update-attachment-tags-dialog'
open={open === 'update-tags'}
onOpenChange={() => setOpen('update-tags')}
/>
<RestoreMessageDialog
key='restore-mail-dialog'
open={open === 'restore'}
onOpenChange={() => setOpen('restore')}
/>
<MailBoxDeleteDialog
key='mailbox-delete'
open={open === 'delete-mailbox'}
onOpenChange={() => setOpen('delete-mailbox')}
/> */}
</SearchProvider>
</Main>
</>
);
}
@@ -0,0 +1,61 @@
//
// 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 { useSearchContext } from './context'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { MailMessageView } from './mail-message-view'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
}
export function MailDisplayDrawer({ open, onOpenChange }: Props) {
const { t } = useTranslation()
const { currentEnvelope } = useSearchContext()
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<DialogContent className='w-full md:max-w-6xl mx-auto h-full'>
<DialogHeader className="p-4 pb-3 border-b shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
{t('mail.emailViewer')}
</DialogTitle>
</div>
</DialogHeader>
<ScrollArea>
<div className='m-5'>
{currentEnvelope ? (
<MailMessageView envelope={currentEnvelope} />
) : (
<div className="p-8 text-center text-muted-foreground">{t('mail.noMessageSelected')}</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>)
}
@@ -0,0 +1,296 @@
//
// 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 { dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { format, formatDistanceToNow } from "date-fns"
import { Skeleton } from "@/components/ui/skeleton"
import { Checkbox } from "@/components/ui/checkbox"
import { useSearchContext } from "./context"
import { AttachmentBulkActions } from "./bulk-actions"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
import { ColumnDef } from "@tanstack/react-table"
import LongText from "@/components/long-text"
import { DataTableColumnHeader } from "./table/data-table-column-header"
import { SearchTable } from "./table/table"
import { DataTableRowActions } from "./table/data-table-row-actions"
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { DataTableToolbar } from "./table/toolbar"
import { AttachmentModel } from "@/api/attachment/api"
import { useSearchAttachments } from "@/hooks/use-search-attachments"
import { FileIcon } from "lucide-react"
import { AttachmentIcon } from "./attachment-icon"
interface MailListProps {
items: AttachmentModel[]
isLoading: boolean
onAttachmentChanged: (attachment: AttachmentModel) => void
setSortBy: (sortBy: "DATE" | "SIZE") => void
setSortOrder: (value: "desc" | "asc") => void
}
export function AttachmentListTable({
items,
isLoading,
onAttachmentChanged,
setSortBy,
setSortOrder
}: MailListProps) {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS
const { selected, setSelected } = useSearchContext()
const columns: ColumnDef<AttachmentModel>[] = [
{
accessorKey: "id",
header: () => (
<Checkbox
checked={
totalSelected === items.length && items.length > 0
? true
: totalSelected > 0
? "indeterminate"
: false
}
onCheckedChange={handleToggleAll}
className="h-4 w-4"
/>
),
cell: ({ row }) => (
<Checkbox
checked={hasSelected(row.original.account_id, row.original.id)}
onCheckedChange={() => toggleSelected(row.original.account_id, row.original.id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0"
/>
),
meta: { className: 'text-left text-sm' },
minSize: 25,
maxSize: 25,
},
{
accessorKey: "source",
header: t('attachment.source'),
cell: ({ row }) => {
const { from, account_email, mailbox_name, account_id, mailbox_id } = row.original;
const { setFilter } = useSearchAttachments();
const accountPrefix = account_email.split('@')[0];
return (
<div className="flex flex-col py-1.5 min-w-0 group">
<div
className="cursor-pointer hover:text-primary transition-colors flex items-center gap-1.5"
onClick={(e) => {
e.stopPropagation();
setFilter((prev: any) => ({ ...prev, from: from }));
}}
>
<LongText className="text-xs truncate">
{from}
</LongText>
</div>
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/70">
<span
className="truncate max-w-[90px] hover:text-primary cursor-pointer transition-colors"
title={account_email}
onClick={(e) => {
e.stopPropagation();
setFilter((prev: any) => ({ ...prev, account_ids: [account_id], mailbox_ids: undefined }));
}}
>
{accountPrefix}
</span>
<span className="shrink-0 opacity-40">/</span>
<span
className="truncate max-w-[70px] hover:text-primary cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation();
setFilter((prev: any) => ({ ...prev, account_ids: [account_id], mailbox_ids: [mailbox_id] }));
}}
>
{mailbox_name}
</span>
</div>
</div>
);
},
meta: { className: 'text-left' }
},
{
accessorKey: "subject",
header: t('attachment.subject'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
meta: { className: 'text-left text-xs' },
minSize: 300,
maxSize: 300,
},
{
accessorKey: "name",
header: t('attachment.name'),
cell: ({ row }) => {
const { name, content_type } = row.original;
const safeName = name ?? "n/a";
const shortContentType = content_type
? content_type.split('/').pop()?.toUpperCase().replace('X-', '')
: "UNK";
return (
<div className="flex flex-col min-w-0 py-1">
<div className="flex items-center gap-2.5">
<AttachmentIcon
contentType={content_type ?? ""}
className="h-4 w-4 mt-0.5"
/>
<LongText className='text-xs font-medium max-w-[320px] text-foreground/90'>
{safeName}
</LongText>
</div>
<div className="flex items-center gap-1 ml-6.5 mt-1">
<span className="text-[10px] text-muted-foreground font-mono bg-muted px-1 py-0.5 rounded-sm">
{shortContentType}
</span>
</div>
</div>
);
},
meta: { className: 'text-left text-xs' },
},
{
accessorKey: 'size',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('attachment.size')} />
),
cell: ({ row }) => <span className='text-xs max-w-[40px]'>{formatBytes(row.original.size)}</span>,
meta: { className: 'text-left text-xs' },
minSize: 80,
maxSize: 80,
},
{
accessorKey: 'date',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('attachment.date')} />
),
cell: ({ row }) => {
const date = new Date(row.original.date)
const title = format(date, 'yyyy-MM-dd HH:mm:ss')
return (
<Tooltip>
<TooltipTrigger asChild>
<span className='text-xs whitespace-nowrap'>
{formatDistanceToNow(date, { addSuffix: true, locale })}
</span>
</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
)
},
meta: { className: 'text-left text-xs' },
minSize: 100,
maxSize: 100,
},
{
id: 'actions',
header: t('users.columns.actions'),
cell: DataTableRowActions,
meta: { className: 'text-left text-xs' },
minSize: 60,
maxSize: 60,
},
]
const handleToggleAll = () => {
const total = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
if (total === items.length && items.length > 0) {
setSelected(new Map())
} else {
setSelected(prev => {
const next = new Map(prev)
for (const item of items) {
const set = new Set(next.get(item.account_id) || [])
set.add(item.id)
next.set(item.account_id, set)
}
return next
})
}
}
const toggleSelected = (accountId: number, mailId: string) => {
setSelected(prev => {
const next = new Map(prev)
const set = new Set(next.get(accountId) || [])
if (set.has(mailId)) {
set.delete(mailId)
if (set.size === 0) next.delete(accountId)
else next.set(accountId, set)
} else {
set.add(mailId)
next.set(accountId, set)
}
return next
})
}
const totalSelected = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0)
const hasSelected = (accountId: number, mailId: string) => selected.get(accountId)?.has(mailId) ?? false
if (isLoading) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="h-3 w-3" />
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-2.5 w-16" />
</div>
))}
</div>
)
}
return (
<>
<SearchTable
data={items}
columns={columns}
onRowClick={(e, row) => {
const target = e.target as HTMLElement
if (target.closest('input[type="checkbox"], button')) return
onAttachmentChanged(row.original)
}}
setSortBy={setSortBy}
setSortOrder={setSortOrder}
>
{(table) => {
return <DataTableToolbar table={table} />
}}
</SearchTable>
{totalSelected > 0 && <AttachmentBulkActions />}
</>
)
}
+278
View File
@@ -0,0 +1,278 @@
//
// 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 { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { formatDistanceToNow } from "date-fns"
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { Checkbox } from "@/components/ui/checkbox"
import { EmailEnvelope } from "@/api"
import { useSearchContext } from "./context"
import { AttachmentBulkActions } from "./bulk-actions"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
interface MailListProps {
items: EmailEnvelope[]
isLoading: boolean
onEnvelopeChanged: (envelope: EmailEnvelope) => void
}
export function MailList({
items,
isLoading,
onEnvelopeChanged
}: MailListProps) {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const { setOpen, currentEnvelope, setCurrentEnvelope, selected, setSelected, setToDelete } = useSearchContext()
const handleToggleAll = () => {
const total = Array.from(selected.values())
.reduce((sum, set) => sum + set.size, 0);
if (total === items.length && items.length > 0) {
setSelected(new Map());
} else {
setSelected(prev => {
const next = new Map(prev);
for (const item of items) {
const set = new Set(next.get(item.account_id) || []);
set.add(item.id);
next.set(item.account_id, set);
}
return next;
});
}
}
const toggleToDelete = (accountId: number, mailId: string) => {
setToDelete(prev => {
const next = new Map(prev);
const set = new Set(next.get(accountId) || []);
if (set.has(mailId)) {
set.delete(mailId);
if (set.size === 0) next.delete(accountId);
else next.set(accountId, set);
} else {
set.add(mailId);
next.set(accountId, set);
}
return next;
});
};
const toggleSelected = (accountId: number, mailId: string) => {
setSelected(prev => {
const next = new Map(prev);
const set = new Set(next.get(accountId) || []);
if (set.has(mailId)) {
set.delete(mailId);
if (set.size === 0) next.delete(accountId);
else next.set(accountId, set);
} else {
set.add(mailId);
next.set(accountId, set);
}
return next;
});
}
const totalSelected = Array.from(selected.values())
.reduce((sum, set) => sum + set.size, 0);
const hasSelected = (accountId: number, mailId: string) => {
return selected.get(accountId)?.has(mailId) ?? false;
}
const handleDelete = (envelope: EmailEnvelope) => {
setToDelete(new Map());
toggleToDelete(envelope.account_id, envelope.id)
setOpen("delete")
}
if (isLoading) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="h-3 w-3" />
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-2.5 w-16" />
</div>
))}
</div>
)
}
return (
<div className="divide-y divide-border">
{items.length > 0 && (
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
<Checkbox
checked={
totalSelected === items.length && items.length > 0
? true
: totalSelected > 0
? "indeterminate"
: false
}
onCheckedChange={handleToggleAll}
className="h-4 w-4"
/>
<span className="text-xs text-muted-foreground">
{totalSelected > 0
? `${t('search.bulkActions.selected', { count: totalSelected })}`
: t('common.selectAll')}
</span>
</div>
)}
{items.map((item, index) => {
const hasAttachments = item.regular_attachment_count > 0
const isSelectedRow = currentEnvelope?.id === item.id
const isChecked = hasSelected(item.account_id, item.id)
return (
<div
key={index}
className={cn(
"flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
"hover:bg-accent/50",
isSelectedRow && "bg-accent"
)}
onClick={(e) => {
const target = e.target as HTMLElement
if (target.closest('input[type="checkbox"], button')) return
onEnvelopeChanged(item)
}}
>
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleSelected(item.account_id, item.id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0"
/>
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-0.5">
<div className="flex items-center gap-1 min-w-0">
<p className="text-sm font-medium truncate">{item.from}</p>
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
{item.subject}
</h3>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/60">
<span className="truncate">{item.account_email}</span>
<span className="scale-75 opacity-50"></span>
<span className="font-medium text-primary/70">{item.mailbox_name}</span>
</div>
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
{item.subject}
</h3>
<div className="flex flex-wrap gap-1 mt-0.25">
{item.tags?.map((tag, i) => (
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={i}>{tag}</Badge>
))}
</div>
</div>
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
{hasAttachments && (
<div className="flex items-center gap-1">
<Paperclip className="h-3 w-3" />
<span>{item.regular_attachment_count}</span>
</div>
)}
<span className="hidden md:inline">{formatBytes(item.size)}</span>
<span className={cn(isSelectedRow ? "text-foreground font-medium" : "text-muted-foreground")}>
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
setCurrentEnvelope(item);
setOpen("edit-tags");
}}
>
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('search.editTag')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
setCurrentEnvelope(item);
setOpen("restore");
}}
>
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('restore_message.restore_to_imap')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
handleDelete(item);
}}
>
<Trash2 className="ml-2 h-3.5 w-3.5" />
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
})}
{totalSelected > 0 && <AttachmentBulkActions />}
</div>
)
}
@@ -0,0 +1,432 @@
//
// 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 { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/hooks/use-toast';
import { formatBytes } from '@/lib/utils';
import EmailIframe from '@/components/mail-iframe';
import {
AttachmentInfo,
download_attachment,
download_message,
getContent,
load_message,
} from '@/api/mailbox/envelope/api';
import { AxiosError } from 'axios';
import { useSearchContext } from './context';
import { MailThreadDialog } from './thread-dialog';
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
import { useTranslation } from 'react-i18next';
import { NestedEmailDialog } from './nested-email-dialog';
interface MailMessageViewProps {
envelope: {
id: string;
account_id: number,
from?: string;
to?: string[];
cc?: string[];
bcc?: string[];
subject?: string;
internal_date?: number;
};
showActions?: boolean;
showHeader?: boolean;
showAttachments?: boolean;
}
const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines }) => {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false);
return (
<div className="text-xs">
<div className="flex items-start space-x-2">
<span className="font-medium text-gray-400 whitespace-nowrap">{title}:</span>
<div className="flex-1">
<ul className="list-disc list-inside">
{lines.slice(0, expanded ? lines.length : 3).map((ref, i) => (
<li key={i} className="line-clamp-1">{ref}</li>
))}
</ul>
{lines.length > 3 && (
<button
className="text-blue-500 hover:underline text-xs"
onClick={() => setExpanded(!expanded)}
>
{expanded ? t('common.showLess') : t('common.showMore')}
</button>
)}
</div>
</div>
</div>
);
};
export const getFileConfig = (mimeType: string) => {
const type = mimeType.toLowerCase();
if (type.includes('pdf')) {
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
}
if (type.includes('image/')) {
return { icon: <FileImage className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50 border-blue-100' };
}
if (type.includes('audio/')) {
return { icon: <FileAudio className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50 border-purple-100' };
}
if (type.includes('video/')) {
return { icon: <FileVideo className="h-4 w-4" />, color: 'text-indigo-600 bg-indigo-50 border-indigo-100' };
}
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {
return { icon: <FileSpreadsheet className="h-4 w-4" />, color: 'text-green-600 bg-green-50 border-green-100' };
}
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) {
return { icon: <FileArchive className="h-4 w-4" />, color: 'text-orange-600 bg-orange-50 border-orange-100' };
}
if (type.includes('text/') || type.includes('json') || type.includes('javascript')) {
return { icon: <FileCode className="h-4 w-4" />, color: 'text-slate-600 bg-slate-50 border-slate-100' };
}
return { icon: <FileIcon className="h-4 w-4" />, color: 'text-gray-600 bg-gray-50 border-gray-100' };
};
export function MailMessageView({
envelope,
showActions = true,
showAttachments = true,
showHeader = true
}: MailMessageViewProps) {
const { t } = useTranslation()
const { setToDelete, setOpen, setSelected } = useSearchContext();
const [content, setContent] = useState<string | null>(null);
const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null);
const [attachments, setAttachments] = useState<AttachmentInfo[] | null>(null);
const [loading, setLoading] = useState(true);
const [downloadingAttachmentFileName, setDownloadingAttachmentFileName] = useState<string | null>(null);
const [nestedEmlFile, setNestedEmlFile] = useState<AttachmentInfo | null>(null);
const { getEmailById } = useMinimalAccountList();
const [threadOpen, setThreadOpen] = useState(false);
const downloadAttachmentMutation = useMutation({
mutationFn: ({ content_hash }: { content_hash: string }) =>
download_attachment(envelope.account_id, envelope.id, content_hash, downloadingAttachmentFileName!),
onSuccess: () => setDownloadingAttachmentFileName(null),
onError: (error: any) => {
setDownloadingAttachmentFileName(null);
toast({
title: t('mail.failedToDownloadFile'),
description: error.message,
variant: 'destructive',
});
},
});
const loadMessageMutation = useMutation({
mutationFn: () => load_message(envelope.account_id, envelope.id),
onSuccess: (data) => {
setLoading(false);
setContent(getContent(data));
if (data.attachments) setAttachments(data.attachments);
setContentType(data.html ? 'Html' : 'Plain');
},
onError: (error: any) => {
setLoading(false);
toast({
title: t('mail.failedToLoadEmail'),
description: error.message,
variant: 'destructive',
});
},
});
useEffect(() => {
setLoading(true);
loadMessageMutation.mutate();
}, [envelope.id]);
const handleViewNestedEml = (attachment: AttachmentInfo) => {
setNestedEmlFile(attachment);
};
const toggleToDelete = (accountId: number, mailId: string) => {
setToDelete(prev => {
const next = new Map(prev);
const set = new Set(next.get(accountId) || []);
if (set.has(mailId)) {
set.delete(mailId);
if (set.size === 0) next.delete(accountId);
else next.set(accountId, set);
} else {
set.add(mailId);
next.set(accountId, set);
}
return next;
});
};
const handleDelete = () => {
if (envelope) {
toggleToDelete(envelope.account_id, envelope.id)
setOpen("delete")
}
}
const downloadEmlFile = async () => {
try {
toast({ title: t('mail.downloadStarted'), description: t('mail.isBeingDownloaded', { id: envelope.id }) });
await download_message(envelope.account_id, envelope.id);
toast({ title: t('mail.downloadComplete'), description: t('mail.downloaded', { id: envelope.id }) });
} catch (error) {
let msg = t('mail.downloadFailed');
if (error instanceof AxiosError) {
msg = error.response?.data?.message || error.response?.data?.error || error.message;
if (error.response?.status) msg = `${error.response.status}: ${msg}`;
} else if (error instanceof Error) {
msg = error.message;
}
toast({ title: t('mail.downloadFailed'), description: msg, variant: 'destructive' });
}
};
return (
<div className="flex flex-col h-full">
{showHeader && <div className="grid gap-1 text-xs">
<div className="flex space-x-2">
<span className="font-medium text-gray-400">{t('mail.account')}:</span>
<span>{getEmailById(envelope.account_id)}</span>
</div>
<div className="flex space-x-2">
<span className="font-medium text-gray-400">{t('mail.id')}:</span>
<span>{envelope.id}</span>
</div>
{envelope.from && (
<div className="flex space-x-2">
<span className="font-medium text-gray-400">{t('mail.from')}:</span>
<span>{envelope.from}</span>
</div>
)}
{envelope.to && envelope.to.length > 0 && <Multilines title={t('mail.to')} lines={envelope.to} />}
{envelope.cc && envelope.cc.length > 0 && <Multilines title={t('mail.cc')} lines={envelope.cc} />}
{envelope.bcc && envelope.bcc.length > 0 && <Multilines title={t('mail.bcc')} lines={envelope.bcc} />}
{envelope.subject && (
<div className="flex space-x-2">
<span className="font-medium text-gray-400">{t('mail.subject')}:</span>
<span>{envelope.subject}</span>
</div>
)}
{envelope.internal_date && (
<div className="flex space-x-2">
<span className="font-medium text-gray-400">{t('mail.date')}:</span>
<span>{formatTimestamp(envelope.internal_date)}</span>
</div>
)}
</div>}
{showActions && (
<>
<div className="flex items-center mt-2 space-x-2">
<Separator orientation="horizontal" className="flex-1 bg-border" />
</div>
<div className="flex items-center justify-start gap-3 text-xs text-gray-500">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleDelete} className="hover:text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('mail.delete')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={downloadEmlFile}>
<Download className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('mail.download')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setThreadOpen(true)}
>
<MessageSquareMore className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('mail.viewThread')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
setSelected(new Map())
setOpen('restore')
}}
>
<Upload className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('restore_message.restore_to_imap', 'Restore Mail')}</TooltipContent>
</Tooltip>
</div>
</>
)}
{showAttachments && <Separator className="my-2" />}
{showAttachments && (
<div className="mb-2">
{loading ? (
<span className="text-gray-500 text-xs" />
) : attachments && attachments.length > 0 ? (
(() => {
const nonInline = attachments.filter((a) => !a.inline);
return nonInline.length > 0 ? (
<div className="space-y-2">
{nonInline.map((attachment, i) => {
const { icon, color } = getFileConfig(attachment.file_type);
const is_message = attachment.is_message;
return <div key={i} className="flex items-center">
<div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
<div className={`flex-shrink-0 ${color}`}>
{icon}
</div>
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
<span
className="truncate text-xs font-medium text-foreground/90"
title={attachment.filename}
>
{attachment.filename}
</span>
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase">
{attachment.file_type.split('/').pop()}
</span>
</div>
</div>
<div className="flex items-center space-x-3 ml-auto pr-1">
{is_message && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-orange-600 hover:text-orange-700 hover:bg-orange-50"
onClick={() => {
handleViewNestedEml(attachment);
}}
>
<MessageSquareMore className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('mail.viewNestedEmail', 'View Embedded Email')}</TooltipContent>
</Tooltip>
)}
<span className="text-gray-500 text-xs shrink-0">
{formatBytes(attachment.size)}
</span>
{downloadingAttachmentFileName === attachment.filename ? (
<Loader className="w-4 h-4 animate-spin" />
) : (
<Download
className="w-4 h-4 cursor-pointer"
onClick={() => {
setDownloadingAttachmentFileName(attachment.filename);
downloadAttachmentMutation.mutate({ content_hash: attachment.content_hash });
}}
/>
)}
</div>
</div>
})}
</div>
) : (
<span className="text-gray-500 text-xs italic">
{t('mail.onlyNonInlineAttachments')}
</span>
);
})()
) : (
<span className="text-gray-500 text-xs">{t('mail.noAttachments')}</span>
)}
</div>
)}
{showAttachments && <Separator className="mb-2" />}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex justify-center items-center py-8">
<Loader className="w-6 h-6 animate-spin" />
<span className="ml-2 text-sm text-muted-foreground">loading...</span>
</div>
) : content ? (
<div className="bg-gray-100 rounded-lg border border-gray-300 p-4">
{contentType === 'Html' ? (
<EmailIframe emailHtml={content} />
) : (
<pre className="whitespace-pre-wrap text-gray-800 text-sm font-sans">{content}</pre>
)}
</div>
) : (
<div className="text-center text-muted-foreground text-sm">No content available</div>
)}
</div>
<MailThreadDialog open={threadOpen} onOpenChange={setThreadOpen} />
<NestedEmailDialog
open={!!nestedEmlFile}
onOpenChange={(open: boolean) => !open && setNestedEmlFile(null)}
accountId={envelope.account_id}
envelopeId={envelope.id}
fileName={nestedEmlFile?.filename || ''}
content_hash={nestedEmlFile?.content_hash}
/>
</div>
);
}
export function formatTimestamp(milliseconds: number): string {
const date = new Date(milliseconds);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetSign = timezoneOffset > 0 ? '-' : '+';
const offsetHours = String(Math.floor(Math.abs(timezoneOffset) / 60)).padStart(2, '0');
const offsetMinutes = String(Math.abs(timezoneOffset) % 60).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMinutes}`;
}
@@ -0,0 +1,401 @@
//
// 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,
MoreVertical, Trash2, Search,
Check
} from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { animated, useSpring } from '@react-spring/web';
import { styled } from '@mui/material/styles';
import Collapse from '@mui/material/Collapse';
import { TransitionProps } from '@mui/material/transitions';
import {
TreeItemCheckbox,
TreeItemContent,
TreeItemDragAndDropOverlay,
TreeItemIcon,
TreeItemIconContainer,
TreeItemLabel,
TreeItemProvider,
TreeItemRoot,
useTreeItemModel,
} from '@mui/x-tree-view';
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
import { useTreeItem, UseTreeItemParameters } from '@mui/x-tree-view/useTreeItem';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { list_mailboxes } from '@/api/mailbox/api';
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
import { useSearchContext } from './context';
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree';
const CustomCollapse = styled(Collapse)({ padding: 0 });
const AnimatedCollapse = animated(CustomCollapse);
function TransitionComponent(props: TransitionProps) {
const style = useSpring({
to: {
opacity: props.in ? 1 : 0,
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
},
});
return <AnimatedCollapse style={style} {...props} />;
}
interface CustomTreeItemProps
extends Omit<UseTreeItemParameters, 'rootRef'>,
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
interface CustomLabelProps {
exists?: number;
attributes?: { attr: string; extension: string | null }[],
children: React.ReactNode;
id: string;
icon?: React.ElementType;
expandable?: boolean;
onDelete: (id: string) => void;
}
function CustomLabel({
expandable,
exists,
attributes,
children,
id,
onDelete,
...other
}: CustomLabelProps) {
const { t } = useTranslation()
return (
<TreeItemLabel
{...other}
sx={{
display: 'flex',
alignItems: 'center',
}}
>
<FolderIcon className="mr-2 h-3.5 w-3.5" />
<span className="font-medium text-xs text-inherit">
{children}
</span>
<div className="ml-auto flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-24">
<DropdownMenuItem
className="text-destructive focus:text-destructive flex items-center px-2 py-1 text-[11px] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
}}
onSelect={(e) => {
e.preventDefault();
onDelete(id);
}}
>
<Trash2 className="mr-1 h-3 w-3" />
<span>{t('common.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TreeItemLabel>
);
}
export function MailboxPopover() {
const { t } = useTranslation();
const { filter, setFilter, setOpen, setDeleteMailboxId, setSelectedAccountId } = useSearchContext();
const { minimalList = [] } = useMinimalAccountList();
const [localOpen, setLocalOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const accountIds: number[] = filter.account_ids ?? [];
const selectedMailboxIds: number[] = filter.mailbox_ids ?? [];
const [localSelectedIds, setLocalSelectedIds] = React.useState<number[]>([]);
const [activeAccountId, setActiveAccountId] = React.useState<number | undefined>(undefined);
const queryClient = useQueryClient();
React.useEffect(() => {
if (localOpen) {
const globalMailboxIds = filter.mailbox_ids ?? [];
setLocalSelectedIds(globalMailboxIds);
const currentAccountIds = filter.account_ids ?? [];
if (currentAccountIds.length > 0) {
if (!activeAccountId || !currentAccountIds.includes(activeAccountId)) {
setActiveAccountId(currentAccountIds[0]);
}
} else {
setActiveAccountId(undefined);
}
}
}, [localOpen, activeAccountId, filter.account_ids, filter.mailbox_ids]);
const { data: activeMailboxes = [], isLoading: activeIsLoading } = useQuery({
queryKey: ['search-mailboxes', activeAccountId],
queryFn: () => list_mailboxes(activeAccountId!, false),
enabled: !!activeAccountId,
});
const treeData = React.useMemo(() => {
const filtered = search.trim()
? activeMailboxes.filter(m => m.name.toLowerCase().includes(search.toLowerCase()))
: activeMailboxes;
return buildTree(filtered);
}, [activeMailboxes, search]);
const disabled = accountIds.length === 0;
const handleApply = () => {
setFilter(prev => ({
...prev,
mailbox_ids: localSelectedIds.length > 0 ? localSelectedIds : undefined
}));
setLocalOpen(false);
};
const handleDeleteClick = (id: string) => {
setDeleteMailboxId(id);
setSelectedAccountId(activeAccountId);
setOpen('delete-mailbox');
};
const CustomTreeItem = React.forwardRef(function CustomTreeItem(
props: CustomTreeItemProps,
ref: React.Ref<HTMLLIElement>,
) {
const { id, itemId, label, disabled, children, ...other } = props;
const {
getContextProviderProps,
getRootProps,
getContentProps,
getLabelProps,
getIconContainerProps,
getCheckboxProps,
getGroupTransitionProps,
getDragAndDropOverlayProps,
status,
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
return (
<TreeItemProvider {...getContextProviderProps()}>
<TreeItemRoot {...getRootProps(other)} className="group">
<TreeItemContent {...getContentProps()} sx={{ paddingY: '2px' }}>
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIcon status={status} />
</TreeItemIconContainer>
<TreeItemCheckbox {...getCheckboxProps()} sx={{
color: 'hsl(var(--muted-foreground) / 0.4)',
'&.Mui-checked': {
color: 'hsl(var(--primary))',
},
'& .MuiSvgIcon-root': {
fontSize: '1.3rem'
}
}} />
<CustomLabel
{...getLabelProps({
exists: item.exists,
id: item.id,
onDelete: handleDeleteClick,
attributes: item.attributes,
expandable: status.expandable && status.expanded,
})}
/>
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
</TreeItemContent>
{children && <TransitionComponent {...getGroupTransitionProps()} />}
</TreeItemRoot>
</TreeItemProvider>
);
});
return (
<Popover open={localOpen} onOpenChange={setLocalOpen} >
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
disabled={disabled}
className={cn(
'h-6 rounded-none border-l-0 px-3 gap-1.5 transition-colors',
selectedMailboxIds.length > 0 && 'bg-primary/10 text-primary border-primary/20'
)}
>
<Folders className="h-4 w-4" />
<span className="max-w-[100px] truncate">{t('search_mailbox.label')}</span>
{selectedMailboxIds.length > 0 && (
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{selectedMailboxIds.length}
</span>
)}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[740px] max-w-[95vw] p-0 flex flex-col h-[480px] shadow-xl border-muted"
>
<div className="flex items-center gap-2 p-2 border-b bg-muted/10">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('search_mailbox.search_placeholder')}
className="h-9 pl-8 text-xs bg-background"
/>
</div>
{localSelectedIds.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setLocalSelectedIds([])}
className="h-9 text-xs text-destructive hover:bg-destructive/10"
>
<X className="mr-1.5 h-3 w-3" />
{t('common.clear')}
</Button>
)}
</div>
<div className="flex flex-1 min-h-0">
<div className="w-64 border-r bg-muted/20 flex flex-col">
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{accountIds.map(id => {
const acc = minimalList.find(a => a.id === id);
const isActive = activeAccountId === id;
const cachedData = queryClient.getQueryData<any[]>(['search-mailboxes', id]);
const count = cachedData?.filter(m => localSelectedIds.includes(m.id)).length ?? 0;
return (
<button
key={id}
onClick={() => setActiveAccountId(id)}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-left rounded-md transition-all",
isActive
? "bg-background shadow-sm text-primary ring-1 ring-black/5"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
<span className="text-xs truncate font-medium">
{acc?.email}
</span>
{count > 0 && (
<span className="text-[10px] font-bold bg-primary/10 px-1.5 py-0.5 rounded-full">
{count}
</span>
)}
</button>
);
})}
</div>
</ScrollArea>
</div>
<div className="flex-1 flex flex-col bg-background">
<ScrollArea className="flex-1">
<div className="p-3">
{activeIsLoading ? (
<div className="p-4 space-y-4">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-3 bg-muted animate-pulse rounded w-full" />
))}
</div>
) : activeAccountId ? (
<RichTreeView
multiSelect
items={treeData}
checkboxSelection
expansionTrigger="iconContainer"
selectedItems={localSelectedIds.map(String)}
onSelectedItemsChange={(_, itemIds) => {
setLocalSelectedIds(itemIds.map(id => parseInt(id)).filter(id => !isNaN(id)));
}}
slots={{ item: CustomTreeItem }}
sx={{ width: '100%' }}
/>
) : (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground opacity-40">
<TreeDeciduous className="h-12 w-12 mb-2 stroke-[1px]" />
<p className="text-xs">{t('search_mailbox.select_account_tip')}</p>
</div>
)}
</div>
</ScrollArea>
<div className="p-3 border-t bg-muted/10 flex items-center justify-between">
<div className="text-[10px] text-muted-foreground font-medium">
{t('search_mailbox.selected_total')}: <span className="text-foreground">{localSelectedIds.length}</span>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => setLocalOpen(false)} className="h-8 px-3 text-xs">
{t('common.cancel')}
</Button>
<Button size="sm" onClick={handleApply} className="h-8 px-4 text-xs gap-1.5 shadow-sm">
<Check className="h-3.5 w-3.5" />
{t('common.apply')}
</Button>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover >
);
}
@@ -0,0 +1,189 @@
//
// 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { Info, ListFilter } from "lucide-react"
import { useTranslation } from "react-i18next"
import { useSearchContext } from "./context"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
const SIZES = {
tiny: { min: undefined, max: 15 * 1024 },
small: { min: undefined, max: 2 * 1024 * 1024 },
medium: { min: 2 * 1024 * 1024, max: 10 * 1024 * 1024 },
large: { min: 10 * 1024 * 1024, max: 20 * 1024 * 1024 },
huge: { min: 20 * 1024 * 1024, max: undefined },
};
const getPresetFromSize = (min?: number, max?: number) => {
if (min === SIZES.huge.min) return 'huge';
if (min === SIZES.large.min && max === SIZES.large.max) return 'large';
if (min === SIZES.medium.min && max === SIZES.medium.max) return 'medium';
if (!min && max === SIZES.small.max) return 'small';
if (!min && max === SIZES.tiny.max) return 'tiny';
return 'any';
};
export function MoreFiltersPopover() {
const { t } = useTranslation();
const { filter, setFilter } = useSearchContext();
const [open, setOpen] = React.useState(false);
const [localState, setLocalState] = React.useState({
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
is_message: filter?.is_message || false
});
React.useEffect(() => {
if (open) {
setLocalState({
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
is_message: filter?.is_message || false
});
}
}, [open, filter]);
const handleApply = () => {
setFilter(prev => {
const next = { ...prev };
if (localState.is_message) next.is_message = true;
else delete next.is_message;
const range = SIZES[localState.size_preset as keyof typeof SIZES] || { min: undefined, max: undefined };
if (range.min) next.min_size = range.min; else delete next.min_size;
if (range.max) next.max_size = range.max; else delete next.max_size;
return next;
});
setOpen(false);
};
const activeCount = [
filter?.min_size,
filter?.max_size,
filter?.is_message,
].filter(Boolean).length;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-6 gap-2 px-3 rounded-none border-l-0",
activeCount > 0 && "bg-primary/10 border-primary text-primary"
)}
>
<ListFilter className="h-3.5 w-3.5" />
<span className="text-xs">{t('search_more.trigger_label')}</span>
{activeCount > 0 && (
<Badge className="ml-1 h-4 px-1 text-[10px] bg-primary text-primary-foreground border-none rounded-sm">
{activeCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-4 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium">{t('search_more.title')}</h4>
{activeCount > 0 && (
<Button
variant="ghost"
className="h-auto p-0 text-[10px] text-muted-foreground hover:text-destructive"
onClick={() => {
setFilter(prev => {
const next = { ...prev };
delete next.min_size;
delete next.max_size;
delete next.is_message;
return next;
});
setOpen(false);
}}
>
{t('search_more.reset')}
</Button>
)}
</div>
<Separator />
<div className="flex items-center space-x-2 px-1">
<Checkbox
id="is_message"
checked={localState.is_message}
onCheckedChange={(checked) => {
const isChecked = checked as boolean;
setLocalState(prev => ({
...prev,
is_message: isChecked
}));
}}
/>
<Label
htmlFor="is_message"
className="text-xs font-normal cursor-pointer select-none"
>
{t('search_more.is_message')}
</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3 h-3 ml-1.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs">{t('search_more.is_message_desc')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('attachment.size')}</Label>
<Select
value={localState.size_preset}
onValueChange={(v) => setLocalState(prev => ({ ...prev, size_preset: v }))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(SIZES).concat('any').map((key) => (
<SelectItem key={key} className="text-xs" value={key}>
{t(`search_more.size_presets.${key}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button size="sm" className="w-full h-8 text-xs mt-2" onClick={handleApply}>
{t('search_more.apply')}
</Button>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,191 @@
//
// 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';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { formatBytes, formatTimestamp } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query';
import { Download, Loader, Mail } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getFileConfig } from './mail-message-view';
const MessageHeader = ({
envelope,
attachments,
onDownload
}: {
envelope: EmailEnvelope,
attachments?: AttachmentInfo[],
onDownload: (nested_content_hash: string) => void
}) => {
const { t } = useTranslation();
const displayAttachments = attachments || [];
return (
<div className="space-y-4 mb-4 bg-white p-5 rounded-xl border shadow-sm">
<div className="space-y-1">
<h1 className="text-lg font-bold text-slate-900 leading-snug">
{envelope.subject || `(${t('mail.noSubject')})`}
</h1>
<div className="text-[11px] text-slate-400">
{formatTimestamp(envelope.date)}
</div>
</div>
<Separator className="opacity-50" />
<div className="grid grid-cols-1 gap-y-3">
{/* From */}
<div className="flex items-baseline gap-2">
<span className="w-12 text-[10px] font-bold uppercase text-slate-400 shrink-0">
{t('mail.from')}
</span>
<span className="text-sm font-medium text-slate-700 truncate">
{envelope.from}
</span>
</div>
{envelope.to && envelope.to.length > 0 && (
<div className="flex items-baseline gap-2">
<span className="w-12 text-[10px] font-bold uppercase text-slate-400 shrink-0">
{t('mail.to')}
</span>
<div className="flex flex-wrap gap-x-2 gap-y-1">
{envelope.to.map((addr, i) => (
<span key={i} className="text-sm text-slate-600">
{addr}{i < envelope.to.length - 1 ? ',' : ''}
</span>
))}
</div>
</div>
)}
{envelope.cc && envelope.cc.length > 0 && (
<div className="flex items-baseline gap-2">
<span className="w-12 text-[10px] font-bold uppercase text-slate-400 shrink-0">
{t('mail.cc')}
</span>
<div className="flex flex-wrap gap-x-2 gap-y-1 text-slate-500 italic">
{envelope.cc.map((addr, i) => (
<span key={i} className="text-xs">
{addr}{i < envelope.cc.length - 1 ? ',' : ''}
</span>
))}
</div>
</div>
)}
{envelope.bcc && envelope.bcc.length > 0 && (
<div className="flex items-baseline gap-2">
<span className="w-12 text-[10px] font-bold uppercase text-slate-400 shrink-0">
{t('mail.bcc')}
</span>
<div className="flex flex-wrap gap-x-2 gap-y-1 text-slate-500 italic">
{envelope.bcc.map((addr, i) => (
<span key={i} className="text-xs">
{addr}{i < envelope.bcc.length - 1 ? ',' : ''}
</span>
))}
</div>
</div>
)}
</div>
{displayAttachments.length > 0 && (
<div className="pt-2 border-t border-dashed">
<div className="flex flex-wrap gap-2">
{displayAttachments.map((att, i) => {
const { icon, color } = getFileConfig(att.file_type);
return (
<Tooltip key={i}>
<TooltipTrigger asChild>
<button
onClick={() => onDownload(att.content_hash)}
className="group flex items-center gap-2 px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg hover:bg-blue-50 hover:border-blue-200 transition-all text-slate-600 hover:text-blue-700"
>
<span className={`${color} p-0.5 rounded`}>{icon}</span>
<span className="text-xs font-medium truncate max-w-[180px]">
{att.filename}
</span>
<span className="text-[9px] text-slate-400 group-hover:text-blue-400">
({formatBytes(att.size)})
</span>
<Download className="h-3 w-3 ml-1 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
</TooltipTrigger>
<TooltipContent>{t('mail.clickToDownload')}</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
)}
</div>
);
};
export function NestedEmailDialog({ open, onOpenChange, accountId, envelopeId, fileName, content_hash }: any) {
const { data, isLoading } = useQuery({
queryKey: ['nested-message', accountId, envelopeId, content_hash],
queryFn: () => load_nested_message(accountId, envelopeId, content_hash),
enabled: open && !!content_hash,
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 overflow-hidden border-none shadow-2xl">
<div className="text-white px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium truncate max-w-[400px] opacity-90">{fileName}</span>
</div>
</div>
<div className="flex-1 overflow-auto bg-white p-8">
{isLoading ? (
<div className="h-full flex items-center justify-center"><Loader className="animate-spin" /></div>
) : data && (
<div className="max-w-3xl mx-auto">
<MessageHeader
envelope={data.envelope}
attachments={data.attachments}
onDownload={(nested_content_hash) => download_nested_attachment(accountId, envelopeId, content_hash, nested_content_hash)}
/>
<div className="mt-8 pt-8 border-t border-slate-100">
{data.html ? (
<EmailIframe emailHtml={data.html} />
) : (
<pre className="whitespace-pre-wrap font-sans text-sm text-slate-800 leading-relaxed">
{data.text}
</pre>
)}
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,186 @@
//
// 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 { restore_message } from '@/api/mailbox/envelope/api'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { toast } from '@/hooks/use-toast'
import { useMutation } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
import { ToastAction } from '@/components/ui/toast'
import { useSearchContext } from './context'
import { EmailEnvelope } from '@/api'
function MessageSummary({ envelope, t }: { envelope: EmailEnvelope, t: (key: string) => string }) {
return (
<div className="mt-3 rounded-md border bg-muted/20 p-3 text-sm overflow-hidden">
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1.5">
<span className="font-medium text-muted-foreground">{t("mail.subject")}:</span>
<div className="break-words font-medium">
{envelope.subject || <em className="italic opacity-70">(No subject)</em>}
</div>
<span className="font-medium text-muted-foreground">{t("mail.from")}:</span>
<div className="break-all text-foreground/90">
{envelope.from}
</div>
{envelope.to?.length > 0 && (
<>
<span className="font-medium text-muted-foreground">{t("mail.to")}:</span>
<div className="break-all text-foreground/90">
{envelope.to.slice(0, 2).join(", ")}
{envelope.to.length > 2 && " …"}
</div>
</>
)}
<span className="font-medium text-muted-foreground">{t("mail.date")}:</span>
<div className="text-foreground/90">
{new Date(envelope.date).toLocaleString()}
</div>
{envelope.mailbox_name && (
<>
<span className="font-medium text-muted-foreground">{t("search.mailbox")}:</span>
<div className="truncate text-foreground/90" title={envelope.mailbox_name}>
{envelope.mailbox_name}
</div>
</>
)}
</div>
</div>
);
}
interface RestoreMessageDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function RestoreMessageDialog({
open,
onOpenChange
}: RestoreMessageDialogProps) {
const { t } = useTranslation()
const { currentEnvelope, selected } = useSearchContext()
const accountsWithSelection = Array.from(selected.entries()).filter(([_, ids]) => ids.size > 0);
const selectedCount = accountsWithSelection.reduce((sum, [_, set]) => sum + set.size, 0);
const accountCount = accountsWithSelection.length;
const isBulk = selectedCount > 0;
const restoreMutation = useMutation({
mutationFn: async () => {
if (isBulk) {
const promises = accountsWithSelection.map(([accountId, ids]) =>
restore_message(accountId, Array.from(ids))
);
return Promise.all(promises);
} else if (currentEnvelope) {
return restore_message(currentEnvelope.account_id, [currentEnvelope.id]);
}
},
onSuccess: handleRestoreSuccess,
onError: handleRestoreError,
});
function handleRestoreSuccess() {
toast({
title: t('restore_message.success', 'Messages restored'),
description: t(
'restore_message.successDesc',
'The selected messages have been restored to the IMAP server.'
),
action: (
<ToastAction altText={t('common.close')}>
{t('common.close')}
</ToastAction>
),
});
onOpenChange(false);
}
function handleRestoreError(error: AxiosError) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
t('restore_message.failed', 'Failed to restore messages');
toast({
variant: 'destructive',
title: t(
'restore_message.failedTitle',
'Restore failed'
),
description: errorMessage,
action: (
<ToastAction altText={t('common.tryAgain')}>
{t('common.tryAgain')}
</ToastAction>
),
});
console.error(error);
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={isBulk ? t('restore_message.bulkTitle', 'Restore multiple messages') : t('restore_message.title', 'Restore message')}
desc={<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{t(
'restore_message.desc',
'This action will append the selected messages to their corresponding mailboxes on the IMAP server.'
)}
</p>
{isBulk ? (
<div className="rounded-md bg-primary/5 border border-primary/20 p-3 text-sm">
<div className="flex justify-between items-center text-primary font-medium">
<span>{t('restore_message.summary', 'Summary')}</span>
<span className="bg-primary/10 px-2 py-0.5 rounded text-xs">
{selectedCount} {t('restore_message.messages', 'messages')}
</span>
</div>
<div className="mt-2 text-xs space-y-1 text-muted-foreground">
<p> {t('restore_message.accountsInvolved', 'Accounts involved')}: {accountCount}</p>
<p> {t('restore_message.bulkWarning', 'Messages will be restored to their original folders.')}</p>
</div>
</div>
) : (
currentEnvelope && <MessageSummary envelope={currentEnvelope} t={t} />
)}
</div>}
confirmText={t('restore_message.confirm', 'Restore')}
handleConfirm={() => restoreMutation.mutate()}
className="sm:max-w-sm"
isLoading={restoreMutation.isPending}
disabled={restoreMutation.isPending || (!isBulk && !currentEnvelope)}
/>
)
}
@@ -0,0 +1,88 @@
//
// 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { ChevronDown, Mail } from "lucide-react"
import { useTranslation } from 'react-i18next'
import { useSearchContext } from "./context"
import { userAttachmentSenders } from "@/hooks/use-attachment-senders"
import { Group } from "@/api/system/api"
import { MetadataSelectorField } from "./attachment-metadata-selector"
export function SenderFilterPopover() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const { senders, isLoading } = userAttachmentSenders("")
const activeCount = filter.from ? 1 : 0
const senderOptions: Group[] = React.useMemo(() => {
return senders.map(email => ({
key: email,
count: 0
}))
}, [senders])
const updateFilter = (email: string | undefined) => {
setFilter(prev => ({
...prev,
from: email
}))
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
className={cn(
'h-6 rounded-none px-3 gap-1.5 transition-colors border-l-0',
activeCount > 0 && 'bg-primary/10 text-primary hover:bg-primary/20'
)}
>
<Mail className="h-3.5 w-3.5 opacity-60" />
<span>
{activeCount > 0
? t('attachment.sender_with_count', { count: activeCount })
: t('attachment.sender')}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-fit min-w-[280px] max-w-[90vw] sm:max-w-[min(90vw,500px)] p-0 flex flex-col divide-y divide-border shadow-xl"
>
<div className="flex flex-col bg-muted/20">
<MetadataSelectorField
label={t('attachment.sender')}
value={filter.from}
options={senderOptions}
isLoading={isLoading}
onSelect={(val) => updateFilter(val)}
onReset={() => updateFilter(undefined)}
/>
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,83 @@
//
// 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 {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
} from '@radix-ui/react-icons'
import { Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
title: string
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
const { t } = useTranslation()
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className=' h-8 data-[state=open]:bg-accent'
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className='ml-2 h-4 w-4' />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className='ml-2 h-4 w-4' />
) : (
<CaretSortIcon className='ml-2 h-4 w-4' />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
{t('table.asc')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
{t('table.desc')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -0,0 +1,71 @@
//
// 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 { Row } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { MoreVertical, TagIcon } from 'lucide-react'
import { useSearchContext } from '../context'
import { AttachmentModel } from '@/api/attachment/api'
interface DataTableRowActionsProps {
row: Row<AttachmentModel>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentEnvelope, setSelected } = useSearchContext()
const { t } = useTranslation()
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<MoreVertical size={10} />
<span className='sr-only'>Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
setCurrentEnvelope(row.original)
setOpen("edit-tags")
}}
>
{t('attachment.editTag')}
<DropdownMenuShortcut>
<TagIcon size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
+168
View File
@@ -0,0 +1,168 @@
//
// 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 { useState, MouseEvent as ReactMouseEvent, useEffect } from 'react'
import {
ColumnDef,
ColumnFiltersState,
Row,
RowData,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { type Table } from '@tanstack/react-table'
import {
Table as ShadcnTable,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useSearchContext } from '../context'
import { ScrollArea } from '@/components/ui/scroll-area'
import { AttachmentModel } from '@/api/attachment/api'
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
className: string
}
}
interface DataTableProps {
columns: ColumnDef<AttachmentModel>[]
data: AttachmentModel[]
onRowClick: (e: ReactMouseEvent<HTMLTableRowElement, MouseEvent>, row: Row<AttachmentModel>) => void
setSortBy: (sortBy: "DATE" | "SIZE") => void
setSortOrder: (value: "desc" | "asc") => void
children?: (table: Table<AttachmentModel>) => React.ReactNode
}
export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder, children }: DataTableProps) {
const { sorting, setSorting } = useSearchContext()
const { t } = useTranslation()
const [rowSelection, setRowSelection] = useState({})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
useEffect(() => {
const [value] = sorting
setSortBy(value.id.toUpperCase() as "DATE" | "SIZE")
setSortOrder(value.desc ? "desc" : "asc")
}, [sorting])
const table = useReactTable({
data,
columns,
state: {
sorting,
rowSelection,
columnFilters,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
return (
<div className="flex flex-1 flex-col gap-0.5">
{children && (<>{children(table)}</>)}
<ScrollArea className='h-[calc(100vh-16rem)] rounded-md border' orientation='both'>
<ShadcnTable>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='group/row'>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={header.column.columnDef.meta?.className ?? ''}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={cn("group/row cursor-pointer transition-colors hover:bg-accent/50")}
onClick={(e) => onRowClick(e, row)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cell.column.columnDef.meta?.className ?? ''}
style={{
width: cell.column.columnDef.size,
minWidth: cell.column.columnDef.minSize,
maxWidth: cell.column.columnDef.maxSize
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
{t('common.table.noResults')}
</TableCell>
</TableRow>
)}
</TableBody>
</ShadcnTable>
</ScrollArea>
</div>
)
}
@@ -0,0 +1,60 @@
import { type Table } from '@tanstack/react-table'
import { DataTableViewOptions } from './view-options'
import { TagFilterPopover } from '../tag-filter-popover'
import { TimePopover } from '../time-popover'
import { SenderFilterPopover } from '../sender-popover'
import { TextSearchInput } from '../text-search-input'
import { MoreFiltersPopover } from '../more-filters-popover'
import { FilterResetButton } from '../filter-reset'
import { MailboxPopover } from '../mailbox-popover'
import { AccountPopover } from '../account-popover'
import { MetadataFilter } from '../attachment-metadata-filter'
import { FileType, Laptop, Tag } from 'lucide-react'
type DataTableToolbarProps<TData> = {
table: Table<TData>
}
export function DataTableToolbar<TData>({
table,
}: DataTableToolbarProps<TData>) {
return (
<div className="flex flex-col gap-1 p-1 bg-background">
<div className="mb-4 flex items-center justify-center w-full">
<div className="w-full max-w-3xl">
<TextSearchInput />
</div>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-1">
<div className="flex items-center gap-2 flex-wrap w-full sm:w-auto">
<div className="flex items-center gap-1.5 flex-wrap">
<AccountPopover />
<MailboxPopover />
<SenderFilterPopover />
<TagFilterPopover />
<MetadataFilter
type="extension"
icon={<FileType className="h-3.5 w-3.5" />}
/>
<MetadataFilter
type="category"
icon={<Tag className="h-3.5 w-3.5" />}
/>
<MetadataFilter
type="content_type"
icon={<Laptop className="h-3.5 w-3.5" />}
/>
<MoreFiltersPopover />
</div>
<FilterResetButton />
</div>
<div className="flex items-center gap-1 shrink-0">
<TimePopover />
<DataTableViewOptions table={table} />
</div>
</div>
</div>
)
}
@@ -0,0 +1,80 @@
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
import { type Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import React from 'react'
import { useTranslation } from 'react-i18next'
type DataTableViewOptionsProps<TData> = {
table: Table<TData>
}
const defaultColumns = (t: (key: string) => string) => [
{ label: t('search.account'), value: "account_email" },
{ label: t('search.mailbox'), value: "mailbox_name" },
{ label: t('search.from'), value: "from" },
{ label: t('search.to'), value: "to" },
{ label: t('search.subject'), value: "subject" },
{ label: t('search.size'), value: "size" },
{ label: t('search.date'), value: "date" },
]
export function DataTableViewOptions<TData>({
table,
}: DataTableViewOptionsProps<TData>) {
const { t } = useTranslation()
const columnLabels = React.useMemo(() => {
return Object.fromEntries(
defaultColumns(t).map(col => [col.value, col.label])
)
}, [t]);
const visibleColumnKeys = React.useMemo(() => {
return new Set(defaultColumns(t).map(c => c.value))
}, [t])
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='ms-auto hidden h-6 lg:flex rounded-none'
>
<MixerHorizontalIcon className='size-4' />
{t('search_view.button_label')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[150px]'>
<DropdownMenuLabel className='text-xs'>{t('search_view.menu_title')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(column => visibleColumnKeys.has(column.id))
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className='capitalize text-xs'
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{columnLabels[column.id] ?? column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,209 @@
//
// 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'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { useSearchContext } from './context'
import { useAvailableAttachmentTags } from '@/hooks/use-available-attachment-tags'
export function TagFilterPopover() {
const { t } = useTranslation()
const [search, setSearch] = React.useState('')
const { filter, setFilter } = useSearchContext()
const selectedTags = (filter?.tags as string[]) || []
const {
tagsCount = [],
isLoading,
} = useAvailableAttachmentTags()
const handleTagToggle = (tag: string) => {
setFilter(prev => {
const next = { ...prev }
const currentTags = (next.tags as string[]) || []
const isSelected = currentTags.includes(tag)
const nextTags = isSelected
? currentTags.filter(t => t !== tag)
: [...currentTags, tag]
if (nextTags.length > 0) {
next.tags = nextTags
} else {
delete next.tags
}
return next
})
}
const clearAllTags = () => {
setFilter(prev => {
const next = { ...prev }
delete next.tags
return next
})
}
const filteredTags = React.useMemo(() => {
const q = search.toLowerCase()
return tagsCount
.filter(t =>
!q || t.tag.toLowerCase().includes(q)
)
.sort((a, b) => {
const aSelected = selectedTags.includes(a.tag)
const bSelected = selectedTags.includes(b.tag)
if (aSelected && !bSelected) return -1
if (!aSelected && bSelected) return 1
return b.count - a.count
})
}, [tagsCount, search, selectedTags])
return (
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
className={cn(
'h-6 gap-1.5 px-3 rounded-none border-l-0',
selectedTags.length > 0 &&
'bg-primary/10 border-primary text-primary'
)}
>
<Tag className="h-4 w-4" />
{t('tag.label')}
{selectedTags.length > 0 && (
<Badge
variant="secondary"
className="ml-1 h-5 px-1.5 text-xs"
>
{selectedTags.length}
</Badge>
)}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-96 p-1"
>
<div className="p-1 pb-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('tag.search_placeholder')}
className="h-8 text-sm"
autoFocus
/>
</div>
<ScrollArea className="h-96 p-1">
{!search && selectedTags.length > 0 && (
<>
<div
onClick={clearAllTags}
className="flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer text-destructive hover:bg-destructive/10 transition-colors"
>
<div className="flex h-4 w-4 items-center justify-center">
<X className="h-3 w-3" />
</div>
<span className="flex-1 text-xs font-medium">
{t('tag.clear_all')}
</span>
<span className="text-[10px] opacity-60">({selectedTags.length})</span>
</div>
<div className="my-1 h-px bg-border" />
</>
)}
{isLoading ? (
<div className="space-y-2 p-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-4 rounded bg-muted animate-pulse"
/>
))}
</div>
) : filteredTags.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t('tag.no_tags_found')}
</p>
) : (
filteredTags.map(({ tag, count }) => {
const checked = selectedTags.includes(tag)
const id = `tag-${tag}`
return (
<div
key={tag}
onClick={() => handleTagToggle(tag)}
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
'hover:bg-accent transition-colors'
)}
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={() =>
handleTagToggle(tag)
}
onClick={(e) =>
e.stopPropagation()
}
/>
<Label
htmlFor={id}
className="flex-1 truncate text-xs cursor-pointer"
title={tag}
>
{tag}
</Label>
<Badge
variant="secondary"
className="h-5 px-1.5 text-xs"
>
{count}
</Badge>
</div>
)
})
)}
</ScrollArea>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,227 @@
//
// 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"
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 = "bichon_attachment_search_history"
const MAX_HISTORY = 20
type SearchField = "text" | "subject" | "attachment_name" | "from"
const SEARCH_FIELDS: SearchField[] = ["text", "subject", "attachment_name", "from"]
export function TextSearchInput() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
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))
} catch (err) {
console.warn("Failed to load attachment search history", err)
}
}, [])
const applyFilter = (currentField: SearchField, searchTerm: string) => {
const trimmed = searchTerm.trim()
setFilter((prev) => {
const next = { ...prev }
SEARCH_FIELDS.forEach(f => {
delete next[f]
})
if (trimmed) {
next[currentField] = trimmed
}
return next
})
if (trimmed) {
saveToHistory(trimmed)
}
setShowHistory(false)
inputRef.current?.blur()
}
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("")
applyFilter(field, "")
}
const handleSelectHistory = (term: string) => {
setValue(term)
applyFilter(field, term)
}
const handleClearHistory = (e: React.MouseEvent) => {
e.stopPropagation()
setHistory([])
localStorage.removeItem(STORAGE_KEY)
}
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowHistory(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
return (
<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-xs bg-transparent focus:ring-0 focus:ring-offset-0 shadow-none border-y-0 border-l-0"
)}
>
<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("attachment.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("attachment.name")}
</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("attachment.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-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")}
</div>
{history.length > 0 && (
<button
onClick={handleClearHistory}
className="text-destructive hover:underline flex items-center gap-1"
>
<Trash2 className="h-3 w-3" />
{t("search_input.clear_history")}
</button>
)}
</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 text-xs">{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>
)
}
@@ -0,0 +1,221 @@
//
// 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 { useState } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { ChevronDown, ChevronUp, Loader2, MessageSquareText } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { get_thread_messages } from '@/api/mailbox/envelope/api';
import { MailMessageView } from './mail-message-view';
import { useSearchContext } from './context';
import { useTranslation } from 'react-i18next';
import { format } from 'date-fns';
interface MailThreadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps) {
const { currentEnvelope } = useSearchContext();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const { t } = useTranslation();
const threadId = currentEnvelope?.thread_id;
const accountId = currentEnvelope?.account_id;
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
} = useInfiniteQuery({
queryKey: ['thread', accountId, threadId],
queryFn: ({ pageParam = 1 }) =>
get_thread_messages(accountId!, threadId!, pageParam, 10),
getNextPageParam: (lastPage) =>
lastPage.current_page && lastPage.total_pages
? lastPage.current_page < lastPage.total_pages
? lastPage.current_page + 1
: undefined
: undefined,
enabled: open && !!accountId && !!threadId,
initialPageParam: 1,
});
const allMessages = data?.pages.flatMap((page) => page.items) ?? [];
const totalCount = data?.pages[0]?.total_items ?? 0;
const toggleExpand = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-width-full p-0 max-h-full flex flex-col md:max-w-3xl lg:max-w-4xl">
{/* Header */}
<DialogHeader className="p-4 pb-3 border-b shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<MessageSquareText className="w-5 h-5" />
<div className="text-sm">
{t('search.thread.title', { count: totalCount })}
</div>
</DialogTitle>
</div>
</DialogHeader>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{isLoading && <ThreadSkeleton />}
{isError && (
<div className="text-center text-destructive text-sm">
{t('search.thread.error')}: {(error as Error)?.message}
</div>
)}
{!isLoading && allMessages.length === 0 && (
<div className="text-center text-muted-foreground text-sm">
{t('search.thread.empty')}
</div>
)}
{allMessages
.sort((a, b) => a.date - b.date)
.map((msg) => {
const isExpanded = expandedIds.has(msg.id);
const preview = msg.preview;
const date = new Date(msg.date);
const formattedDate = isNaN(date.getTime())
? t('search.thread.invalidDate')
: format(date, 'yyyy-MM-dd HH:mm:ss');
return (
<Card
key={msg.id}
className={`transition-all ${isExpanded ? 'ring-2 ring-primary' : ''}`}
>
<CardHeader
className="cursor-pointer pb-3"
onClick={() => toggleExpand(msg.id)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium truncate">{msg.from}</span>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground truncate">
{msg.to.join(', ')}
</span>
</div>
<p className="font-medium mt-1 text-sm">
{msg.subject || t('search.thread.noSubject')}
</p>
{!isExpanded && preview && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{preview}
</p>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formattedDate}</span>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</div>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="p-0">
<div className="h-96 border-t m-5">
<MailMessageView
envelope={msg}
showActions={false}
showAttachments={false}
showHeader={false}
/>
</div>
</CardContent>
)}
</Card>
);
})}
{hasNextPage && (
<div className="flex justify-center py-3">
<Button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
variant="outline"
size="sm"
>
{isFetchingNextPage ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('search.thread.loadingMore')}
</>
) : (
t('search.thread.loadMore')
)}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// Skeleton
function ThreadSkeleton() {
return (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-4 w-48 mb-2" />
<Skeleton className="h-5 w-64 mb-1" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-32 mt-2" />
</CardHeader>
</Card>
))}
</div>
);
}
@@ -0,0 +1,244 @@
//
// 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'
import { format } from 'date-fns'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useSearchContext } from './context'
import { DatePicker } from '@/components/date-picker'
const DAY = 86400000
export function TimePopover() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const [customDays, setCustomDays] = React.useState<string>('')
const since = filter.since
const before = filter.before
const toDate = (ts: number) => {
return format(ts, t('time.format'))
}
const label = (s?: number, b?: number) => {
if (!s && !b) return t('time.label')
if (s && b) return `${toDate(s)}${toDate(b)}`
if (s) return `${t('time.since')} ${toDate(s)}`
return `${t('time.before')} ${toDate(b!)}`
}
const setRange = (s?: number, b?: number) => {
setFilter(prev => {
const next = { ...prev }
s ? (next.since = s) : delete next.since
b ? (next.before = b) : delete next.before
return next
})
}
const setSince = (s?: number) => setRange(s, before)
const setBefore = (b?: number) => setRange(since, b)
const handleApplyRecent = () => {
const days = parseInt(customDays)
if (!isNaN(days) && days > 0) {
setRange(Date.now() - days * DAY, undefined)
}
}
const clear = () => {
setRange()
setCustomDays('')
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
className={cn(
'h-6 rounded-none px-3 gap-1.5 transition-colors max-w-full',
(since || before) && 'bg-primary/10 text-primary hover:bg-primary/20'
)}
>
<CalendarRange className="h-4 w-4 shrink-0" />
<span className="truncate max-w-[120px] sm:max-w-none">
{label(since, before)}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-60 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={8}
className="w-[92vw] sm:w-[420px] max-w-[420px] p-4 space-y-6"
>
<Section title={t('time.recent_range')}>
<div className="space-y-4 w-full">
<div className="flex flex-wrap gap-2">
{[1, 7, 30].map(d => (
<Quick key={d} onClick={() => setRange(Date.now() - d * DAY, undefined)}>
{d === 1 ? t('time.last_day') : t('time.last_days', { count: d })}
</Quick>
))}
{[3, 6].map(m => (
<Quick key={m} onClick={() => setRange(Date.now() - m * 30 * DAY, undefined)}>
{t('time.last_months', { count: m })}
</Quick>
))}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 pt-3 border-t border-border/50">
<span className="text-[10px] uppercase font-bold opacity-40 shrink-0">
{t('time.recent_prefix')}
</span>
<Input
type="number"
min={1}
placeholder="10"
className="h-8 w-full sm:w-20 text-xs"
value={customDays}
onChange={e => setCustomDays(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleApplyRecent()}
/>
<span className="text-xs text-muted-foreground shrink-0">
{t('time.days_ago_to_now')}
</span>
<Button
size="sm"
variant="secondary"
className="h-8 px-3 sm:ml-auto text-xs w-full sm:w-auto"
onClick={handleApplyRecent}
>
{t('time.apply')}
</Button>
</div>
</div>
</Section>
<Section title={t('time.historical')}>
<div className="flex flex-wrap gap-2 w-full">
{[1, 2, 3, 5, 10].map(y => (
<Quick
key={y}
onClick={() => setRange(undefined, Date.now() - y * 365 * DAY)}
className="border-orange-200 hover:border-orange-400 hover:text-orange-600"
>
{t('time.over_years_ago', {
count: y,
unit: y === 1 ? t('time.year') : t('time.years')
})}
</Quick>
))}
</div>
</Section>
<Section title={t('time.absolute_range')}>
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-3 w-full">
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
{t('time.since').toUpperCase()}:
</span>
<div className="flex-1">
<DatePicker
placeholder={t('time.start_date')}
selected={since ? new Date(since) : undefined}
onSelect={(date) => setSince(date?.getTime())}
/>
</div>
</div>
<div className="flex items-center gap-3 w-full">
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
{t('time.before').toUpperCase()}:
</span>
<div className="flex-1">
<DatePicker
placeholder={t('time.end_date')}
selected={before ? new Date(before) : undefined}
onSelect={(date) => setBefore(date?.getTime())}
/>
</div>
</div>
</div>
</Section>
{(since || before) && (
<div className="px-1 pb-2">
<Button
variant="ghost"
size="sm"
onClick={clear}
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X className="mr-2 h-3.5 w-3.5" />
{t('time.clear_filters')}
</Button>
</div>
)}
</PopoverContent>
</Popover>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="flex flex-col items-start w-full">
<div className="text-[11px] font-semibold mb-2.5 text-muted-foreground uppercase tracking-wider">
{title}
</div>
{children}
</div>
)
}
function Quick({
children,
onClick,
className
}: {
children: React.ReactNode;
onClick: () => void;
className?: string
}) {
return (
<Button
size="sm"
variant="outline"
className={cn(
"h-7 px-2.5 text-xs font-normal hover:bg-primary/5 hover:text-primary shrink-0",
className
)}
onClick={onClick}
>
{children}
</Button>
)
}