feat(search): integrate mailbox directory tree into search interface

This commit is contained in:
rustmailer
2026-03-15 03:36:02 +08:00
parent 2b10d201ee
commit af0f47c0e3
27 changed files with 374 additions and 2293 deletions
+12 -1
View File
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::io::Write;
use mail_parser::{parsers::MessageStream, MessageParser, MimeHeaders};
use crate::{
@@ -108,7 +110,7 @@ R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
#[tokio::test]
async fn test44() {
let path = r"C:\Users\polly\Downloads\test333.eml";
let path = r"C:\Users\polly\Downloads\3462966311412541.eml";
let input = std::fs::read(path).unwrap();
let message = MessageParser::default().parse(&input).unwrap();
for attachment in message.attachments() {
@@ -120,6 +122,15 @@ async fn test44() {
let disposition = attachment.content_disposition();
let body_start = attachment.raw_body_offset() as usize;
let body_end = attachment.raw_end_offset() as usize;
if body_start < input.len() && body_end <= input.len() && body_start <= body_end {
//let raw_data = &input[body_start..body_end];
let mut file = std::fs::File::create(&filename).unwrap();
file.write_all(attachment.contents()).unwrap();
}
let file_type = format!(
"{}/{}",
content_type.c_type.as_ref(),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"private": true,
"name": "bichon-ui",
"version": "0.0.1",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -22,7 +22,7 @@ import {
IconLayoutDashboard,
IconSettings
} from '@tabler/icons-react'
import { IdCard, Inbox, Mailbox, Search, Users2 } from 'lucide-react'
import { IdCard, Inbox, Search, Users2 } from 'lucide-react'
import { type SidebarData } from '../types'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
@@ -52,11 +52,6 @@ export function useSidebarData(): SidebarData {
url: '/accounts',
icon: Inbox,
},
{
title: t('navigation.mailbox'),
url: '/mailboxes',
icon: Mailbox,
},
{
title: t('common.search'),
url: '/search',
+1 -1
View File
@@ -463,7 +463,7 @@ export default function MailArchiveDashboard() {
</div>
<div className="p-6 md:p-8 pt-0 text-center text-xs text-muted-foreground">
© 2025 <a href="https://rustmailer.com" target="_blank" rel="noopener noreferrer" className="hover:underline">rustmailer.com</a> - Bichon Email Archiving Project
© 2025-2026 <a href="https://rustmailer.com" target="_blank" rel="noopener noreferrer" className="hover:underline">rustmailer.com</a> - Bichon Email Archiving Project
</div>
</Main>
</>
@@ -1,58 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import useMinimalAccountList from "@/hooks/use-minimal-account-list";
import { VirtualizedSelect } from "@/components/virtualized-select";
import { Button } from "@/components/ui/button";
import { useNavigate } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
interface AccountSwitcherProps {
onAccountSelect: (accountId: number) => void,
defaultAccountId?: number,
}
export function AccountSwitcher({
onAccountSelect,
defaultAccountId
}: AccountSwitcherProps) {
const { accountsOptions, isLoading } = useMinimalAccountList();
const navigate = useNavigate()
const { t } = useTranslation();
if (isLoading) {
return <div>Loading...</div>;
}
return (
<VirtualizedSelect
className='w-full mr-8'
isLoading={isLoading}
options={accountsOptions}
defaultValue={`${defaultAccountId}`}
onSelectOption={(values) => onAccountSelect(parseInt(values[0], 10))}
placeholder={t('oauth2.selectAnAccount')}
noItemsComponent={<div className='space-y-2'>
<p>No active email account.</p>
<Button variant={'outline'} className="py-1 px-3 text-xs" onClick={() => navigate({ to: '/accounts' })}>Add Email Account</Button>
</div>}
/>
);
}
@@ -1,173 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useRef } from 'react';
import { X, 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 { useMailboxContext } from '../context';
import { useTranslation } from 'react-i18next';
type MailBulkActionsProps = {
children?: React.ReactNode;
};
export function MailBulkActions({ children }: MailBulkActionsProps) {
const { t } = useTranslation();
const { selected, setSelected, setOpen, setDeleteIds } = useMailboxContext();
const toolbarRef = useRef<HTMLDivElement>(null);
const selectedCount = selected.size;
const handleClearSelection = () => {
setSelected(new Set<number>());
};
const handleDelete = () => {
setDeleteIds(new Set(selected));
setSelected(new Set());
setOpen('move-to-trash');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
const buttons = toolbarRef.current?.querySelectorAll('button');
if (!buttons || buttons.length === 0) return;
const currentIndex = Array.from(buttons).findIndex(
(btn) => btn === document.activeElement
);
switch (e.key) {
case 'ArrowRight': {
e.preventDefault();
const next = (currentIndex + 1) % buttons.length;
buttons[next]?.focus();
break;
}
case 'ArrowLeft': {
e.preventDefault();
const prev = currentIndex === 0 ? buttons.length - 1 : currentIndex - 1;
buttons[prev]?.focus();
break;
}
case 'Home':
e.preventDefault();
buttons[0]?.focus();
break;
case 'End':
e.preventDefault();
buttons[buttons.length - 1]?.focus();
break;
case 'Escape': {
const target = e.target as HTMLElement;
const active = document.activeElement as HTMLElement;
const isFromDropdown =
target.closest('[data-slot="dropdown-menu-trigger"]') ||
active.closest('[data-slot="dropdown-menu-trigger"]') ||
target.closest('[data-slot="dropdown-menu-content"]') ||
active.closest('[data-slot="dropdown-menu-content"]');
if (!isFromDropdown) {
e.preventDefault();
handleClearSelection();
}
break;
}
}
};
if (selectedCount === 0) return null;
return (
<>
<div
ref={toolbarRef}
role="toolbar"
aria-label={t('mailbox.bulkActions.ariaLabel', {
count: selectedCount,
emailLabel: selectedCount > 1 ? t('mailbox.bulkActions.emails') : t('mailbox.bulkActions.email')
})}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cn(
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
'transition-all delay-100 duration-300 ease-out hover:scale-105',
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
)}
>
<div
className={cn(
'p-2 shadow-xl rounded-xl border',
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
'flex items-center gap-x-2'
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={handleClearSelection}
className="size-6 rounded-full"
aria-label={t('mailbox.bulkActions.clearSelection')}
>
<X className="h-3 w-3" />
<span className="sr-only">{t('mailbox.bulkActions.clearSelection')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t('mailbox.bulkActions.clearSelectionWithKey', { key: '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">
{selectedCount > 1 ? t('mailbox.bulkActions.emails') : t('mailbox.bulkActions.email')}
</span>{' '}
{t('mailbox.bulkActions.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">{t('mailbox.bulkActions.delete')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t('mailbox.bulkActions.deleteTooltip')}</TooltipContent>
</Tooltip>
{children}
</div>
</div>
</>
);
}
@@ -1,109 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { IconAlertTriangle } from '@tabler/icons-react';
import { toast } from '@/hooks/use-toast';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { delete_messages } from '@/api/mailbox/envelope/api';
import { useMailboxContext } from '../context';
import { mapToRecordOfArrays } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function EnvelopeDeleteDialog({ open, onOpenChange }: Props) {
const queryClient = useQueryClient();
const { selectedAccountId, deleteIds, setDeleteIds } = useMailboxContext();
const { t } = useTranslation();
const deleteMutation = useMutation({
mutationFn: ({ payload }: { payload: Record<string, number[]> }) => delete_messages(payload),
retry: false,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mailbox-list-messages'] });
onOpenChange(false);
setDeleteIds(new Set());
toast({
title: t('mailbox.deleteDialog.successTitle'),
description: t('mailbox.deleteDialog.successDesc'),
});
},
onError: (error: any) => {
toast({
title: t('mailbox.deleteDialog.errorTitle'),
description: `${error.message}`,
variant: 'destructive',
});
},
});
const handleDelete = () => {
if (selectedAccountId) {
const body = new Map<number, Set<number>>();
body.set(selectedAccountId, deleteIds);
const payload = mapToRecordOfArrays(body);
deleteMutation.mutate({ payload });
}
};
const isLoading = deleteMutation.isPending;
const emailCount = deleteIds.size;
const countText =
emailCount > 1
? t('mailbox.deleteDialog.descCountMultiple', { count: emailCount })
: t('mailbox.deleteDialog.descCountSingle');
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}
/>{' '}
{t('mailbox.deleteDialog.title')}
</span>
}
desc={
<div className="space-y-4">
<p className="mb-2">
{t('mailbox.deleteDialog.desc', { countText })}
</p>
<Alert variant="destructive">
<AlertTitle>{t('mailbox.deleteDialog.warningTitle')}</AlertTitle>
<AlertDescription>{t('mailbox.deleteDialog.warningDesc')}</AlertDescription>
</Alert>
</div>
}
confirmText={t('mailbox.deleteDialog.confirm')}
destructive
/>
);
}
@@ -1,58 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
Sheet,
SheetContent,
SheetTitle
} from '@/components/ui/sheet'
import { useMailboxContext } from '../context'
import { MailMessageView } from './mail-message-view'
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
}
export function MailDisplayDrawer({ open, onOpenChange }: Props) {
const { currentEnvelope } = useMailboxContext();
const { t } = useTranslation()
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<VisuallyHidden asChild>
<SheetTitle />
</VisuallyHidden>
<SheetContent className="md:w-[80rem] h-full p-0">
<div className='m-5'>
{currentEnvelope ? (
<MailMessageView envelope={currentEnvelope} />
) : (
<div className="p-8 text-center text-muted-foreground">{t('mail.noMessageSelected')}</div>
)}
</div>
</SheetContent>
</Sheet>
);
}
@@ -1,230 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { formatDistanceToNow } from "date-fns"
import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { EmailEnvelope } from "@/api"
import { useMailboxContext } from "../context"
import { Checkbox } from "@/components/ui/checkbox"
import { MailBulkActions } from "./bulk-actions"
import { Badge } from "@/components/ui/badge"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
interface MailListProps {
items: EmailEnvelope[]
isLoading: boolean
}
export function MailList({
items,
isLoading,
}: MailListProps) {
const { t, i18n } = useTranslation()
const { currentEnvelope, setCurrentEnvelope, setDeleteIds, setOpen, selected, setSelected } = useMailboxContext()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const handleDelete = (envelope: EmailEnvelope) => {
setDeleteIds(new Set([envelope.id]))
setOpen("move-to-trash")
}
const totalSelected = selected.size;
const handleToggleAll = () => {
const total = selected.size;
if (total === items.length && items.length > 0) {
setSelected(new Set<number>());
} else {
const set = new Set<number>();
for (const item of items) {
set.add(item.id);
}
setSelected(set);
}
}
const hasSelected = (mailId: number) => {
return selected.has(mailId);
}
const toggleSelected = (id: number) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
});
}
if (isLoading) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1 max-w-xs" />
<Skeleton className="h-2.5 w-12 ml-auto" />
</div>
))}
</div>
)
}
return (
<div className="divide-y divide-border">
{items.length > 0 && (
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
<Checkbox
checked={
selected.size === items.length && items.length > 0
? true
: selected.size > 0
? "indeterminate"
: false
}
onCheckedChange={handleToggleAll}
className="h-4 w-4"
/>
<span className="text-xs text-muted-foreground">
{selected.size > 0
? `${t('search.bulkActions.selected', { count: selected.size })}`
: t('common.selectAll')}
</span>
</div>
)}
{items.map((item, index) => {
const hasAttachments = item.attachment_count > 0
const isSelected = currentEnvelope?.id === item.id
const isChecked = hasSelected(item.id)
return (
<div
key={index}
className={cn(
"group flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
"hover:bg-accent/50",
isSelected && "bg-accent"
)}
onClick={(e) => {
const target = e.target as HTMLElement
if (target.closest('input[type="checkbox"], button')) return
setCurrentEnvelope(item);
setOpen("display")
}}
>
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleSelected(item.id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0"
/>
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-1">
<div className="flex items-center gap-1 min-w-0">
<p className="text-sm font-medium truncate">{item.from}</p>
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
{item.subject}
</h3>
</div>
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
{item.subject}
</h3>
<div className="flex flex-wrap gap-1 mt-0.25">
{item.tags?.map((tag, i) => (
<Badge
key={i}
className="px-1 py-0.5 text-[10px] h-auto leading-none"
>
{tag}
</Badge>
))}
</div>
</div>
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
{hasAttachments && (
<div className="flex items-center gap-1">
<Paperclip className="h-3 w-3" />
<span>{item.attachment_count}</span>
</div>
)}
<span className="hidden md:inline">{formatBytes(item.size)}</span>
<span className={cn(
isSelected ? "text-foreground font-medium" : "text-muted-foreground"
)}>
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
setSelected(new Set([item.id]));
setOpen("restore");
}}
>
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('restore_message.restore_to_imap', 'Restore Mail')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
handleDelete(item);
}}
>
<Trash2 className="ml-2 h-3.5 w-3.5" />
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
})}
{totalSelected > 0 && <MailBulkActions />}
</div>
)
}
@@ -1,350 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileAudio, FileVideo, FileSpreadsheet, FileArchive, FileCode, FileIcon } 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, formatTimestamp } from '@/lib/utils';
import { useMailboxContext } from '../context';
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 { MailThreadDialog } from './thread-dialog';
import { useTranslation } from 'react-i18next';
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
interface MailMessageViewProps {
envelope: {
account_id: number;
id: number;
from?: string;
to?: string[];
cc?: string[];
bcc?: string[];
subject?: string;
internal_date?: number;
};
showActions?: boolean;
showHeader?: boolean;
showAttachments?: boolean;
}
const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines }) => {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false);
return (
<div className="text-xs">
<div className="flex items-start space-x-2">
<span className="font-medium text-gray-400 whitespace-nowrap">{title}:</span>
<div className="flex-1">
<ul className="list-disc list-inside">
{lines.slice(0, expanded ? lines.length : 3).map((ref, i) => (
<li key={i} className="line-clamp-1">{ref}</li>
))}
</ul>
{lines.length > 3 && (
<button
className="text-blue-500 hover:underline text-xs"
onClick={() => setExpanded(!expanded)}
>
{expanded ? t('common.showLess') : t('common.showMore')}
</button>
)}
</div>
</div>
</div>
);
};
const getFileConfig = (mimeType: string) => {
const type = mimeType.toLowerCase();
if (type.includes('pdf')) {
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
}
if (type.includes('image/')) {
return { icon: <FileImage className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50 border-blue-100' };
}
if (type.includes('audio/')) {
return { icon: <FileAudio className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50 border-purple-100' };
}
if (type.includes('video/')) {
return { icon: <FileVideo className="h-4 w-4" />, color: 'text-indigo-600 bg-indigo-50 border-indigo-100' };
}
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {
return { icon: <FileSpreadsheet className="h-4 w-4" />, color: 'text-green-600 bg-green-50 border-green-100' };
}
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) {
return { icon: <FileArchive className="h-4 w-4" />, color: 'text-orange-600 bg-orange-50 border-orange-100' };
}
if (type.includes('text/') || type.includes('json') || type.includes('javascript')) {
return { icon: <FileCode className="h-4 w-4" />, color: 'text-slate-600 bg-slate-50 border-slate-100' };
}
return { icon: <FileIcon className="h-4 w-4" />, color: 'text-gray-600 bg-gray-50 border-gray-100' };
};
export function MailMessageView({
envelope,
showActions = true,
showAttachments = true,
showHeader = true
}: MailMessageViewProps) {
const { t } = useTranslation()
const { selectedAccountId, setDeleteIds, setOpen } = useMailboxContext();
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(selectedAccountId!, envelope.id, fileName),
onSuccess: () => setDownloadingAttachmentFileName(null),
onError: (error: any) => {
setDownloadingAttachmentFileName(null);
toast({
title: t('mail.failedToDownloadFile'),
description: error.message,
variant: 'destructive',
});
},
});
const loadMessageMutation = useMutation({
mutationFn: () => load_message(selectedAccountId!, envelope.id),
onSuccess: (data) => {
setLoading(false);
setContent(getContent(data));
if (data.attachments) setAttachments(data.attachments);
setContentType(data.html ? 'Html' : 'Plain');
},
onError: (error: any) => {
setLoading(false);
toast({
title: t('mail.failedToLoadEmail'),
description: error.message,
variant: 'destructive',
});
},
});
useEffect(() => {
setLoading(true);
loadMessageMutation.mutate();
}, [envelope.id]);
const handleDelete = () => {
setDeleteIds(new Set([envelope.id]));
setOpen('move-to-trash');
};
const downloadEmlFile = async () => {
try {
toast({ title: t('mail.downloadStarted'), description: t('mail.isBeingDownloaded', { id: envelope.id }) });
await download_message(selectedAccountId!, envelope.id);
toast({ title: t('mail.downloadComplete'), description: t('mail.downloaded', { id: envelope.id }) });
} catch (error) {
let msg = t('mail.downloadFailed');
if (error instanceof AxiosError) {
msg = error.response?.data?.message || error.response?.data?.error || error.message;
if (error.response?.status) msg = `${error.response.status}: ${msg}`;
} else if (error instanceof Error) {
msg = error.message;
}
toast({ title: t('mail.downloadFailed'), description: msg, variant: 'destructive' });
}
};
return (
<div className="flex flex-col h-full">
{/* Header Info */}
{showHeader && <div className="grid gap-1 text-xs">
<div className="flex space-x-2">
<span className="font-medium text-gray-400">{t('mail.account')}:</span>
<span>{getEmailById(envelope.account_id)}</span>
</div>
<div className="flex space-x-2">
<span className="font-medium text-gray-400">{t('mail.id')}:</span>
<span>{envelope.id}</span>
</div>
{envelope.from && (
<div className="flex space-x-2">
<span className="font-medium text-gray-400">{t('mail.from')}:</span>
<span>{envelope.from}</span>
</div>
)}
{envelope.to && envelope.to.length > 0 && <Multilines title={t('mail.to')} lines={envelope.to} />}
{envelope.cc && envelope.cc.length > 0 && <Multilines title={t('mail.cc')} lines={envelope.cc} />}
{envelope.bcc && envelope.bcc.length > 0 && <Multilines title={t('mail.bcc')} lines={envelope.bcc} />}
{envelope.subject && (
<div className="flex space-x-2">
<span className="font-medium text-gray-400">{t('mail.subject')}:</span>
<span>{envelope.subject}</span>
</div>
)}
{envelope.internal_date && (
<div className="flex space-x-2">
<span className="font-medium text-gray-400">{t('mail.date')}:</span>
<span>{formatTimestamp(envelope.internal_date)}</span>
</div>
)}
</div>}
{/* 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>{t('mail.delete')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={downloadEmlFile}>
<Download className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('mail.download')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setThreadOpen(true)}
>
<MessageSquareMore className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('mail.viewThread')}</TooltipContent>
</Tooltip>
</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) => {
const { icon, color } = getFileConfig(attachment.file_type);
return <div key={i} className="flex items-center">
<div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
<div className={`flex-shrink-0 ${color}`}>
{icon}
</div>
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
<span
className="truncate text-xs font-medium text-foreground/90"
title={attachment.filename}
>
{attachment.filename}
</span>
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase tracking-tighter group-hover:text-foreground transition-colors">
{attachment.file_type.split('/').pop()?.toUpperCase()}
</span>
</div>
</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">
{t('mail.onlyNonInlineAttachments')}
</span>
);
})()
) : (
<span className="text-gray-500 text-xs">{t('mail.noAttachments')}</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>
);
}
@@ -1,468 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import * as React from "react"
import { cn } from "@/lib/utils"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { Separator } from "@/components/ui/separator"
import { TooltipProvider } from "@/components/ui/tooltip"
import { AccountSwitcher } from "./account-switcher"
import { ScrollArea } from "@/components/ui/scroll-area"
import { list_mailboxes, MailboxData } from "@/api/mailbox/api"
import { useQuery } from "@tanstack/react-query"
import { Skeleton } from "@/components/ui/skeleton"
import MailboxProvider, { MailboxDialogType } from "../context"
import useDialogState from "@/hooks/use-dialog-state"
import { MailboxDialog } from "./mailbox-detail"
import { MailList } from "./mail-list"
import { list_messages } from "@/api/mailbox/envelope/api"
import { MailDisplayDrawer } from "./mail-display-drawer"
import { toast } from "@/hooks/use-toast"
import { EnvelopeDeleteDialog } from "./delete-dialog"
import Logo from '@/assets/logo.svg'
import { EmailEnvelope } from "@/api"
import { EnvelopeListPagination } from "@/components/pagination"
import { RichTreeView, TreeItemCheckbox, TreeItemContent, TreeItemDragAndDropOverlay, TreeItemIcon, TreeItemIconContainer, TreeItemLabel, TreeItemProvider, TreeItemRoot, useTreeItem, useTreeItemModel, UseTreeItemParameters } from "@mui/x-tree-view"
import { buildTree, ExtendedTreeItemProps } from "@/lib/build-tree"
import { useTheme } from "@/context/theme-context"
import { styled } from "@mui/material/styles"
import { animated, useSpring } from "@react-spring/web"
import { TransitionProps } from "@mui/material/transitions"
import Collapse from "@mui/material/Collapse"
import { FolderIcon, MoreVertical, Trash2 } from "lucide-react"
import { RestoreMessageDialog } from "./restore-message-dialog"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { useTranslation } from "react-i18next"
import { MailBoxDeleteDialog } from "./delete-mailbox-dialog"
interface MailProps {
defaultLayout: number[] | undefined
defaultCollapsed?: boolean
navCollapsedSize: number,
lastSelectedAccountId?: number | undefined
}
interface ListMessagesOptions {
accountId: number | undefined;
mailboxId: number | undefined;
page: number;
page_size: number;
}
const useListMessages = ({ accountId, mailboxId, page, page_size }: ListMessagesOptions) => {
return useQuery({
queryKey: ['mailbox-list-messages', `${accountId}`, mailboxId, page, page_size],
queryFn: () => {
return list_messages(accountId!, mailboxId!, page, page_size);
},
enabled: !!accountId && !!mailboxId,
retry: 0,
staleTime: 1000,
});
};
interface CustomLabelProps {
exists?: number;
attributes?: { attr: string; extension: string | null }[],
children: React.ReactNode;
id: string;
icon?: React.ElementType;
expandable?: boolean;
onDelete: (id: string) => void;
}
function CustomLabel({
expandable,
exists,
attributes,
children,
id,
onDelete,
...other
}: CustomLabelProps) {
const { t } = useTranslation()
return (
<TreeItemLabel
{...other}
sx={{
display: 'flex',
alignItems: 'center',
}}
>
<FolderIcon className="mr-2" />
<span className="font-medium text-sm text-inherit">
{children}
</span>
<div className="ml-auto flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-20">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
}}
onSelect={(e) => {
e.preventDefault();
onDelete(id);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t('common.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TreeItemLabel>
);
}
const CustomCollapse = styled(Collapse)({
padding: 0,
});
const AnimatedCollapse = animated(CustomCollapse);
function TransitionComponent(props: TransitionProps) {
const style = useSpring({
to: {
opacity: props.in ? 1 : 0,
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
},
});
return <AnimatedCollapse style={style} {...props} />;
}
interface CustomTreeItemProps
extends Omit<UseTreeItemParameters, 'rootRef'>,
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
export function Mail({
defaultLayout = [20, 80],
defaultCollapsed = false,
navCollapsedSize,
lastSelectedAccountId,
}: MailProps) {
const [open, setOpen] = useDialogState<MailboxDialogType>(null)
const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed)
const [selectedMailbox, setSelectedMailbox] = React.useState<MailboxData | undefined>(undefined);
const [selectedAccountId, setSelectedAccountId] = React.useState<number | undefined>(lastSelectedAccountId);
const [selectedEvelope, setSelectedEvelope] = React.useState<EmailEnvelope | undefined>(undefined);
const [page, setPage] = React.useState(0);
const [pageSize, setPageSize] = React.useState(30);
const [deleteIds, setDeleteIds] = React.useState<Set<number>>(() => new Set());
const [selected, setSelected] = React.useState<Set<number>>(() => new Set());
const [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
const { theme } = useTheme()
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
queryKey: ['account-mailboxes', `${selectedAccountId}`],
queryFn: () => list_mailboxes(selectedAccountId!, false),
enabled: !!selectedAccountId,
})
const tree = buildTree(mailboxes ?? []);
const { data: envelopes, isLoading: isMessagesLoading, isError, error } = useListMessages({
accountId: selectedAccountId,
mailboxId: selectedMailbox?.id,
page: page + 1,
page_size: pageSize
});
const hasNextPage = () => {
return page + 1 < envelopes?.total_pages!;
}
const handlePageChange = (newPage: number) => {
setPage(newPage);
}
const handlePageSizeChange = (newSize: number) => {
setPage(0);
setPageSize(newSize);
}
React.useEffect(() => {
if (isError && error) {
toast({
variant: "destructive",
title: "Failed to load messages",
description: error.message || "An unknown error occurred. Please try again.",
});
}
}, [isError, error]);
// const handleItemSelectionToggle = (
// _event: React.SyntheticEvent | null,
// itemId: string,
// isSelected: boolean,
// ) => {
// if (isSelected) {
// setSelectedMailbox(mailboxes?.find(m => String(m.id) === itemId))
// setPage(0);
// }
// };
const handleItemClick = (
_event: React.SyntheticEvent | null,
itemId: string
) => {
//console.log(itemId)
setSelectedMailbox(mailboxes?.find(m => String(m.id) === itemId))
setPage(0);
};
const handleDeleteClick = (id: string) => {
setDeleteMailboxId(id);
setOpen('delete');
};
const CustomTreeItem = React.useMemo(() => {
return React.forwardRef(function CustomTreeItem(
props: CustomTreeItemProps,
ref: React.Ref<HTMLLIElement>,
) {
const { id, itemId, label, disabled, children, ...other } = props;
const {
getContextProviderProps,
getRootProps,
getContentProps,
getIconContainerProps,
getCheckboxProps,
getLabelProps,
getGroupTransitionProps,
getDragAndDropOverlayProps,
status,
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
return (
<TreeItemProvider {...getContextProviderProps()}>
<TreeItemRoot {...getRootProps(other)}>
<TreeItemContent {...getContentProps()}>
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIcon status={status} />
</TreeItemIconContainer>
<TreeItemCheckbox {...getCheckboxProps()} />
<CustomLabel
{...getLabelProps({
exists: item.exists,
id: item.id,
onDelete: handleDeleteClick,
attributes: item.attributes,
expandable: status.expandable && status.expanded,
})}
/>
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
</TreeItemContent>
{children && <TransitionComponent {...getGroupTransitionProps()} />}
</TreeItemRoot>
</TreeItemProvider>
);
});
}, [theme]);
return (
<MailboxProvider value={{
open,
setOpen,
currentMailbox: selectedMailbox,
selectedAccountId,
setCurrentMailbox: setSelectedMailbox,
currentEnvelope: selectedEvelope,
setCurrentEnvelope: setSelectedEvelope,
deleteIds,
setDeleteIds,
selected,
setSelected,
deleteMailboxId,
setDeleteMailboxId
}}>
<TooltipProvider delayDuration={0}>
<ResizablePanelGroup
direction="horizontal"
onLayout={(sizes: number[]) => {
localStorage.setItem('react-resizable-panels:layout:mail', JSON.stringify(sizes));
}}
className="items-stretch"
>
<ResizablePanel
defaultSize={defaultLayout[0]}
collapsedSize={navCollapsedSize}
minSize={navCollapsedSize}
collapsible={true}
onCollapse={() => {
setIsCollapsed(true);
localStorage.setItem('react-resizable-panels:collapsed', JSON.stringify(true));
}}
onResize={() => {
setIsCollapsed(false);
localStorage.setItem('react-resizable-panels:collapsed', JSON.stringify(false));
}}
className={cn(
isCollapsed &&
"min-w-[50px] transition-all duration-300 ease-in-out"
)}
>
<Separator className="mb-2" />
<ScrollArea className='h-[calc(100vh-8rem)] w-full pr-4 -mr-4 py-1'>
<div>
<AccountSwitcher onAccountSelect={(accountId) => {
localStorage.setItem('mailbox:selectedAccountId', `${accountId}`);
setSelectedAccountId(accountId);
setSelectedMailbox(undefined);
}} defaultAccountId={lastSelectedAccountId} />
</div>
<Separator className="mt-2" />
{isMailboxesLoading ? (
<div className="space-y-2 p-4">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="space-y-2">
<div className="flex items-center space-x-2">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 w-[200px]" />
</div>
<div className="pl-6 space-y-2">
{Array.from({ length: 3 }).map((_, subIndex) => (
<div key={subIndex} className="flex items-center space-x-2">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 w-[150px]" />
</div>
))}
</div>
</div>
))}
</div>
) : (
<RichTreeView
//checkboxSelection
items={tree}
expansionTrigger="iconContainer"
onItemClick={handleItemClick}
slots={{ item: CustomTreeItem }}
/>
)}
</ScrollArea>
</ResizablePanel>
<ResizableHandle withHandle className="h-[calc(100vh-7rem)]" />
<ResizablePanel defaultSize={defaultLayout[1]}>
{selectedMailbox && <div>
<Separator />
<div className="flex items-center px-4 py-2">
<h2 className="text-xl font-bold cursor-pointer hover:underline" onClick={() => setOpen("mailbox")}>
{selectedMailbox?.name}
</h2>
</div>
<Separator />
<div className="mt-2">
<ScrollArea className='h-[calc(100vh-14rem)] w-full pr-4 -mr-4 py-1'>
<MailList
isLoading={isMessagesLoading}
items={(envelopes?.items ?? []).sort((a, b) => {
const dateA = a.date;
const dateB = b.date;
return dateB - dateA;
})}
/>
</ScrollArea>
{selectedMailbox && <div className="flex justify-center mt-4">
<EnvelopeListPagination
totalItems={envelopes?.total_items ?? 0}
hasNextPage={hasNextPage}
pageIndex={page}
pageSize={pageSize}
setPageIndex={handlePageChange}
setPageSize={handlePageSizeChange}
/>
</div>}
</div>
</div>}
{!selectedMailbox && <div className="flex h-[750px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className='mb-6 opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100'
width={350}
height={350}
alt='Bichon Logo'
/>
</div>
</div>
}
</ResizablePanel>
</ResizablePanelGroup>
</TooltipProvider>
<MailboxDialog
key='mailbox-detail'
open={open === 'mailbox'}
onOpenChange={() => setOpen('mailbox')}
/>
<MailDisplayDrawer
key='mail-display'
open={open === 'display'}
onOpenChange={() => setOpen('display')}
/>
<EnvelopeDeleteDialog
key='envelope-move-to-trash'
open={open === 'move-to-trash'}
onOpenChange={() => setOpen('move-to-trash')}
/>
<RestoreMessageDialog
key='envelope-restore'
open={open === 'restore'}
onOpenChange={() => setOpen('restore')}
/>
<MailBoxDeleteDialog
key='mailbox-delete'
open={open === 'delete'}
onOpenChange={() => setOpen('delete')}
/>
</MailboxProvider >
)
}
@@ -1,92 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import AceEditor from '@/components/ace-editor'
import { useTheme } from '@/context/theme-context'
import { MailboxData } from '@/api/mailbox/api'
import { useMailboxContext } from '../context'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
}
function convertMailboxData(raw: MailboxData): any {
const attributes: string[] = [];
raw.attributes.forEach(item => {
attributes.push(item.attr);
if (item.attr.toLowerCase() === "Extension" && item.extension !== null) {
attributes.push(item.extension);
}
});
return {
// ...raw,
id: raw.id.toString(),
attributes
};
}
export function MailboxDialog({ open, onOpenChange }: Props) {
const { theme } = useTheme()
const { currentMailbox } = useMailboxContext()
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={(state) => {
onOpenChange(state)
}}
>
<DialogContent className='w-full md:max-w-xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{currentMailbox?.name}</DialogTitle>
</DialogHeader>
<AceEditor
readOnly={true}
value={currentMailbox
? JSON.stringify(convertMailboxData(currentMailbox), null, 2)
: 'null'}
className="h-[14rem]"
mode='json'
theme={theme === "dark" ? 'monokai' : 'kuroir'}
/>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">{t('common.close')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,106 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { restore_message } from '@/api/mailbox/envelope/api'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { toast } from '@/hooks/use-toast'
import { useMutation } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
import { useMailboxContext } from '../context'
import { ToastAction } from '@/components/ui/toast'
interface RestoreMessageDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function RestoreMessageDialog({
open,
onOpenChange
}: RestoreMessageDialogProps) {
const { t } = useTranslation()
const { selectedAccountId, selected, setSelected } = useMailboxContext();
const restoreMutation = useMutation({
mutationFn: (messageIds: number[]) =>
restore_message(selectedAccountId!, messageIds),
onSuccess: handleRestoreSuccess,
onError: handleRestoreError,
});
function handleRestoreSuccess() {
toast({
title: t('restore_message.success', 'Messages restored'),
description: t(
'restore_message.successDesc',
'The selected messages have been restored to the IMAP server.'
),
action: (
<ToastAction altText={t('common.close')}>
{t('common.close')}
</ToastAction>
),
});
setSelected(new Set());
onOpenChange(false);
}
function handleRestoreError(error: AxiosError) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
t('restore_message.failed', 'Failed to restore messages');
toast({
variant: 'destructive',
title: t(
'restore_message.failedTitle',
'Restore failed'
),
description: errorMessage,
action: (
<ToastAction altText={t('common.tryAgain')}>
{t('common.tryAgain')}
</ToastAction>
),
});
console.error(error);
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={t('restore_message.title', 'Restore messages')}
desc={t(
'restore_message.desc',
'This action will append the selected messages from Bichon to their corresponding mailboxes on the IMAP server.'
)}
confirmText={t('restore_message.confirm', 'Restore')}
handleConfirm={() => restoreMutation.mutate(Array.from(selected))}
className="sm:max-w-sm"
isLoading={restoreMutation.isPending}
disabled={restoreMutation.isPending}
/>
)
}
@@ -1,223 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useState } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { ChevronDown, ChevronUp, Loader2, MessageSquareText } from 'lucide-react';
import { useTranslation } from 'react-i18next';
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 { useMailboxContext } from '../context';
import { get_thread_messages } from '@/api/mailbox/envelope/api';
import { MailMessageView } from './mail-message-view';
interface MailThreadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps) {
const { t } = useTranslation();
const { selectedAccountId, currentEnvelope } = useMailboxContext();
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const threadId = currentEnvelope?.thread_id;
const accountId = selectedAccountId;
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">
{t('mailbox.thread.title', {
count: totalCount,
messageLabel: totalCount === 1 ? t('mailbox.thread.message') : t('mailbox.thread.messages')
})}
</div>
</DialogTitle>
</div>
</DialogHeader>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{isLoading && <ThreadSkeleton />}
{isError && (
<div className="text-center text-destructive text-sm">
{t('mailbox.thread.loadError')}: {(error as Error)?.message}
</div>
)}
{!isLoading && allMessages.length === 0 && (
<div className="text-center text-muted-foreground text-sm">
{t('mailbox.thread.empty')}
</div>
)}
{allMessages
.sort((a, b) => a.date - b.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.date);
const formattedDate = isNaN(date.getTime())
? t('mailbox.thread.invalidDate')
: format(date, 'yyyy-MM-dd HH:mm:ss');
return (
<Card
key={msg.id}
className={`transition-all ${isExpanded ? 'ring-2 ring-primary' : ''}`}
>
<CardHeader
className="cursor-pointer pb-3"
onClick={() => toggleExpand(msg.id)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium truncate">{msg.from}</span>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground truncate">
{msg.to.join(', ')}
</span>
</div>
<p className="font-medium mt-1 text-sm">
{msg.subject || t('mailbox.thread.noSubject')}
</p>
{!isExpanded && preview && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{preview}
</p>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formattedDate}</span>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</div>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="p-0">
<div className="h-96 border-t m-5">
<MailMessageView
envelope={msg}
showActions={false}
showAttachments={false}
showHeader={false}
/>
</div>
</CardContent>
)}
</Card>
);
})}
{hasNextPage && (
<div className="flex justify-center py-3">
<Button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
variant="outline"
size="sm"
>
{isFetchingNextPage ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('mailbox.thread.loadingMore')}
</>
) : (
t('mailbox.thread.loadMore')
)}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// Skeleton
function ThreadSkeleton() {
return (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-4 w-48 mb-2" />
<Skeleton className="h-5 w-64 mb-1" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-32 mt-2" />
</CardHeader>
</Card>
))}
</div>
);
}
@@ -1,63 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from 'react'
import { MailboxData } from '@/api/mailbox/api'
import { EmailEnvelope } from '@/api'
export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters' | 'restore' | 'delete'
interface MailboxContextType {
open: MailboxDialogType | null
setOpen: (str: MailboxDialogType | null) => void
selectedAccountId: number | undefined
currentMailbox: MailboxData | undefined
currentEnvelope: EmailEnvelope | undefined
setCurrentMailbox: React.Dispatch<React.SetStateAction<MailboxData | undefined>>
deleteMailboxId: string | undefined,
setDeleteMailboxId: React.Dispatch<React.SetStateAction<string | undefined>>
setCurrentEnvelope: React.Dispatch<React.SetStateAction<EmailEnvelope | undefined>>
deleteIds: Set<number>
setDeleteIds: React.Dispatch<React.SetStateAction<Set<number>>>
selected: Set<number>
setSelected: React.Dispatch<React.SetStateAction<Set<number>>>
}
const MailboxContext = React.createContext<MailboxContextType | null>(null)
interface Props {
children: React.ReactNode
value: MailboxContextType
}
export default function MailboxProvider({ children, value }: Props) {
return <MailboxContext.Provider value={value}>{children}</MailboxContext.Provider>
}
export const useMailboxContext = () => {
const mailboxContext = React.useContext(MailboxContext)
if (!mailboxContext) {
throw new Error(
'useMailboxContext has to be used within <MailboxContext.Provider>'
)
}
return mailboxContext
}
-46
View File
@@ -1,46 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Mail } from "./components/mail"
import { Main } from "@/components/layout/main"
import { FixedHeader } from "@/components/layout/fixed-header"
export default function Mailboxes() {
const layout = localStorage.getItem("react-resizable-panels:layout:mail")
const collapsed = localStorage.getItem("react-resizable-panels:collapsed")
const defaultLayout = layout ? JSON.parse(layout) : undefined
const defaultCollapsed = collapsed ? JSON.parse(collapsed) : undefined
const lastSelectedAccountId = localStorage.getItem('mailbox:selectedAccountId') ?? undefined
return (
<>
<FixedHeader />
<Main>
<Mail
defaultLayout={defaultLayout}
defaultCollapsed={defaultCollapsed}
lastSelectedAccountId={lastSelectedAccountId ? parseInt(lastSelectedAccountId) : undefined}
navCollapsedSize={2}
/>
</Main>
</>
)
}
@@ -1,11 +0,0 @@
import { AccountPopover } from './account-popover'
import { MailboxPopover } from './mailbox-popover'
export function AccountMailboxFilter() {
return (
<>
<AccountPopover />
<MailboxPopover />
</>
)
}
+1 -16
View File
@@ -138,7 +138,6 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
'flex items-center gap-x-2'
)}
>
{/* Clear Selection */}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -153,19 +152,12 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
</Button>
</TooltipTrigger>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
{/* Selected Count */}
<div className="flex items-center gap-x-1 text-sm">
<Badge variant="default" className="min-w-8 rounded-lg">
{selectedCount}
</Badge>{' '}
<span className="hidden sm:inline">
{t('search.bulkActions.selected', { count: selectedCount })}
</span>
</div>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
@@ -176,18 +168,14 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
className="gap-1"
>
<Upload className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
{t('restore_message.restore_to_imap', 'Restore Mail')}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{t('search.bulkActions.restoreDesc')}
{t('restore_message.restore_to_imap', 'Restore Mail')}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
{/* Delete */}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -197,9 +185,6 @@ export function MailBulkActions({ children }: MailBulkActionsProps) {
className="gap-1"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
{t('search.bulkActions.delete')}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
+5 -1
View File
@@ -21,7 +21,7 @@ import React from 'react'
import { EmailEnvelope } from '@/api'
import { SortingState } from '@tanstack/react-table'
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'restore'
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'restore' | 'delete-mailbox'
interface SearchContextType {
open: SearchDialogType | null
@@ -32,6 +32,10 @@ interface SearchContextType {
setToDelete: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
selected: Map<number, Set<number>>
setSelected: React.Dispatch<React.SetStateAction<Map<number, Set<number>>>>
deleteMailboxId: string | undefined
setDeleteMailboxId: React.Dispatch<React.SetStateAction<string | undefined>>
selectedAccountId: number | undefined
setSelectedAccountId: React.Dispatch<React.SetStateAction<number | undefined>>
selectedTags: string[]
sorting: SortingState
setSorting: React.Dispatch<React.SetStateAction<SortingState>>
@@ -21,9 +21,9 @@ 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 { useMailboxContext } from '../context';
import { useTranslation } from 'react-i18next';
import { delete_mailbox } from '@/api/mailbox/api';
import { useSearchContext } from './context';
interface Props {
open: boolean;
@@ -32,7 +32,7 @@ interface Props {
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
const queryClient = useQueryClient();
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useMailboxContext();
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useSearchContext();
const { t } = useTranslation();
const deleteMutation = useMutation({
@@ -40,7 +40,7 @@ export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
delete_mailbox(accountId, mailboxId),
retry: false,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['account-mailboxes', `${selectedAccountId}`] });
queryClient.invalidateQueries({ queryKey: ['search-mailboxes', selectedAccountId] });
onOpenChange(false);
setDeleteMailboxId(undefined);
toast({
+13
View File
@@ -33,6 +33,7 @@ import { useTranslation } from 'react-i18next';
import { RestoreMessageDialog } from './restore-message-dialog';
import { MailListTable } from './mail-list-table';
import { SortingState } from '@tanstack/react-table';
import { MailBoxDeleteDialog } from './delete-mailbox-dialog';
export default function Search() {
const { t } = useTranslation()
@@ -42,6 +43,8 @@ export default function Search() {
const [selected, setSelected] = React.useState<Map<number, Set<number>>>(new Map());
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
const [deleteMailboxId, setDeleteMailboxId] = React.useState<string | undefined>(undefined);
const [selectedAccountId, setSelectedAccountId] = React.useState<number | undefined>(undefined);
const {
emails,
@@ -90,6 +93,10 @@ export default function Search() {
setSorting,
filter,
setFilter,
deleteMailboxId,
setDeleteMailboxId,
selectedAccountId,
setSelectedAccountId,
handleTagToggle
}}
>
@@ -152,6 +159,12 @@ export default function Search() {
open={open === 'restore'}
onOpenChange={() => setOpen('restore')}
/>
<MailBoxDeleteDialog
key='mailbox-delete'
open={open === 'delete-mailbox'}
onOpenChange={() => setOpen('delete-mailbox')}
/>
</SearchProvider>
</Main>
</>
+1 -1
View File
@@ -252,7 +252,7 @@ export function MailList({
}}
>
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('restore_message.restore_to_imap', 'Restore Mail')}
{t('restore_message.restore_to_imap')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
+331 -214
View File
@@ -1,263 +1,380 @@
import * as React from 'react'
import { ChevronDown, Folders, X } from 'lucide-react'
import { useQueries } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import * as React from 'react';
import {
ChevronDown, Folders, X, TreeDeciduous, FolderIcon,
MoreVertical, Trash2, Search,
Check
} from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { animated, useSpring } from '@react-spring/web';
import { styled } from '@mui/material/styles';
import Collapse from '@mui/material/Collapse';
import { TransitionProps } from '@mui/material/transitions';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
TreeItemCheckbox,
TreeItemContent,
TreeItemDragAndDropOverlay,
TreeItemIcon,
TreeItemIconContainer,
TreeItemLabel,
TreeItemProvider,
TreeItemRoot,
useTreeItemModel,
} from '@mui/x-tree-view';
import { RichTreeView } from '@mui/x-tree-view/RichTreeView';
import { useTreeItem, UseTreeItemParameters } from '@mui/x-tree-view/useTreeItem';
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { list_mailboxes, MailboxData } from '@/api/mailbox/api'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { useSearchContext } from './context'
import { list_mailboxes } from '@/api/mailbox/api';
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
import { useSearchContext } from './context';
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree';
const CustomCollapse = styled(Collapse)({ padding: 0 });
const AnimatedCollapse = animated(CustomCollapse);
function TransitionComponent(props: TransitionProps) {
const style = useSpring({
to: {
opacity: props.in ? 1 : 0,
transform: `translate3d(0,${props.in ? 0 : 20}px,0)`,
},
});
return <AnimatedCollapse style={style} {...props} />;
}
interface CustomTreeItemProps
extends Omit<UseTreeItemParameters, 'rootRef'>,
Omit<React.HTMLAttributes<HTMLLIElement>, 'onFocus'> { }
interface CustomLabelProps {
exists?: number;
attributes?: { attr: string; extension: string | null }[],
children: React.ReactNode;
id: string;
icon?: React.ElementType;
expandable?: boolean;
onDelete: (id: string) => void;
}
function CustomLabel({
expandable,
exists,
attributes,
children,
id,
onDelete,
...other
}: CustomLabelProps) {
const { t } = useTranslation()
return (
<TreeItemLabel
{...other}
sx={{
display: 'flex',
alignItems: 'center',
}}
>
<FolderIcon className="mr-2" />
<span className="font-medium text-sm text-inherit">
{children}
</span>
<div className="ml-auto flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-20">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
}}
onSelect={(e) => {
e.preventDefault();
onDelete(id);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t('common.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TreeItemLabel>
);
}
export function MailboxPopover() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const { minimalList = [] } = useMinimalAccountList()
const { t } = useTranslation();
const { filter, setFilter, setOpen, setDeleteMailboxId, setSelectedAccountId } = useSearchContext();
const { minimalList = [] } = useMinimalAccountList();
const [search, setSearch] = React.useState('')
const [localOpen, setLocalOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const accountIds: number[] = filter.account_ids ?? []
const selectedMailboxIds: number[] = filter.mailbox_ids ?? []
const accountIds: number[] = filter.account_ids ?? [];
const selectedMailboxIds: number[] = filter.mailbox_ids ?? [];
const { mailboxes, isLoading } = useQueries({
queries: accountIds.map(id => ({
queryKey: ['search-mailboxes', id],
queryFn: () => list_mailboxes(id, false),
enabled: accountIds.length > 0,
})),
combine: results => ({
mailboxes: results.flatMap(r => r.data ?? []),
isLoading: results.some(r => r.isLoading),
}),
})
const [localSelectedIds, setLocalSelectedIds] = React.useState<number[]>([]);
const [activeAccountId, setActiveAccountId] = React.useState<number | undefined>(undefined);
const toggleMailbox = (id: number) => {
setFilter(prev => {
const next = { ...prev }
const set = new Set<number>(next.mailbox_ids ?? [])
const queryClient = useQueryClient();
set.has(id) ? set.delete(id) : set.add(id)
React.useEffect(() => {
if (localOpen) {
const globalMailboxIds = filter.mailbox_ids ?? [];
setLocalSelectedIds(globalMailboxIds);
const ids = Array.from(set)
if (ids.length === 0) delete next.mailbox_ids
else next.mailbox_ids = ids
return next
})
}
const clearAllMailboxes = () => {
setFilter(prev => {
const next = { ...prev }
delete next.mailbox_ids
return next
})
}
const grouped = React.useMemo(() => {
const q = search.trim().toLowerCase()
const map = new Map<number, MailboxData[]>()
for (const mb of mailboxes) {
if (q && !mb.name.toLowerCase().includes(q)) continue
if (!map.has(mb.account_id)) map.set(mb.account_id, [])
map.get(mb.account_id)!.push(mb)
const currentAccountIds = filter.account_ids ?? [];
if (currentAccountIds.length > 0) {
if (!activeAccountId || !currentAccountIds.includes(activeAccountId)) {
setActiveAccountId(currentAccountIds[0]);
}
} else {
setActiveAccountId(undefined);
}
}
}, [localOpen, activeAccountId, filter.account_ids, filter.mailbox_ids]);
for (const list of map.values()) {
list.sort((a, b) => {
const aSel = selectedMailboxIds.includes(a.id)
const bSel = selectedMailboxIds.includes(b.id)
if (aSel && !bSel) return -1
if (!aSel && bSel) return 1
return a.name.localeCompare(b.name)
})
}
const { data: activeMailboxes = [], isLoading: activeIsLoading } = useQuery({
queryKey: ['search-mailboxes', activeAccountId],
queryFn: () => list_mailboxes(activeAccountId!, false),
enabled: !!activeAccountId,
});
return Array.from(map.entries())
}, [mailboxes, search, selectedMailboxIds])
const treeData = React.useMemo(() => {
const filtered = search.trim()
? activeMailboxes.filter(m => m.name.toLowerCase().includes(search.toLowerCase()))
: activeMailboxes;
return buildTree(filtered);
}, [activeMailboxes, search]);
const defaultOpen = grouped
.filter(([, boxes]) =>
boxes.some(m => selectedMailboxIds.includes(m.id))
)
.map(([id]) => id.toString())
const disabled = accountIds.length === 0;
const getAccountEmail = (id: number) =>
minimalList.find(a => a.id === id)?.email ?? ''
const handleApply = () => {
setFilter(prev => ({
...prev,
mailbox_ids: localSelectedIds.length > 0 ? localSelectedIds : undefined
}));
setLocalOpen(false);
};
const disabled = accountIds.length === 0
const handleDeleteClick = (id: string) => {
console.log("delete=", id);
setDeleteMailboxId(id);
setSelectedAccountId(activeAccountId);
setOpen('delete-mailbox');
};
const CustomTreeItem = React.forwardRef(function CustomTreeItem(
props: CustomTreeItemProps,
ref: React.Ref<HTMLLIElement>,
) {
const { id, itemId, label, disabled, children, ...other } = props;
const {
getContextProviderProps,
getRootProps,
getContentProps,
getLabelProps,
getIconContainerProps,
getCheckboxProps,
getGroupTransitionProps,
getDragAndDropOverlayProps,
status,
} = useTreeItem({ id, itemId, children, label, disabled, rootRef: ref });
const item = useTreeItemModel<ExtendedTreeItemProps>(itemId)!;
return (
<TreeItemProvider {...getContextProviderProps()}>
<TreeItemRoot {...getRootProps(other)} className="group">
<TreeItemContent {...getContentProps()} sx={{ paddingY: '2px' }}>
<TreeItemIconContainer {...getIconContainerProps()}>
<TreeItemIcon status={status} />
</TreeItemIconContainer>
<TreeItemCheckbox {...getCheckboxProps()} sx={{
color: 'hsl(var(--muted-foreground) / 0.4)',
'&.Mui-checked': {
color: 'hsl(var(--primary))',
},
}} />
<CustomLabel
{...getLabelProps({
exists: item.exists,
id: item.id,
onDelete: handleDeleteClick,
attributes: item.attributes,
expandable: status.expandable && status.expanded,
})}
/>
<TreeItemDragAndDropOverlay {...getDragAndDropOverlayProps()} />
</TreeItemContent>
{children && <TransitionComponent {...getGroupTransitionProps()} />}
</TreeItemRoot>
</TreeItemProvider>
);
});
return (
<Popover>
<Popover open={localOpen} onOpenChange={setLocalOpen} >
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
disabled={disabled}
className={cn(
'h-8 rounded-none px-3 gap-1.5',
selectedMailboxIds.length > 0 &&
'bg-primary/10 text-primary'
'h-8 rounded-none px-3 gap-1.5 transition-colors',
selectedMailboxIds.length > 0 && 'bg-primary/10 text-primary border-primary/20'
)}
>
<Folders className="h-4 w-4" />
{t('search_mailbox.label')}
<span className="max-w-[100px] truncate">{t('search_mailbox.label')}</span>
{selectedMailboxIds.length > 0 && (
<span className="ml-1 text-xs opacity-70">
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{selectedMailboxIds.length}
</span>
)}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="min-w-[260px] w-fit max-w-[620px] p-1">
<div className="p-1 pb-2">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('search_mailbox.search_placeholder')}
className="h-8 text-sm"
/>
</div>
{selectedMailboxIds.length > 0 && (
<div className="px-1 pb-2">
<PopoverContent
align="start"
className="w-[740px] max-w-[95vw] p-0 flex flex-col h-[480px] shadow-xl border-muted"
>
<div className="flex items-center gap-2 p-2 border-b bg-muted/10">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('search_mailbox.search_placeholder')}
className="h-9 pl-8 text-xs bg-background"
/>
</div>
{localSelectedIds.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearAllMailboxes}
className="h-7 w-full justify-start text-xs text-muted-foreground hover:text-destructive transition-colors"
onClick={() => setLocalSelectedIds([])}
className="h-9 text-xs text-destructive hover:bg-destructive/10"
>
<X className="mr-2 h-3.5 w-3.5" />
{t('search_mailbox.clear_mailboxes')} ({selectedMailboxIds.length})
<X className="mr-1.5 h-3 w-3" />
{t('common.clear')}
</Button>
</div>
)}
<ScrollArea className="h-96 p-1">
{disabled ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t('search_mailbox.select_account_first')}
</p>
) : isLoading ? (
<div className="space-y-2 p-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-4 rounded bg-muted animate-pulse"
/>
))}
</div>
) : grouped.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t('search_mailbox.no_mailbox_found')}
</p>
) : (
<Accordion
type="multiple"
defaultValue={defaultOpen}
className="space-y-1"
>
{grouped.map(([accountId, boxes]) => {
const selectedCount = boxes.filter(b =>
selectedMailboxIds.includes(b.id)
).length
)}
</div>
return (
<AccordionItem
key={accountId}
value={accountId.toString()}
>
<AccordionTrigger className="text-xs px-2 py-1.5">
<span className="truncate">
{getAccountEmail(accountId)}
<div className="flex flex-1 min-h-0">
<div className="w-64 border-r bg-muted/20 flex flex-col">
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{accountIds.map(id => {
const acc = minimalList.find(a => a.id === id);
const isActive = activeAccountId === id;
const cachedData = queryClient.getQueryData<any[]>(['search-mailboxes', id]);
const count = cachedData?.filter(m => localSelectedIds.includes(m.id)).length ?? 0;
return (
<button
key={id}
onClick={() => setActiveAccountId(id)}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-left rounded-md transition-all",
isActive
? "bg-background shadow-sm text-primary ring-1 ring-black/5"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
<span className="text-xs truncate font-medium">
{acc?.email}
</span>
{selectedCount > 0 && (
<span className="ml-2 text-[10px] text-primary">
{selectedCount}
{count > 0 && (
<span className="text-[10px] font-bold bg-primary/10 px-1.5 py-0.5 rounded-full">
{count}
</span>
)}
</AccordionTrigger>
</button>
);
})}
</div>
</ScrollArea>
</div>
<div className="flex-1 flex flex-col bg-background">
<ScrollArea className="flex-1">
<div className="p-3">
{activeIsLoading ? (
<div className="p-4 space-y-4">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-3 bg-muted animate-pulse rounded w-full" />
))}
</div>
) : activeAccountId ? (
<RichTreeView
multiSelect
items={treeData}
checkboxSelection
expansionTrigger="iconContainer"
selectedItems={localSelectedIds.map(String)}
onSelectedItemsChange={(_, itemIds) => {
setLocalSelectedIds(itemIds.map(id => parseInt(id)).filter(id => !isNaN(id)));
}}
slots={{ item: CustomTreeItem }}
sx={{ width: '100%' }}
/>
) : (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground opacity-40">
<TreeDeciduous className="h-12 w-12 mb-2 stroke-[1px]" />
<p className="text-xs">{t('search_mailbox.select_account_tip')}</p>
</div>
)}
</div>
</ScrollArea>
<AccordionContent>
<div className="space-y-0.5">
{boxes.map(mailbox => {
const checked =
selectedMailboxIds.includes(mailbox.id)
return (
<TooltipProvider key={mailbox.id}>
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={() =>
toggleMailbox(mailbox.id)
}
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
'hover:bg-accent transition-colors',
checked &&
'bg-primary/10 text-primary'
)}
>
<Checkbox
checked={checked}
onCheckedChange={() =>
toggleMailbox(mailbox.id)
}
onClick={e =>
e.stopPropagation()
}
/>
<span className="text-xs truncate">
{mailbox.name}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<div className="text-sm break-all">
{mailbox.name}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
})}
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
)}
</ScrollArea>
<div className="p-3 border-t bg-muted/10 flex items-center justify-between">
<div className="text-[10px] text-muted-foreground font-medium">
{t('search_mailbox.selected_total')}: <span className="text-foreground">{localSelectedIds.length}</span>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => setLocalOpen(false)} className="h-8 px-3 text-xs">
{t('common.cancel')}
</Button>
<Button size="sm" onClick={handleApply} className="h-8 px-4 text-xs gap-1.5 shadow-sm">
<Check className="h-3.5 w-3.5" />
{t('common.apply')}
</Button>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
</Popover >
);
}
+5 -3
View File
@@ -1,12 +1,13 @@
import { type Table } from '@tanstack/react-table'
import { DataTableViewOptions } from './view-options'
import { TagFilterPopover } from '../tag-filter-popover'
import { AccountMailboxFilter } from '../account-mailbox-filter'
import { TimePopover } from '../time-popover'
import { MailFilterPopover } from '../contact-popover'
import { TextSearchInput } from '../text-search-input'
import { MoreFiltersPopover } from '../more-filters-popover'
import { FilterResetButton } from '../filter-reset'
import { MailboxPopover } from '../mailbox-popover'
import { AccountPopover } from '../account-popover'
type DataTableToolbarProps<TData> = {
table: Table<TData>
@@ -25,9 +26,10 @@ export function DataTableToolbar<TData>({
</div>
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap lg:justify-end">
<div className="flex flex-wrap items-center gap-1 lg:flex-nowrap">
<TagFilterPopover />
<AccountMailboxFilter />
<AccountPopover />
<MailboxPopover />
<MailFilterPopover />
<TagFilterPopover />
<TimePopover />
<MoreFiltersPopover />
<FilterResetButton />
@@ -144,8 +144,6 @@ export const TokenCardList: React.FC<Props> = ({ tokens, userId }) => {
</Button>
</div>
</div>
{/* Meta */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-muted-foreground">
<div>
<span className="block font-medium text-foreground">
-32
View File
@@ -46,9 +46,6 @@ const AuthenticatedOauth2IndexLazyImport = createFileRoute(
const AuthenticatedOauth2ResultIndexLazyImport = createFileRoute(
'/_authenticated/oauth2-result/',
)()
const AuthenticatedMailboxesIndexLazyImport = createFileRoute(
'/_authenticated/mailboxes/',
)()
const AuthenticatedApiDocsIndexLazyImport = createFileRoute(
'/_authenticated/api-docs/',
)()
@@ -207,15 +204,6 @@ const AuthenticatedOauth2ResultIndexLazyRoute =
),
)
const AuthenticatedMailboxesIndexLazyRoute =
AuthenticatedMailboxesIndexLazyImport.update({
id: '/mailboxes/',
path: '/mailboxes/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any).lazy(() =>
import('./routes/_authenticated/mailboxes/index.lazy').then((d) => d.Route),
)
const AuthenticatedApiDocsIndexLazyRoute =
AuthenticatedApiDocsIndexLazyImport.update({
id: '/api-docs/',
@@ -451,13 +439,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedApiDocsIndexLazyImport
parentRoute: typeof AuthenticatedRouteImport
}
'/_authenticated/mailboxes/': {
id: '/_authenticated/mailboxes/'
path: '/mailboxes'
fullPath: '/mailboxes'
preLoaderRoute: typeof AuthenticatedMailboxesIndexLazyImport
parentRoute: typeof AuthenticatedRouteImport
}
'/_authenticated/oauth2-result/': {
id: '/_authenticated/oauth2-result/'
path: '/oauth2-result'
@@ -550,7 +531,6 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedAccountsIndexLazyRoute: typeof AuthenticatedAccountsIndexLazyRoute
AuthenticatedApiDocsIndexLazyRoute: typeof AuthenticatedApiDocsIndexLazyRoute
AuthenticatedMailboxesIndexLazyRoute: typeof AuthenticatedMailboxesIndexLazyRoute
AuthenticatedOauth2ResultIndexLazyRoute: typeof AuthenticatedOauth2ResultIndexLazyRoute
AuthenticatedOauth2IndexLazyRoute: typeof AuthenticatedOauth2IndexLazyRoute
AuthenticatedSearchIndexLazyRoute: typeof AuthenticatedSearchIndexLazyRoute
@@ -564,7 +544,6 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedAccountsIndexLazyRoute: AuthenticatedAccountsIndexLazyRoute,
AuthenticatedApiDocsIndexLazyRoute: AuthenticatedApiDocsIndexLazyRoute,
AuthenticatedMailboxesIndexLazyRoute: AuthenticatedMailboxesIndexLazyRoute,
AuthenticatedOauth2ResultIndexLazyRoute:
AuthenticatedOauth2ResultIndexLazyRoute,
AuthenticatedOauth2IndexLazyRoute: AuthenticatedOauth2IndexLazyRoute,
@@ -594,7 +573,6 @@ export interface FileRoutesByFullPath {
'/users/roles': typeof AuthenticatedUsersRolesLazyRoute
'/accounts': typeof AuthenticatedAccountsIndexLazyRoute
'/api-docs': typeof AuthenticatedApiDocsIndexLazyRoute
'/mailboxes': typeof AuthenticatedMailboxesIndexLazyRoute
'/oauth2-result': typeof AuthenticatedOauth2ResultIndexLazyRoute
'/oauth2': typeof AuthenticatedOauth2IndexLazyRoute
'/search': typeof AuthenticatedSearchIndexLazyRoute
@@ -619,7 +597,6 @@ export interface FileRoutesByTo {
'/users/roles': typeof AuthenticatedUsersRolesLazyRoute
'/accounts': typeof AuthenticatedAccountsIndexLazyRoute
'/api-docs': typeof AuthenticatedApiDocsIndexLazyRoute
'/mailboxes': typeof AuthenticatedMailboxesIndexLazyRoute
'/oauth2-result': typeof AuthenticatedOauth2ResultIndexLazyRoute
'/oauth2': typeof AuthenticatedOauth2IndexLazyRoute
'/search': typeof AuthenticatedSearchIndexLazyRoute
@@ -649,7 +626,6 @@ export interface FileRoutesById {
'/_authenticated/users/roles': typeof AuthenticatedUsersRolesLazyRoute
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexLazyRoute
'/_authenticated/api-docs/': typeof AuthenticatedApiDocsIndexLazyRoute
'/_authenticated/mailboxes/': typeof AuthenticatedMailboxesIndexLazyRoute
'/_authenticated/oauth2-result/': typeof AuthenticatedOauth2ResultIndexLazyRoute
'/_authenticated/oauth2/': typeof AuthenticatedOauth2IndexLazyRoute
'/_authenticated/search/': typeof AuthenticatedSearchIndexLazyRoute
@@ -679,7 +655,6 @@ export interface FileRouteTypes {
| '/users/roles'
| '/accounts'
| '/api-docs'
| '/mailboxes'
| '/oauth2-result'
| '/oauth2'
| '/search'
@@ -703,7 +678,6 @@ export interface FileRouteTypes {
| '/users/roles'
| '/accounts'
| '/api-docs'
| '/mailboxes'
| '/oauth2-result'
| '/oauth2'
| '/search'
@@ -731,7 +705,6 @@ export interface FileRouteTypes {
| '/_authenticated/users/roles'
| '/_authenticated/accounts/'
| '/_authenticated/api-docs/'
| '/_authenticated/mailboxes/'
| '/_authenticated/oauth2-result/'
| '/_authenticated/oauth2/'
| '/_authenticated/search/'
@@ -790,7 +763,6 @@ export const routeTree = rootRoute
"/_authenticated/",
"/_authenticated/accounts/",
"/_authenticated/api-docs/",
"/_authenticated/mailboxes/",
"/_authenticated/oauth2-result/",
"/_authenticated/oauth2/",
"/_authenticated/search/"
@@ -878,10 +850,6 @@ export const routeTree = rootRoute
"filePath": "_authenticated/api-docs/index.lazy.tsx",
"parent": "/_authenticated"
},
"/_authenticated/mailboxes/": {
"filePath": "_authenticated/mailboxes/index.lazy.tsx",
"parent": "/_authenticated"
},
"/_authenticated/oauth2-result/": {
"filePath": "_authenticated/oauth2-result/index.lazy.tsx",
"parent": "/_authenticated"
@@ -1,25 +0,0 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { createLazyFileRoute } from '@tanstack/react-router'
import Mailboxes from '@/features/mailbox'
export const Route = createLazyFileRoute('/_authenticated/mailboxes/')({
component: Mailboxes,
})