mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
initial commit
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { EmailEnvelope } from '@/api';
|
||||
import { validateTag } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
currentEnvelope: EmailEnvelope | undefined
|
||||
}
|
||||
|
||||
export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) {
|
||||
const { tags: availableTags } = useAvailableTags();
|
||||
const { mutate, isPending } = useUpdateTags();
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
|
||||
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: 'Invalid tag',
|
||||
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 = () => {
|
||||
const updates = {
|
||||
[currentEnvelope.account_id]: [currentEnvelope.id],
|
||||
};
|
||||
|
||||
mutate(
|
||||
{
|
||||
updates,
|
||||
tags: selectedTags
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Tags updated',
|
||||
description: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Successfully updated tags</span>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Failed to update tags',
|
||||
description: error?.message || 'Please try again',
|
||||
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" />
|
||||
Edit Tags
|
||||
</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">No tags yet</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="Search or create new tag..."
|
||||
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">
|
||||
Press <kbd className="px-1.5 py-0.5 rounded bg-muted font-medium">Enter</kbd>
|
||||
or click
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-muted font-medium">+</kbd>
|
||||
to create tag "<span className="font-medium text-foreground">{inputValue}</span>"
|
||||
</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">
|
||||
{selectedTags.length} tag{selectedTags.length !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// Copyright (c) 2025 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, Trash2 } 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'
|
||||
|
||||
type MailBulkActionsProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function MailBulkActions({ children }: MailBulkActionsProps) {
|
||||
const { selected, setSelected, setOpen, setToDelete } = useSearchContext()
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const selectedCount = Array.from(selected.values())
|
||||
.reduce((sum, set) => sum + set.size, 0)
|
||||
|
||||
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelected(new Map())
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
setToDelete(new Map())
|
||||
selected.forEach((ids, accountId) => {
|
||||
setToDelete(prev => {
|
||||
const next = new Map(prev)
|
||||
next.set(accountId, new Set(ids))
|
||||
return next
|
||||
})
|
||||
})
|
||||
setOpen('delete')
|
||||
}
|
||||
|
||||
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={`Bulk actions for ${selectedCount} selected email${selectedCount > 1 ? 's' : ''}`}
|
||||
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="Clear selection"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<span className="sr-only">Clear selection</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Clear selection (Escape)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<div className="flex items-center gap-x-1 text-sm" id="bulk-actions-desc">
|
||||
<Badge variant="default" className="min-w-8 rounded-lg">
|
||||
{selectedCount}
|
||||
</Badge>{' '}
|
||||
<span className="hidden sm:inline">
|
||||
email{selectedCount > 1 ? 's' : ''}
|
||||
</span>{' '}
|
||||
selected
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
className="gap-1"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Delete</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete selected emails</TooltipContent>
|
||||
</Tooltip>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { EmailEnvelope } from '@/api'
|
||||
|
||||
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form'
|
||||
|
||||
interface SearchContextType {
|
||||
open: SearchDialogType | null
|
||||
setOpen: (str: SearchDialogType | null) => void
|
||||
currentEnvelope: EmailEnvelope | undefined
|
||||
setCurrentEnvelope: React.Dispatch<React.SetStateAction<EmailEnvelope | undefined>>
|
||||
toDelete: Map<number, Set<number>>
|
||||
setToDelete: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
|
||||
selected: Map<number, Set<number>>
|
||||
setSelected: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
|
||||
selectedTags: string[]
|
||||
}
|
||||
|
||||
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,111 @@
|
||||
//
|
||||
// Copyright (c) 2025 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'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function EnvelopeDeleteDialog({ open, onOpenChange }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const { toDelete, setToDelete, setSelected } = useSearchContext()
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ payload }: { payload: Record<string, number[]> }) => 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: 'Messages deleted successfully',
|
||||
description: 'The messages have been deleted.',
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: 'Failed to delete messages',
|
||||
description: `${error.message}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
const payload = mapToRecordOfArrays(toDelete);
|
||||
deleteMutation.mutate({ payload })
|
||||
}
|
||||
|
||||
const isLoading = deleteMutation.isPending
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
handleConfirm={handleDelete}
|
||||
className="max-w-xl"
|
||||
isLoading={isLoading}
|
||||
title={
|
||||
<span className='text-destructive'>
|
||||
<IconAlertTriangle
|
||||
className='mr-1 inline-block stroke-destructive'
|
||||
size={18}
|
||||
/>{' '}
|
||||
Delete Email
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<div className='space-y-4'>
|
||||
<p className='mb-2'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-bold'>
|
||||
{(() => {
|
||||
const emailCount = Array.from(toDelete.values())
|
||||
.reduce((sum, set) => sum + set.size, 0);
|
||||
return emailCount > 1 ? `this ${emailCount} emails` : 'this email';
|
||||
})()}
|
||||
</span>{' '}
|
||||
?
|
||||
<br />
|
||||
This action will delete the selected email(s) from local database. the email(s) will be permanently deleted, and cannot be recovered.
|
||||
</p>
|
||||
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>Warning!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Please be cautious before proceeding.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
confirmText='Delete'
|
||||
destructive
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { useSearchMessages } from '@/hooks/use-search-messages';
|
||||
import { SearchFormDialog } from './search-form';
|
||||
import { EnvelopeListPagination } from '@/components/pagination';
|
||||
import { MailList } from './mail-list';
|
||||
import React from 'react';
|
||||
import { EmailEnvelope } from '@/api';
|
||||
import { Filter, SearchIcon } from 'lucide-react';
|
||||
import { MailDisplayDrawer } from './mail-display-dialog';
|
||||
import { EnvelopeDeleteDialog } from './delete-dialog';
|
||||
import SearchProvider, { SearchDialogType } from './context';
|
||||
import useDialogState from '@/hooks/use-dialog-state';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EnvelopeTags } from './tag-facet';
|
||||
import { EditTagsDialog } from './add-tag-dialog';
|
||||
|
||||
export default function Search() {
|
||||
const [selectedEnvelope, setSelectedEnvelope] = React.useState<EmailEnvelope | undefined>(undefined);
|
||||
const [open, setOpen] = useDialogState<SearchDialogType>(null)
|
||||
const [toDelete, setToDelete] = React.useState<Map<number, Set<number>>>(new Map());
|
||||
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
|
||||
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
|
||||
|
||||
const {
|
||||
emails,
|
||||
total,
|
||||
totalPages,
|
||||
isLoading,
|
||||
isFetching,
|
||||
page,
|
||||
pageSize,
|
||||
setPage,
|
||||
setPageSize,
|
||||
onSubmit,
|
||||
reset,
|
||||
filter
|
||||
} = useSearchMessages();
|
||||
|
||||
const handleSetPageSize = (pageSize: number) => {
|
||||
setPage(1);
|
||||
setPageSize(pageSize)
|
||||
}
|
||||
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setSelectedTags([]);
|
||||
};
|
||||
|
||||
const handleTagToggle = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag)
|
||||
? prev.filter(t => t !== tag)
|
||||
: [...prev, tag]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FixedHeader />
|
||||
<Main>
|
||||
<SearchProvider value={{ open, setOpen, currentEnvelope: selectedEnvelope, selectedTags, setCurrentEnvelope: setSelectedEnvelope, toDelete, setToDelete, selected, setSelected }}>
|
||||
<div className="mx-auto w-full px-4">
|
||||
<div className="mb-4 lg:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Tag Filter
|
||||
{selectedTags.length > 0 && ` (${selectedTags.length})`}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-80">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Tag Filter</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6">
|
||||
<EnvelopeTags
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<EnvelopeTags
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
<Button size="sm" onClick={() => setOpen("search-form")}>
|
||||
<SearchIcon className="mr-2 h-4 w-4" />
|
||||
Search
|
||||
</Button>
|
||||
{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">Searching, please wait…</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{total === 0 && <div className="text-center py-12 space-y-4">
|
||||
<div className="bg-muted/50 border-2 border-dashed rounded-xl w-24 h-24 mx-auto flex items-center justify-center">
|
||||
<SearchIcon className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">No emails found</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
{Object.keys(filter).length === 0
|
||||
? "Start by entering a keyword, sender, or using advanced filters."
|
||||
: "Try adjusting your search criteria or clearing filters."}
|
||||
</p>
|
||||
</div>}
|
||||
{total > 0 && <ScrollArea className='h-[40rem] w-full pr-4 -mr-4 py-1'>
|
||||
<MailList
|
||||
isLoading={isLoading}
|
||||
items={emails}
|
||||
onEnvelopeChanged={(envelope) => {
|
||||
setOpen('display');
|
||||
setSelectedEnvelope(envelope);
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>}
|
||||
{total > 0 && <EnvelopeListPagination
|
||||
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-tags-dialog'
|
||||
open={open === 'edit-tags'}
|
||||
onOpenChange={() => setOpen('edit-tags')} currentEnvelope={selectedEnvelope}
|
||||
/>
|
||||
|
||||
<SearchFormDialog
|
||||
key='search-form-dialog'
|
||||
onSubmit={onSubmit} isLoading={isLoading || isFetching} reset={handleReset}
|
||||
open={open === 'search-form'}
|
||||
onOpenChange={() => setOpen('search-form')}
|
||||
/>
|
||||
</SearchProvider>
|
||||
</Main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// Copyright (c) 2025 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'
|
||||
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function MailDisplayDrawer({ open, onOpenChange }: Props) {
|
||||
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">
|
||||
Email Viewer
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<ScrollArea>
|
||||
<div className='m-5'>
|
||||
{currentEnvelope ? (
|
||||
<MailMessageView envelope={currentEnvelope} />
|
||||
) : (
|
||||
<div className="p-8 text-center text-muted-foreground">No message selected</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//
|
||||
// Copyright (c) 2025 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, 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" // shadcn Checkbox
|
||||
import { EmailEnvelope } from "@/api"
|
||||
import { useSearchContext } from "./context"
|
||||
import { MailBulkActions } from "./bulk-actions"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface MailListProps {
|
||||
items: EmailEnvelope[]
|
||||
isLoading: boolean
|
||||
onEnvelopeChanged: (envelope: EmailEnvelope) => void
|
||||
}
|
||||
|
||||
export function MailList({
|
||||
items,
|
||||
isLoading,
|
||||
onEnvelopeChanged
|
||||
}: MailListProps) {
|
||||
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: number) => {
|
||||
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: number) => {
|
||||
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: number) => {
|
||||
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-3 p-3">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{items.length > 0 && (
|
||||
<div className="flex items-center gap-3 px-3 py-2 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
|
||||
? `${totalSelected} selected`
|
||||
: "Select all"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map((item, index) => {
|
||||
const hasAttachments = item.attachments && item.attachments.length > 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-3 px-3 py-2.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-4 w-4 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">
|
||||
|
||||
{/* LEFT AREA: From + Subject + Tags */}
|
||||
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0">
|
||||
|
||||
{/* from + subject (large screen side by side, small screen subject hidden) */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{item.from}</p>
|
||||
|
||||
{/* subject on large screens */}
|
||||
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
|
||||
{item.subject}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* subject on small screens */}
|
||||
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
|
||||
{item.subject}
|
||||
</h3>
|
||||
|
||||
{/* TAGS (always below on small screen, inline on large screen) */}
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{item.tags?.map((tag, index) => (
|
||||
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={index}>{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT AREA – actions & meta */}
|
||||
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-2 text-xs text-muted-foreground">
|
||||
|
||||
{hasAttachments && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
<span>{item.attachments?.length}</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 })}
|
||||
</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 p-0 hover:bg-muted rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">More actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentEnvelope(item);
|
||||
setOpen("edit-tags");
|
||||
}}
|
||||
>
|
||||
<TagIcon className="ml-2 h-4 w-4" />
|
||||
Edit Tags
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="ml-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{totalSelected > 0 && (
|
||||
<MailBulkActions />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 } 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';
|
||||
|
||||
|
||||
interface MailMessageViewProps {
|
||||
envelope: {
|
||||
id: number;
|
||||
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 [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 ? 'show less' : 'show more...'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function MailMessageView({
|
||||
envelope,
|
||||
showActions = true,
|
||||
showAttachments = true,
|
||||
showHeader = true
|
||||
}: MailMessageViewProps) {
|
||||
const { setToDelete, setOpen } = 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 { getEmailById } = useMinimalAccountList();
|
||||
const [threadOpen, setThreadOpen] = useState(false);
|
||||
|
||||
const downloadAttachmentMutation = useMutation({
|
||||
mutationFn: ({ fileName }: { fileName: string }) =>
|
||||
download_attachment(envelope.account_id, envelope.id, fileName),
|
||||
onSuccess: () => setDownloadingAttachmentFileName(null),
|
||||
onError: (error: any) => {
|
||||
setDownloadingAttachmentFileName(null);
|
||||
toast({
|
||||
title: 'Failed to download file',
|
||||
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: 'Failed to load email message.',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadMessageMutation.mutate();
|
||||
}, [envelope.id]);
|
||||
|
||||
|
||||
const toggleToDelete = (accountId: number, mailId: number) => {
|
||||
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: 'Download started', description: `"${envelope.id}" is being downloaded` });
|
||||
await download_message(envelope.account_id, envelope.id);
|
||||
toast({ title: 'Download complete', description: `"${envelope.id}" downloaded` });
|
||||
} catch (error) {
|
||||
let msg = 'Failed to download email';
|
||||
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: 'Download failed', description: msg, variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header Info */}
|
||||
{showHeader && <div className="grid gap-1 text-xs">
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">Account:</span>
|
||||
<span>{getEmailById(envelope.account_id)}</span>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">Id:</span>
|
||||
<span>{envelope.id}</span>
|
||||
</div>
|
||||
{envelope.from && (
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">From:</span>
|
||||
<span>{envelope.from}</span>
|
||||
</div>
|
||||
)}
|
||||
{envelope.to && envelope.to.length > 0 && <Multilines title="To" lines={envelope.to} />}
|
||||
{envelope.cc && envelope.cc.length > 0 && <Multilines title="Cc" lines={envelope.cc} />}
|
||||
{envelope.bcc && envelope.bcc.length > 0 && <Multilines title="Bcc" lines={envelope.bcc} />}
|
||||
{envelope.subject && (
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">Subject:</span>
|
||||
<span>{envelope.subject}</span>
|
||||
</div>
|
||||
)}
|
||||
{envelope.internal_date && (
|
||||
<div className="flex space-x-2">
|
||||
<span className="font-medium text-gray-400">Date:</span>
|
||||
<span>{formatTimestamp(envelope.internal_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
{/* Action Bar */}
|
||||
{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>Delete locally</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>Download .eml file</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>View full thread</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showAttachments && <Separator className="my-2" />}
|
||||
{/* Attachments */}
|
||||
{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) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<div className="flex items-center space-x-8">
|
||||
<span className="truncate text-xs">{attachment.filename}</span>
|
||||
<span className="text-xs px-2 py-1 rounded">[{attachment.file_type}]</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 ml-auto">
|
||||
<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({ fileName: attachment.filename });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 text-xs italic">
|
||||
Only non-inline attachments are shown here.
|
||||
</span>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<span className="text-gray-500 text-xs">No attachments</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showAttachments && <Separator className="mb-2" />}
|
||||
{/* Content */}
|
||||
<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} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,470 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { DatePicker } from "@/components/date-picker";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ChevronDown, ChevronUp, Filter, RotateCcw } from "lucide-react";
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { VirtualizedSelect } from "@/components/virtualized-select";
|
||||
import useMinimalAccountList from "@/hooks/use-minimal-account-list";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { list_mailboxes, MailboxData } from "@/api/mailbox/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchContext } from "./context";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
const searchFilterSchema = z.object({
|
||||
text: z.string().optional().or(z.literal("")),
|
||||
from: z
|
||||
.string()
|
||||
.email({ message: "Please enter a valid email address" })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
to: z
|
||||
.string()
|
||||
.email({ message: "Please enter a valid email address" })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
cc: z
|
||||
.string()
|
||||
.email({ message: "Please enter a valid email address" })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
bcc: z
|
||||
.string()
|
||||
.email({ message: "Please enter a valid email address" })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
has_attachment: z.boolean().optional(),
|
||||
attachment_name: z.string().optional().or(z.literal("")),
|
||||
since: z.date().optional(),
|
||||
before: z.date().optional(),
|
||||
account_id: z.number().optional().or(z.literal("")),
|
||||
mailbox_id: z.number().optional().or(z.literal("")),
|
||||
min_size: z.number().optional().or(z.literal("")),
|
||||
max_size: z.number().optional().or(z.literal("")),
|
||||
message_id: z.string().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
type SearchFilterForm = z.infer<typeof searchFilterSchema>;
|
||||
|
||||
|
||||
interface Props {
|
||||
onSubmit: (values: Record<string, any>) => void,
|
||||
isLoading: boolean,
|
||||
reset: () => void,
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const isEmptyValue = (value: any): boolean => {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (value === '') return true;
|
||||
if (typeof value === 'string' && value.trim() === '') return true;
|
||||
if (typeof value === 'number' && isNaN(value)) return true;
|
||||
if (value === false) return true;
|
||||
if (value === 0) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const cleanEmpty = <T extends Record<string, any>>(obj: T): Partial<T> => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_, value]) => !isEmptyValue(value))
|
||||
) as Partial<T>;
|
||||
};
|
||||
|
||||
export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) {
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<number | undefined>(undefined);
|
||||
const { accountsOptions, isLoading: accountsIsLoading } = useMinimalAccountList();
|
||||
const { selectedTags } = useSearchContext();
|
||||
|
||||
const form = useForm<SearchFilterForm>({
|
||||
resolver: zodResolver(searchFilterSchema),
|
||||
defaultValues: {
|
||||
text: "",
|
||||
from: "",
|
||||
to: "",
|
||||
cc: "",
|
||||
bcc: "",
|
||||
attachment_name: "",
|
||||
message_id: "",
|
||||
min_size: undefined,
|
||||
max_size: undefined,
|
||||
has_attachment: false,
|
||||
since: undefined,
|
||||
before: undefined,
|
||||
account_id: undefined,
|
||||
mailbox_id: undefined,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
|
||||
queryKey: ['search-account-mailboxes', `${selectedAccountId}`],
|
||||
queryFn: () => list_mailboxes(selectedAccountId!, false),
|
||||
enabled: !!selectedAccountId,
|
||||
})
|
||||
|
||||
|
||||
const mailboxesOptions = mailboxes?.map((mailbox: MailboxData) => ({
|
||||
value: mailbox.id.toString(),
|
||||
label: mailbox.name,
|
||||
})) || [];
|
||||
|
||||
|
||||
const handleSubmit = (values: Record<string, any>) => {
|
||||
let cleaned = cleanEmpty(values);
|
||||
if (selectedTags.length > 0) {
|
||||
cleaned.tags = selectedTags;
|
||||
}
|
||||
if (Object.keys(cleaned).length > 0) {
|
||||
onSubmit(cleaned);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Please select at least one search condition',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
form.reset({
|
||||
text: "",
|
||||
from: "",
|
||||
to: "",
|
||||
cc: "",
|
||||
bcc: "",
|
||||
has_attachment: false,
|
||||
attachment_name: "",
|
||||
since: undefined,
|
||||
before: undefined,
|
||||
account_id: undefined,
|
||||
mailbox_id: undefined,
|
||||
min_size: undefined,
|
||||
max_size: undefined,
|
||||
message_id: "",
|
||||
});
|
||||
setSelectedAccountId(undefined);
|
||||
}
|
||||
|
||||
return (<Sheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<SheetContent className='w-full md:max-w-4xl mx-auto'>
|
||||
<SheetHeader className="p-4 pb-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
Search Archived Emails
|
||||
</SheetTitle>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetDescription>
|
||||
Full-text · Multi-account · Advanced filters
|
||||
</SheetDescription>
|
||||
<Form {...form}>
|
||||
<form id="email-search-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="account_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">Account:</FormLabel>
|
||||
<FormControl className="flex-1">
|
||||
<VirtualizedSelect
|
||||
options={accountsOptions}
|
||||
isLoading={accountsIsLoading}
|
||||
onSelectOption={(values) => {
|
||||
const account_id = parseInt(values[0], 10);
|
||||
setSelectedAccountId(account_id);
|
||||
field.onChange(account_id);
|
||||
}}
|
||||
value={field.value?.toString() ?? ""}
|
||||
placeholder="Select account"
|
||||
className="h-10 w-full"
|
||||
noItemsComponent={
|
||||
<div className="p-2">
|
||||
<p className="text-xs">No active email account.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: "/accounts" })}
|
||||
>
|
||||
Add Email Account
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mailbox_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">Mailbox:</FormLabel>
|
||||
<FormControl className="flex-1">
|
||||
<VirtualizedSelect
|
||||
options={mailboxesOptions}
|
||||
isLoading={isMailboxesLoading}
|
||||
onSelectOption={(values) => field.onChange(parseInt(values[0], 10))}
|
||||
value={field.value?.toString() ?? ""}
|
||||
placeholder="Select mailbox"
|
||||
className="h-10 w-full"
|
||||
noItemsComponent={
|
||||
<div className="p-2">
|
||||
<p className="text-xs">
|
||||
No mailbox. Please select an account first.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Search in subject, body, attachments..."
|
||||
className="h-11 text-base"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 sm:ml-auto sm:self-center">
|
||||
<Button type="submit" className="h-11 px-6" disabled={isLoading}>
|
||||
{isLoading ? "Searching..." : <>Search</>}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<Filter className="w-4 h-4 mr-1" />
|
||||
Advanced
|
||||
{showAdvanced ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={() => { handleClear(); reset(); }}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="since"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">Since:</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
placeholder="Select a date"
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="before"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2">
|
||||
<FormLabel className="text-xs whitespace-nowrap">Before:</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
placeholder="Select a date"
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="has_attachment"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="attach"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<FormLabel htmlFor="attach" className="cursor-pointer text-sm font-normal">
|
||||
Has attachments
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{showAdvanced && <Accordion type="multiple" className="space-y-3">
|
||||
{/* Sender & Recipients */}
|
||||
<AccordionItem value="people">
|
||||
<AccordionTrigger className="text-sm">
|
||||
Sender / Recipients
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 pt-2">
|
||||
{(['from', 'to', 'cc', 'bcc'] as const).map((key) => (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="capitalize text-xs">
|
||||
{key === 'from' ? 'From' : key === 'to' ? 'To' : key.toUpperCase()}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={`${key}@example.com`}
|
||||
className="h-9"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="attachment">
|
||||
<AccordionTrigger className="text-sm">
|
||||
Attachments & Size
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attachment_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Attachment name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="invoice.pdf" className="h-9" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="min_size"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Minimum (bytes)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="1MB = 1048576" className="h-9" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_size"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs">Maximum (bytes)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10MB = 10485760" className="h-9" {...field} onChange={(e) => field.onChange(parseInt(e.target.value, 10))} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="ids">
|
||||
<AccordionTrigger className="text-sm">
|
||||
Message-ID
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormControl>
|
||||
<Input placeholder="<abc123@example.com>" className="h-9" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
Original email Message-ID header
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SheetContent>
|
||||
</Sheet>);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { ChevronDown, ChevronUp, Tag } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useAvailableTags } from '@/hooks/use-available-tags';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface EnvelopeTagsProps {
|
||||
selectedTags: string[];
|
||||
onTagToggle: (tag: string) => void;
|
||||
}
|
||||
|
||||
export function EnvelopeTags({ selectedTags, onTagToggle }: EnvelopeTagsProps) {
|
||||
const [open, setOpen] = React.useState(true);
|
||||
|
||||
const {
|
||||
tagsCount: tagsCount = [],
|
||||
isLoading: tagsIsLoading,
|
||||
} = useAvailableTags();
|
||||
|
||||
const sortedTags = React.useMemo(() => {
|
||||
return [...tagsCount].sort((a, b) => b.count - a.count);
|
||||
}, [tagsCount]);
|
||||
|
||||
if (tagsIsLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-2 py-1.5">
|
||||
<div className="h-4 w-4 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 flex-1 bg-muted animate-pulse rounded" />
|
||||
<div className="h-5 w-10 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="space-y-2">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between text-sm font-medium hover:text-primary transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
Tags
|
||||
{selectedTags.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-xs">
|
||||
{selectedTags.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{open ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="space-y-0">
|
||||
{sortedTags.length === 0 ? (
|
||||
<p className="py-2 pl-2 text-sm text-muted-foreground">No tags yet</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[45rem] w-full pr-4 -mr-4">
|
||||
{sortedTags.map(({ tag: facet, count }) => {
|
||||
const checked = selectedTags.includes(facet);
|
||||
const id = `tag-${facet}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={facet}
|
||||
className="flex items-center gap-3 px-2 py-0.5 hover:bg-accent/80 rounded-md transition-colors cursor-pointer group"
|
||||
onClick={() => onTagToggle(facet)}
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={() => onTagToggle(facet)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex-1 max-w-[140px] lg:max-w-[120px] cursor-pointer truncate text-sm font-medium"
|
||||
title={facet}
|
||||
>
|
||||
{facet}
|
||||
</Label>
|
||||
<div className="shrink-0 ml-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-xs font-medium min-w-[1.75rem] text-center"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 { format } from 'date-fns';
|
||||
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';
|
||||
|
||||
interface MailThreadDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps) {
|
||||
const { currentEnvelope } = useSearchContext();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
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: number) => {
|
||||
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-w-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'>Thread ({totalCount} {totalCount === 1 ? 'message' : 'messages'})</div>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{isLoading && <ThreadSkeleton />}
|
||||
|
||||
{isError && (
|
||||
<div className="text-center text-destructive text-sm">
|
||||
Failed to load thread: {(error as Error)?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && allMessages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm">
|
||||
No messages in this thread.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allMessages
|
||||
.sort((a, b) => a.internal_date - b.internal_date)
|
||||
.map((msg) => {
|
||||
const isExpanded = expandedIds.has(msg.id);
|
||||
const preview = msg.text?.slice(0, 120) + (msg.text?.length > 120 ? '...' : '');
|
||||
const date = new Date(msg.internal_date);
|
||||
const formattedDate = isNaN(date.getTime())
|
||||
? 'Invalid Date'
|
||||
: format(date, 'MMM d, yyyy h:mm a');
|
||||
|
||||
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 || '(No subject)'}
|
||||
</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" />
|
||||
Loading more...
|
||||
</>
|
||||
) : (
|
||||
'Load more'
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user