initial commit

This commit is contained in:
rustmailer
2025-11-19 02:14:37 +08:00
commit 1a8f95117e
355 changed files with 54089 additions and 0 deletions
+219
View File
@@ -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>
);
}
+180
View File
@@ -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>
</>
)
}
+58
View File
@@ -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
}
+111
View File
@@ -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
/>
)
}
+196
View File
@@ -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>)
}
+273
View File
@@ -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}`;
}
+470
View File
@@ -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>);
}
+125
View File
@@ -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>
);
}
+217
View File
@@ -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>
);
}