This commit is contained in:
rustmailer
2026-04-20 00:12:18 +08:00
parent 7dd722f9b8
commit b29c6ea9ff
55 changed files with 315 additions and 1515 deletions
+2 -10
View File
@@ -357,9 +357,9 @@ pub async fn detach_and_store_attachments(
for (raw_start, raw_end, att) in ranges {
// Step 2: Extract raw bytes and store them as standalone documents
let raw_bytes = &original_body[raw_start..raw_end];
let content_hash = compute_content_hash(raw_bytes);
let content_hash = compute_content_hash(att.contents());
attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes)));
attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes)));//
// Step 3: Replace raw attachment content with a hash-based placeholder
let placeholder = format!("<<BICHON_DETACH_HASH:{}>>", &content_hash);
@@ -474,14 +474,6 @@ pub async fn reattach_eml_content(
for (start, end, hash) in tasks {
if let Some(original_data) = BLOB_MANAGER.get_attachment(&hash)? {
let actual_hash = compute_content_hash(&original_data);
if actual_hash != hash {
error!(
"[ERROR] Content Hash Mismatch! Expected: {}, Actual: {}",
hash, actual_hash
);
continue;
}
restored_eml.splice(start..end, original_data.iter().cloned());
} else {
error!("[ERROR] Missing attachment blob for hash: {}", hash);
+2 -5
View File
@@ -16,8 +16,7 @@
// 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 crate::modules::logger::{validate_log_level, LocalTimer};
use crate::modules::logger::LocalTimer;
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::dir::DATA_DIR_MANAGER;
use std::sync::OnceLock;
@@ -30,9 +29,7 @@ use tracing_subscriber::layer::SubscriberExt;
pub static LOG_WORKER_GUARD: OnceLock<Vec<WorkerGuard>> = OnceLock::new();
pub fn setup_file_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
validate_log_level(&SETTINGS.bichon_log_level);
let level = SETTINGS.bichon_log_level.parse::<Level>().unwrap();
pub fn setup_file_logger(level: Level) -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
let with_ansi = SETTINGS.bichon_ansi_logs;
let (server_nonb, server_guard) = server_log_writer();
+19 -15
View File
@@ -19,9 +19,9 @@
use crate::modules::logger::file::setup_file_logger;
use crate::modules::settings::cli::SETTINGS;
use chrono::Local;
use tracing_log::LogTracer;
use std::process;
use tracing::Level;
use tracing_log::LogTracer;
use tracing_subscriber::fmt::{format::Writer, time::FormatTime};
mod file;
@@ -35,17 +35,18 @@ impl FormatTime for LocalTimer {
}
pub fn initialize_logging() {
LogTracer::init().unwrap();
let level = validate_log_level(&SETTINGS.bichon_log_level);
if matches!(level, Level::DEBUG) || matches!(level, Level::TRACE) {
LogTracer::init().unwrap();
}
if SETTINGS.bichon_log_to_file {
setup_file_logger().unwrap();
setup_file_logger(level).unwrap();
} else {
setup_stdout_logger().unwrap();
setup_stdout_logger(level).unwrap();
}
}
fn setup_stdout_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
validate_log_level(&SETTINGS.bichon_log_level);
let level = SETTINGS.bichon_log_level.parse::<Level>().unwrap();
fn setup_stdout_logger(level: Level) -> Result<(), tracing::dispatcher::SetGlobalDefaultError> {
let with_ansi = SETTINGS.bichon_ansi_logs;
let format = tracing_subscriber::fmt::format()
@@ -63,13 +64,16 @@ fn setup_stdout_logger() -> Result<(), tracing::dispatcher::SetGlobalDefaultErro
tracing::subscriber::set_global_default(subscriber)
}
fn validate_log_level(value: &String) {
if value.parse::<Level>().is_err() {
eprintln!(
"Invalid log level specified. Use one of: error, warn, info, debug, trace.
The log level you currently specified is 'rustmailer_log_level'='{}'",
value
);
process::exit(1);
fn validate_log_level(value: &String) -> Level {
match value.parse::<Level>() {
Ok(level) => level,
Err(_) => {
eprintln!(
"Invalid log level specified. Use one of: error, warn, info, debug, trace.
The log level you currently specified is 'rustmailer_log_level'='{}'",
value
);
process::exit(1);
}
}
}
+2 -2
View File
@@ -40,7 +40,7 @@ pub async fn retrieve_attachment_content(
let (_, eml) = reattach_eml_content(account_id, envelope_id).await?;
let message = MessageParser::default().parse(&eml).ok_or_else(|| {
raise_error!(
"Failed to parse parent EML".into(),
"Failed to parse EML".into(),
ErrorCode::InternalError
)
})?;
@@ -51,7 +51,7 @@ pub async fn retrieve_attachment_content(
.map(|att| att.contents())
.ok_or_else(|| {
raise_error!(
"Target nested EML not found".into(),
"Target attachment not found".into(),
ErrorCode::ResourceNotFound
)
})?;
+1
View File
@@ -122,6 +122,7 @@ pub struct AttachmentSearchFilter {
pub max_size: Option<u64>,
pub attachment_name: Option<String>,
pub content_hash: Option<String>,
pub tags: Option<HashSet<String>>,
pub attachment_extension: Option<String>,
+7
View File
@@ -343,6 +343,13 @@ impl IndexManager {
subqueries.push((Occur::Must, Box::new(query)));
}
if let Some(content_hash) = &filter.content_hash {
let term = Term::from_field_text(f.f_content_hash, content_hash);
let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query)));
}
if let Some(ref name) = filter.attachment_name {
let query_parser =
QueryParser::for_index(&self.index, vec![f.f_name_text, f.f_name_exact]);
+8 -6
View File
@@ -315,15 +315,17 @@ impl IndexManager {
}
if let Some(ref subject_val) = filter.subject {
let term = Term::from_field_text(f.f_subject, subject_val);
let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query)));
let query_parser = QueryParser::for_index(&self.index, vec![f.f_subject]);
if let Ok(q) = query_parser.parse_query(subject_val) {
subqueries.push((Occur::Must, q));
}
}
if let Some(ref body_val) = filter.body {
let term = Term::from_field_text(f.f_body, body_val);
let query = TermQuery::new(term, IndexRecordOption::Basic);
subqueries.push((Occur::Must, Box::new(query)));
let query_parser = QueryParser::for_index(&self.index, vec![f.f_body]);
if let Ok(q) = query_parser.parse_query(body_val) {
subqueries.push((Occur::Must, q));
}
}
if let Some(ref tags) = filter.tags {
+8 -1
View File
@@ -137,4 +137,11 @@ export interface AttachmentMetadata {
export const get_attachment_meta = async () => {
const response = await axiosInstance.get<AttachmentMetadata>("api/v1/attachment_metadata");
return response.data;
};
};
export const get_envelope = async (accountId: number, id: string) => {
const response = await axiosInstance.get<EmailEnvelope>(`api/v1/envelope/${accountId}/${id}`);
return response.data;
};
@@ -34,11 +34,11 @@ import {
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { cn } from '@/lib/utils'
import { useSearchContext } from './context'
import { useAttachmentContext } from './context'
export function AccountPopover() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const { filter, setFilter } = useAttachmentContext()
const [search, setSearch] = React.useState('')
const { minimalList = [] } = useMinimalAccountList()
@@ -1,72 +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 { Paperclip, Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useSearchContext } from './context'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export function AttachmentFilter() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const hasAttachment = filter?.has_attachment === true
const toggleAttachment = () => {
setFilter((prev) => {
const next = { ...prev }
if (next.has_attachment) {
delete next.has_attachment
} else {
next.has_attachment = true
}
return next
})
}
return (
<Button
size="sm"
variant="outline"
onClick={toggleAttachment}
className={cn(
"h-8 px-3 gap-2 transition-all rounded-none flex-shrink-0",
hasAttachment
? "bg-primary/10 border-primary text-primary hover:bg-primary/20 hover:text-primary z-10"
: "text-muted-foreground border-r-0"
)}
>
<Paperclip
className={cn(
"h-3.5 w-3.5",
hasAttachment ? "opacity-100" : "opacity-60"
)}
/>
<span className="text-xs font-medium">
{t('mail.attachments')}
</span>
{hasAttachment && (
<Check className="h-3 w-3 ml-0.5 stroke-[3px] animate-in zoom-in duration-200" />
)}
</Button>
)
}
@@ -1,7 +1,7 @@
import * as React from "react"
import { ChevronDown } from "lucide-react"
import { useTranslation } from "react-i18next"
import { useSearchContext } from "./context"
import { useAttachmentContext } from "./context"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
@@ -15,7 +15,7 @@ interface MetaFilterProps {
export function MetadataFilter({ type, icon }: MetaFilterProps) {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const { filter, setFilter } = useAttachmentContext()
const [open, setOpen] = React.useState(false)
const { data: meta, isLoading } = useAttachmentMetadata(open)
@@ -1,169 +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, TagIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from '@/components/ui/tooltip'
import { useSearchContext } from './context'
import { useTranslation } from 'react-i18next'
type MailBulkActionsProps = {
children?: React.ReactNode
}
export function AttachmentBulkActions({ children }: MailBulkActionsProps) {
const { selected, setSelected, setOpen } = useSearchContext()
const toolbarRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation()
const selectedCount = Array.from(selected.values())
.reduce((sum, set) => sum + set.size, 0)
const handleClearSelection = () => {
setSelected(new Map())
}
const handleUpdateTags = () => {
setOpen('update-tags')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
const buttons = toolbarRef.current?.querySelectorAll('button')
if (!buttons || buttons.length === 0) return
const currentIndex = Array.from(buttons).findIndex(
btn => btn === document.activeElement
)
switch (e.key) {
case 'ArrowRight': {
e.preventDefault()
const next = (currentIndex + 1) % buttons.length
buttons[next]?.focus()
break
}
case 'ArrowLeft': {
e.preventDefault()
const prev = currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
buttons[prev]?.focus()
break
}
case 'Home':
e.preventDefault()
buttons[0]?.focus()
break
case 'End':
e.preventDefault()
buttons[buttons.length - 1]?.focus()
break
case 'Escape': {
const target = e.target as HTMLElement
const active = document.activeElement as HTMLElement
const isFromDropdown =
target.closest('[data-slot="dropdown-menu-trigger"]') ||
active.closest('[data-slot="dropdown-menu-trigger"]') ||
target.closest('[data-slot="dropdown-menu-content"]') ||
active.closest('[data-slot="dropdown-menu-content"]')
if (!isFromDropdown) {
e.preventDefault()
handleClearSelection()
}
break
}
}
}
if (selectedCount === 0) return null
return (
<>
<div
ref={toolbarRef}
role="toolbar"
aria-label={t('search.bulkActions.ariaLabel', {
count: selectedCount,
})}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cn(
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
'transition-all delay-100 duration-300 ease-out hover:scale-105',
'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none'
)}
>
<div
className={cn(
'p-2 shadow-xl rounded-xl border',
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
'flex items-center gap-x-2'
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={handleClearSelection}
className="size-6 rounded-full"
aria-label={t('search.bulkActions.clear')}
>
<X className="h-3 w-3" />
<span className="sr-only">{t('search.bulkActions.clear')}</span>
</Button>
</TooltipTrigger>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<div className="flex items-center gap-x-1 text-sm">
<Badge variant="default" className="min-w-8 rounded-lg">
{selectedCount}
</Badge>{' '}
</div>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="sm"
onClick={handleUpdateTags}
className="gap-1"
>
<TagIcon className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t('search.bulkActions.manageTags')}
</TooltipContent>
</Tooltip>
{children}
</div>
</div>
</>
)
}
@@ -1,259 +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, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Plus, Tag as TagIcon, X, Loader2, Check, AlertTriangle } from 'lucide-react';
import { useState } from 'react';
import { useAvailableTags } from '@/hooks/use-available-tags';
import { TagAction, useUpdateTags } from '@/hooks/use-update-tags';
import { toast } from '@/hooks/use-toast';
import { validateTag } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useSearchContext } from './context';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
}
export function UpdateTagsDialog({ open, onOpenChange }: Props) {
const { tags: availableTags } = useAvailableTags();
const queryClient = useQueryClient();
const { mutate, isPending } = useUpdateTags();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
const [commandOpen, setCommandOpen] = useState(false);
const [action, setAction] = useState<TagAction>('Overwrite');
const { t } = useTranslation();
const { selected } = useSearchContext()
const handleAddTag = (tag: string) => {
const normalized = tag.toLowerCase().trim();
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: t('search.updateTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
return;
}
if (normalized && !selectedTags.includes(normalized)) {
setSelectedTags(prev => [...prev, normalized]);
}
setInputValue('');
setCommandOpen(false);
};
const handleRemoveTag = (tag: string) => {
setSelectedTags(prev => prev.filter(t => t !== tag));
};
const handleSubmit = () => {
if (inputValue.trim()) {
const normalized = inputValue.toLowerCase().trim();
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: t('search.updateTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
return;
}
if (!selectedTags.includes(normalized)) {
setSelectedTags(prev => [...prev, normalized]);
}
setInputValue('');
}
const updates: Record<number, string[]> = {};
selected.forEach((tagSet, accountId) => {
updates[accountId] = Array.from(tagSet);
});
let finalTags = inputValue.trim()
? [...selectedTags, inputValue.toLowerCase().trim()]
: selectedTags;
if (finalTags.length === 0 && action !== 'Overwrite') {
return;
}
mutate(
{
updates,
tags: finalTags,
action
},
{
onSuccess: () => {
toast({
title: t('search.updateTags.updatedTitle'),
description: (
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-500" />
<span>{t('search.updateTags.updatedDesc')}</span>
</div>
),
});
queryClient.invalidateQueries({ queryKey: ['all-tags'] });
onOpenChange(false);
},
onError: (error: any) => {
toast({
title: t('search.updateTags.updateFailedTitle'),
description: error?.message || t('search.updateTags.tryAgain'),
variant: 'destructive',
});
},
}
);
};
const filteredSuggestions = availableTags.filter(
tag => !selectedTags.includes(tag) && tag.includes(inputValue.toLowerCase())
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md min-h-[50vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TagIcon className="h-5 w-5" />
{t('search.updateTags.title')}
</DialogTitle>
</DialogHeader>
<Tabs value={action} onValueChange={(v) => setAction(v as TagAction)} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="Add" className="text-xs">{t('search.updateTags.actionAdd', "Add")}</TabsTrigger>
<TabsTrigger value="Remove" className="text-xs">{t('search.updateTags.actionRemove', "remove")}</TabsTrigger>
<TabsTrigger value="Overwrite" className="text-xs">{t('search.updateTags.actionOverwrite', "overwrite")}</TabsTrigger>
</TabsList>
</Tabs>
<div className="space-y-5 py-4">
<div className="flex flex-wrap gap-2">
{selectedTags.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('search.updateTags.none')}</p>
) : (
selectedTags.map(tag => (
<Badge key={tag} variant="secondary" className="gap-1 pr-1 h-7">
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="rounded-sm hover:bg-destructive/20 hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</button>
</Badge>
))
)}
</div>
<Command shouldFilter={false} onKeyDown={(e) => e.stopPropagation()} >
<div className="space-y-2">
<div className="relative">
<CommandInput
placeholder={t('search.updateTags.searchPlaceholder')}
value={inputValue}
onValueChange={setInputValue}
onFocus={() => setCommandOpen(true)}
className="h-9 pr-10"
onKeyDown={(e) => {
if (e.key === 'Enter' && inputValue.trim()) {
e.preventDefault();
e.stopPropagation();
handleAddTag(inputValue);
}
}}
/>
{inputValue.trim() && (
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1 h-7 w-7 p-0"
onClick={() => handleAddTag(inputValue)}
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
</div>
{inputValue.trim() && filteredSuggestions.length === 0 && (
<div className="px-1 text-xs text-muted-foreground animate-in fade-in duration-200">
{t('search.updateTags.createHint', { tag: inputValue })}
</div>
)}
{commandOpen && inputValue && filteredSuggestions.length > 0 && (
<CommandList className="max-h-64 overflow-auto rounded-md border bg-popover shadow-md">
<CommandGroup>
{filteredSuggestions.map(tag => (
<CommandItem
key={tag}
onSelect={() => handleAddTag(tag)}
className="cursor-pointer"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
{tag}
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
</div>
</Command>
</div>
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">
{t('search.updateTags.selectedCount', { count: selectedTags.length })}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('search.addTags.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={isPending} variant={action === 'Remove' ? 'destructive' : 'default'}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t(`search.updateTags.saving${action}`)}
</>
) : (
t(`search.updateTags.submit${action}`)
)}
</Button>
</div>
</div>
{action == "Overwrite" && <div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-500">
<AlertTriangle className="h-5 w-5 shrink-0" />
<p className="text-xs leading-relaxed">
{t('search.updateTags.overwriteWarning')}
</p>
</div>}
</DialogContent>
</Dialog>
);
}
+17 -15
View File
@@ -21,13 +21,15 @@ import React from 'react'
import { SortingState } from '@tanstack/react-table'
import { AttachmentModel } from '@/api/attachment/api'
export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'update-tags' | 'restore' | 'delete-mailbox'
export type AttachmentDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'update-tags' | 'restore' | 'delete-mailbox'
interface SearchContextType {
open: SearchDialogType | null
setOpen: (str: SearchDialogType | null) => void
currentEnvelope: AttachmentModel | undefined
setCurrentEnvelope: React.Dispatch<React.SetStateAction<AttachmentModel | undefined>>
interface AttachmentContextType {
open: AttachmentDialogType | null
setOpen: (str: AttachmentDialogType | null) => void
currentAttachment: AttachmentModel | undefined
setCurrentAttachment: React.Dispatch<React.SetStateAction<AttachmentModel | undefined>>
toDelete: Map<number, Set<string>>
setToDelete: React.Dispatch<React.SetStateAction<Map<number, Set<string>>>>
selected: Map<number, Set<string>>
setSelected: React.Dispatch<React.SetStateAction<Map<number, Set<string>>>>
deleteMailboxId: string | undefined
@@ -42,25 +44,25 @@ interface SearchContextType {
handleTagToggle: (tag: string) => void
}
const SearchContext = React.createContext<SearchContextType | null>(null)
const AttachmentContext = React.createContext<AttachmentContextType | null>(null)
interface Props {
children: React.ReactNode
value: SearchContextType
value: AttachmentContextType
}
export default function SearchProvider({ children, value }: Props) {
return <SearchContext.Provider value={value}>{children}</SearchContext.Provider>
export default function AttachmentProvider({ children, value }: Props) {
return <AttachmentContext.Provider value={value}>{children}</AttachmentContext.Provider>
}
export const useSearchContext = () => {
const searchContext = React.useContext(SearchContext)
export const useAttachmentContext = () => {
const attachmentContext = React.useContext(AttachmentContext)
if (!searchContext) {
if (!attachmentContext) {
throw new Error(
'useSearchContext has to be used within <SearchContext.Provider>'
'useAttachmentContext has to be used within <AttachmentContext.Provider>'
)
}
return searchContext
return attachmentContext
}
@@ -22,7 +22,7 @@ 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 { useAttachmentContext } from './context'
import { mapToRecordOfArrays } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
@@ -33,7 +33,7 @@ interface Props {
export function EnvelopeDeleteDialog({ open, onOpenChange }: Props) {
const queryClient = useQueryClient()
const { toDelete, setToDelete, setSelected } = useSearchContext()
const { toDelete, setToDelete, setSelected } = useAttachmentContext()
const { t } = useTranslation()
const deleteMutation = useMutation({
@@ -41,8 +41,8 @@ export function EnvelopeDeleteDialog({ open, onOpenChange }: Props) {
delete_messages(payload),
retry: false,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['search-messages'], exact: false })
queryClient.invalidateQueries({ queryKey: ['all-tags'] })
queryClient.invalidateQueries({ queryKey: ['search-attachments'], exact: false })
queryClient.invalidateQueries({ queryKey: ['attachment-tags'] })
onOpenChange(false)
setToDelete(new Map())
setSelected(new Map())
@@ -1,105 +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 { useTranslation } from 'react-i18next';
import { delete_mailbox } from '@/api/mailbox/api';
import { useSearchContext } from './context';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MailBoxDeleteDialog({ open, onOpenChange }: Props) {
const queryClient = useQueryClient();
const { selectedAccountId, deleteMailboxId, setDeleteMailboxId } = useSearchContext();
const { t } = useTranslation();
const deleteMutation = useMutation({
mutationFn: ({ accountId, mailboxId }: { accountId: number; mailboxId: string }) =>
delete_mailbox(accountId, mailboxId),
retry: false,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['search-mailboxes', selectedAccountId] });
onOpenChange(false);
setDeleteMailboxId(undefined);
toast({
title: t('mailbox.deleteMailboxDialog.successTitle'),
description: t('mailbox.deleteMailboxDialog.successDesc'),
});
},
onError: (error: any) => {
toast({
title: t('mailbox.deleteMailboxDialog.errorTitle'),
description: error.message || "Delete failed",
variant: 'destructive',
});
},
});
const handleDelete = () => {
if (selectedAccountId && deleteMailboxId) {
deleteMutation.mutate({
accountId: selectedAccountId,
mailboxId: deleteMailboxId
});
}
};
const isLoading = deleteMutation.isPending;
return (
<ConfirmDialog
open={open}
onOpenChange={(isOpen) => {
onOpenChange(isOpen);
if (!isOpen) setDeleteMailboxId(undefined);
}}
handleConfirm={handleDelete}
className="max-w-xl"
isLoading={isLoading}
title={
<span className="text-destructive">
<IconAlertTriangle
className="mr-1 inline-block stroke-destructive"
size={18}
/>{' '}
{t('mailbox.deleteMailboxDialog.title')}
</span>
}
desc={
<div className="space-y-4">
<p className="mb-2">
{t('mailbox.deleteMailboxDialog.desc')}
</p>
<Alert variant="destructive">
<AlertTitle>{t('mailbox.deleteMailboxDialog.warningTitle')}</AlertTitle>
<AlertDescription>{t('mailbox.deleteMailboxDialog.warningDesc')}</AlertDescription>
</Alert>
</div>
}
confirmText={t('mailbox.deleteMailboxDialog.confirm')}
destructive
/>
);
}
@@ -1,245 +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, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Plus, Tag as TagIcon, X, Loader2, Check } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useAvailableTags } from '@/hooks/use-available-tags';
import { useUpdateTags } from '@/hooks/use-update-tags';
import { toast } from '@/hooks/use-toast';
import { validateTag } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useSearchContext } from './context';
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
}
export function EditTagsDialog({ open, onOpenChange }: Props) {
const { tags: availableTags } = useAvailableTags();
const queryClient = useQueryClient();
const { mutate, isPending } = useUpdateTags();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
const [commandOpen, setCommandOpen] = useState(false);
const { t } = useTranslation();
const { currentEnvelope } = useSearchContext()
useEffect(() => {
if (open && currentEnvelope) {
setSelectedTags(currentEnvelope.tags || []);
}
}, [open, currentEnvelope]);
if (!currentEnvelope) return null;
const handleAddTag = (tag: string) => {
const normalized = tag.toLowerCase().trim();
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: t('search.addTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
return;
}
if (normalized && !selectedTags.includes(normalized)) {
setSelectedTags(prev => [...prev, normalized]);
}
setInputValue('');
setCommandOpen(false);
};
const handleRemoveTag = (tag: string) => {
setSelectedTags(prev => prev.filter(t => t !== tag));
};
const handleSave = () => {
if (inputValue.trim()) {
const normalized = inputValue.toLowerCase().trim();
const result = validateTag(normalized);
if (!result.valid) {
toast({
title: t('search.addTags.invalidTitle'),
description: result.error,
variant: 'destructive',
});
return;
}
if (!selectedTags.includes(normalized)) {
setSelectedTags(prev => [...prev, normalized]);
}
setInputValue('');
}
const updates = {
[currentEnvelope.account_id]: [currentEnvelope.id],
};
mutate(
{
updates,
tags: inputValue.trim()
? [...selectedTags, inputValue.toLowerCase().trim()]
: selectedTags,
action: "Overwrite"
},
{
onSuccess: () => {
toast({
title: t('search.addTags.updatedTitle'),
description: (
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-green-500" />
<span>{t('search.addTags.updatedDesc')}</span>
</div>
),
});
queryClient.invalidateQueries({ queryKey: ['all-tags'] });
onOpenChange(false);
},
onError: (error: any) => {
toast({
title: t('search.addTags.updateFailedTitle'),
description: error?.message || t('search.addTags.tryAgain'),
variant: 'destructive',
});
},
}
);
};
const filteredSuggestions = availableTags.filter(
tag => !selectedTags.includes(tag) && tag.includes(inputValue.toLowerCase())
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TagIcon className="h-5 w-5" />
{t('search.addTags.title')}
</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-4">
<div className="flex flex-wrap gap-2">
{selectedTags.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('search.addTags.none')}</p>
) : (
selectedTags.map(tag => (
<Badge key={tag} variant="secondary" className="gap-1 pr-1 h-7">
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="rounded-sm hover:bg-destructive/20 hover:text-destructive transition-colors"
>
<X className="h-3 w-3" />
</button>
</Badge>
))
)}
</div>
<Command shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="relative">
<CommandInput
placeholder={t('search.addTags.searchPlaceholder')}
value={inputValue}
onValueChange={setInputValue}
onFocus={() => setCommandOpen(true)}
className="h-9 pr-10"
onKeyDown={(e) => {
if (e.key === 'Enter' && inputValue.trim()) {
e.preventDefault();
e.stopPropagation();
handleAddTag(inputValue);
}
}}
/>
{inputValue.trim() && (
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1 h-7 w-7 p-0"
onClick={() => handleAddTag(inputValue)}
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
</div>
{inputValue.trim() && filteredSuggestions.length === 0 && (
<div className="px-1 text-xs text-muted-foreground animate-in fade-in duration-200">
{t('search.addTags.createHint', { tag: inputValue })}
</div>
)}
{commandOpen && inputValue && filteredSuggestions.length > 0 && (
<CommandList className="max-h-64 overflow-auto rounded-md border bg-popover shadow-md">
<CommandGroup>
{filteredSuggestions.map(tag => (
<CommandItem
key={tag}
onSelect={() => handleAddTag(tag)}
className="cursor-pointer"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
{tag}
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
</div>
</Command>
</div>
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">
{t('search.addTags.selectedCount', { count: selectedTags.length })}
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('search.addTags.cancel')}
</Button>
<Button onClick={handleSave} disabled={isPending}>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('search.addTags.saving')}
</>
) : (
t('search.addTags.save')
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+2 -2
View File
@@ -19,12 +19,12 @@
import { X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useSearchContext } from "./context"
import { useAttachmentContext } from "./context"
import { cn } from "@/lib/utils"
import { useTranslation } from "react-i18next";
export function FilterResetButton() {
const { filter, setFilter } = useSearchContext();
const { filter, setFilter } = useAttachmentContext();
const { t } = useTranslation()
const { q, ...restFilters } = filter;
+17 -33
View File
@@ -22,18 +22,22 @@ import { FixedHeader } from '@/components/layout/fixed-header';
import { Main } from '@/components/layout/main';
import { AttachmentListPagination } from '@/components/pagination';
import React from 'react';
import SearchProvider, { SearchDialogType } from './context';
import AttachmentProvider, { AttachmentDialogType } from './context';
import useDialogState from '@/hooks/use-dialog-state';
import { useTranslation } from 'react-i18next';
import { AttachmentListTable } from './mail-list-table';
import { SortingState } from '@tanstack/react-table';
import { useSearchAttachments } from '@/hooks/use-search-attachments';
import { AttachmentModel } from '@/api/attachment/api';
import { MailDisplayDrawer } from './mail-display-dialog';
import { EnvelopeDeleteDialog } from './delete-dialog';
import { RestoreMessageDialog } from './restore-message-dialog';
export default function AttachmentSearch() {
const { t } = useTranslation()
const [selectedAttachment, setSelectedAttachment] = React.useState<AttachmentModel | undefined>(undefined);
const [open, setOpen] = useDialogState<SearchDialogType>(null)
const [currentAttachment, setCurrentAttachment] = React.useState<AttachmentModel | undefined>(undefined);
const [open, setOpen] = useDialogState<AttachmentDialogType>(null)
const [toDelete, setToDelete] = React.useState<Map<number, Set<string>>>(new Map());
const [selected, setSelected] = React.useState<Map<number, Set<string>>>(new Map());
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
const [sorting, setSorting] = React.useState<SortingState>([{ id: "date", desc: true }]);
@@ -72,13 +76,15 @@ export default function AttachmentSearch() {
<>
<FixedHeader />
<Main>
<SearchProvider
<AttachmentProvider
value={{
open,
setOpen,
currentEnvelope: selectedAttachment,
currentAttachment,
selectedTags,
setCurrentEnvelope: setSelectedAttachment,
setCurrentAttachment,
toDelete,
setToDelete,
selected,
setSelected,
sorting,
@@ -109,10 +115,6 @@ export default function AttachmentSearch() {
<AttachmentListTable
isLoading={isLoading}
items={attachments}
onAttachmentChanged={(att) => {
setOpen('display');
setSelectedAttachment(att);
}}
setSortBy={setSortBy}
setSortOrder={setSortOrder}
/>
@@ -128,42 +130,24 @@ export default function AttachmentSearch() {
</div>
</div>
{/* <MailDisplayDrawer
key='search-mail-display'
<MailDisplayDrawer
key='attachment-mail-display'
open={open === 'display'}
onOpenChange={() => setOpen('display')}
/>
<EnvelopeDeleteDialog
key='delete-envelope'
key='delete-attachment-envelope'
open={open === 'delete'}
onOpenChange={() => setOpen('delete')}
/>
<EditTagsDialog
key='edit-attachment-tags-dialog'
open={open === 'edit-tags'}
onOpenChange={() => setOpen('edit-tags')}
/>
<UpdateTagsDialog
key='update-attachment-tags-dialog'
open={open === 'update-tags'}
onOpenChange={() => setOpen('update-tags')}
/>
<RestoreMessageDialog
key='restore-mail-dialog'
key='attachment-restore-mail-dialog'
open={open === 'restore'}
onOpenChange={() => setOpen('restore')}
/>
<MailBoxDeleteDialog
key='mailbox-delete'
open={open === 'delete-mailbox'}
onOpenChange={() => setOpen('delete-mailbox')}
/> */}
</SearchProvider>
</AttachmentProvider>
</Main>
</>
);
@@ -17,11 +17,12 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useSearchContext } from './context'
import { useAttachmentContext } from './context'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { MailMessageView } from './mail-message-view'
import { useTranslation } from 'react-i18next'
import { MailMessageView } from './mail-message-view'
import { useEnvelope } from '@/hooks/use-envelope'
interface Props {
@@ -31,31 +32,35 @@ interface Props {
export function MailDisplayDrawer({ open, onOpenChange }: Props) {
const { t } = useTranslation()
const { currentEnvelope } = useSearchContext()
const { currentAttachment } = useAttachmentContext()
const {
data: envelope,
isLoading,
error
} = useEnvelope(currentAttachment?.account_id, currentAttachment?.envelope_id);
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='w-full md:max-w-6xl mx-auto h-full'>
<DialogHeader className="p-4 pb-3 border-b shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
{t('mail.emailViewer')}
</DialogTitle>
</div>
<DialogTitle>{t('mail.emailViewer')}</DialogTitle>
</DialogHeader>
<ScrollArea>
<ScrollArea className="h-[calc(100vh-100px)]">
<div className='m-5'>
{currentEnvelope ? (
<MailMessageView envelope={currentEnvelope} />
{isLoading ? (
<div className="p-8 text-center text-muted-foreground">{t('common.loading')}...</div>
) : error ? (
<div className="p-8 text-center text-red-500">{t('attachment.emailMessageNotFound')}</div>
) : envelope ? (
<MailMessageView envelope={envelope} />
) : (
<div className="p-8 text-center text-muted-foreground">{t('mail.noMessageSelected')}</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>)
</Dialog>
)
}
+27 -14
View File
@@ -21,8 +21,7 @@ import { dateFnsLocaleMap, formatBytes } from "@/lib/utils"
import { format, formatDistanceToNow } from "date-fns"
import { Skeleton } from "@/components/ui/skeleton"
import { Checkbox } from "@/components/ui/checkbox"
import { useSearchContext } from "./context"
import { AttachmentBulkActions } from "./bulk-actions"
import { useAttachmentContext } from "./context"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
import { ColumnDef } from "@tanstack/react-table"
@@ -34,13 +33,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { DataTableToolbar } from "./table/toolbar"
import { AttachmentModel } from "@/api/attachment/api"
import { useSearchAttachments } from "@/hooks/use-search-attachments"
import { FileIcon } from "lucide-react"
import { AttachmentIcon } from "./attachment-icon"
interface MailListProps {
items: AttachmentModel[]
isLoading: boolean
onAttachmentChanged: (attachment: AttachmentModel) => void
setSortBy: (sortBy: "DATE" | "SIZE") => void
setSortOrder: (value: "desc" | "asc") => void
}
@@ -48,14 +45,13 @@ interface MailListProps {
export function AttachmentListTable({
items,
isLoading,
onAttachmentChanged,
setSortBy,
setSortOrder
}: MailListProps) {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS
const { selected, setSelected } = useSearchContext()
const { selected, setSelected, setOpen, setCurrentAttachment } = useAttachmentContext()
const columns: ColumnDef<AttachmentModel>[] = [
{
@@ -133,12 +129,34 @@ export function AttachmentListTable({
</div>
);
},
meta: { className: 'text-left' }
meta: { className: 'text-left text-xs' }
},
{
accessorKey: "subject",
header: t('attachment.subject'),
cell: ({ row }) => <LongText className='text-xs'>{row.original.subject}</LongText>,
cell: ({ row }) => {
return (
<div className="group relative flex items-center w-full min-w-0 h-full px-2 overflow-hidden">
<div className="absolute left-0 top-0 bottom-0 w-[2px] bg-primary opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="text-xs flex flex-wrap gap-x-1 min-w-0 flex-1">
<span className="flex items-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setCurrentAttachment(row.original);
setOpen("display");
}}
className="hover:text-primary hover:underline transition-colors truncate"
>
<LongText>{row.original.subject}</LongText>
</button>
</span>
</div>
</div>
);
},
meta: { className: 'text-left text-xs' },
minSize: 300,
maxSize: 300,
@@ -277,11 +295,7 @@ export function AttachmentListTable({
<SearchTable
data={items}
columns={columns}
onRowClick={(e, row) => {
const target = e.target as HTMLElement
if (target.closest('input[type="checkbox"], button')) return
onAttachmentChanged(row.original)
}}
onRowClick={() => { }}
setSortBy={setSortBy}
setSortOrder={setSortOrder}
>
@@ -290,7 +304,6 @@ export function AttachmentListTable({
}}
</SearchTable>
{totalSelected > 0 && <AttachmentBulkActions />}
</>
)
}
-278
View File
@@ -1,278 +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 { Checkbox } from "@/components/ui/checkbox"
import { EmailEnvelope } from "@/api"
import { useSearchContext } from "./context"
import { AttachmentBulkActions } from "./bulk-actions"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { useTranslation } from 'react-i18next'
import { enUS } from "date-fns/locale"
interface MailListProps {
items: EmailEnvelope[]
isLoading: boolean
onEnvelopeChanged: (envelope: EmailEnvelope) => void
}
export function MailList({
items,
isLoading,
onEnvelopeChanged
}: MailListProps) {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
const { setOpen, currentEnvelope, setCurrentEnvelope, selected, setSelected, setToDelete } = useSearchContext()
const handleToggleAll = () => {
const total = Array.from(selected.values())
.reduce((sum, set) => sum + set.size, 0);
if (total === items.length && items.length > 0) {
setSelected(new Map());
} else {
setSelected(prev => {
const next = new Map(prev);
for (const item of items) {
const set = new Set(next.get(item.account_id) || []);
set.add(item.id);
next.set(item.account_id, set);
}
return next;
});
}
}
const toggleToDelete = (accountId: number, mailId: string) => {
setToDelete(prev => {
const next = new Map(prev);
const set = new Set(next.get(accountId) || []);
if (set.has(mailId)) {
set.delete(mailId);
if (set.size === 0) next.delete(accountId);
else next.set(accountId, set);
} else {
set.add(mailId);
next.set(accountId, set);
}
return next;
});
};
const toggleSelected = (accountId: number, mailId: string) => {
setSelected(prev => {
const next = new Map(prev);
const set = new Set(next.get(accountId) || []);
if (set.has(mailId)) {
set.delete(mailId);
if (set.size === 0) next.delete(accountId);
else next.set(accountId, set);
} else {
set.add(mailId);
next.set(accountId, set);
}
return next;
});
}
const totalSelected = Array.from(selected.values())
.reduce((sum, set) => sum + set.size, 0);
const hasSelected = (accountId: number, mailId: string) => {
return selected.get(accountId)?.has(mailId) ?? false;
}
const handleDelete = (envelope: EmailEnvelope) => {
setToDelete(new Map());
toggleToDelete(envelope.account_id, envelope.id)
setOpen("delete")
}
if (isLoading) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="h-3 w-3" />
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-2.5 w-16" />
</div>
))}
</div>
)
}
return (
<div className="divide-y divide-border">
{items.length > 0 && (
<div className="flex items-center gap-2 px-2 py-1 bg-muted/30">
<Checkbox
checked={
totalSelected === items.length && items.length > 0
? true
: totalSelected > 0
? "indeterminate"
: false
}
onCheckedChange={handleToggleAll}
className="h-4 w-4"
/>
<span className="text-xs text-muted-foreground">
{totalSelected > 0
? `${t('search.bulkActions.selected', { count: totalSelected })}`
: t('common.selectAll')}
</span>
</div>
)}
{items.map((item, index) => {
const hasAttachments = item.regular_attachment_count > 0
const isSelectedRow = currentEnvelope?.id === item.id
const isChecked = hasSelected(item.account_id, item.id)
return (
<div
key={index}
className={cn(
"flex items-center gap-2 px-2 py-1.5 cursor-pointer transition-colors",
"hover:bg-accent/50",
isSelectedRow && "bg-accent"
)}
onClick={(e) => {
const target = e.target as HTMLElement
if (target.closest('input[type="checkbox"], button')) return
onEnvelopeChanged(item)
}}
>
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleSelected(item.account_id, item.id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0"
/>
<MailIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-12 gap-1 sm:gap-0">
<div className="col-span-1 sm:col-span-8 flex flex-col min-w-0 gap-0.5">
<div className="flex items-center gap-1 min-w-0">
<p className="text-sm font-medium truncate">{item.from}</p>
<h3 className="text-sm text-muted-foreground truncate hidden sm:block">
{item.subject}
</h3>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/60">
<span className="truncate">{item.account_email}</span>
<span className="scale-75 opacity-50"></span>
<span className="font-medium text-primary/70">{item.mailbox_name}</span>
</div>
<h3 className="text-sm text-muted-foreground truncate sm:hidden">
{item.subject}
</h3>
<div className="flex flex-wrap gap-1 mt-0.25">
{item.tags?.map((tag, i) => (
<Badge className="px-1 py-0.5 text-[10px] h-auto leading-none" key={i}>{tag}</Badge>
))}
</div>
</div>
<div className="col-span-1 sm:col-span-4 flex items-center justify-end gap-1 text-xs text-muted-foreground">
{hasAttachments && (
<div className="flex items-center gap-1">
<Paperclip className="h-3 w-3" />
<span>{item.regular_attachment_count}</span>
</div>
)}
<span className="hidden md:inline">{formatBytes(item.size)}</span>
<span className={cn(isSelectedRow ? "text-foreground font-medium" : "text-muted-foreground")}>
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-muted rounded-md"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
setCurrentEnvelope(item);
setOpen("edit-tags");
}}
>
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('search.editTag')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
setCurrentEnvelope(item);
setOpen("restore");
}}
>
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('restore_message.restore_to_imap')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
handleDelete(item);
}}
>
<Trash2 className="ml-2 h-3.5 w-3.5" />
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
})}
{totalSelected > 0 && <AttachmentBulkActions />}
</div>
)
}
@@ -35,24 +35,16 @@ import {
load_message,
} from '@/api/mailbox/envelope/api';
import { AxiosError } from 'axios';
import { useSearchContext } from './context';
import { useAttachmentContext } from './context';
import { MailThreadDialog } from './thread-dialog';
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
import { useTranslation } from 'react-i18next';
import { NestedEmailDialog } from './nested-email-dialog';
import { EmailEnvelope } from '@/api';
interface MailMessageViewProps {
envelope: {
id: string;
account_id: number,
from?: string;
to?: string[];
cc?: string[];
bcc?: string[];
subject?: string;
internal_date?: number;
};
envelope: EmailEnvelope;
showActions?: boolean;
showHeader?: boolean;
showAttachments?: boolean;
@@ -120,7 +112,7 @@ export function MailMessageView({
showHeader = true
}: MailMessageViewProps) {
const { t } = useTranslation()
const { setToDelete, setOpen, setSelected } = useSearchContext();
const { setToDelete, setOpen, setSelected } = useAttachmentContext();
const [content, setContent] = useState<string | null>(null);
const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null);
const [attachments, setAttachments] = useState<AttachmentInfo[] | null>(null);
@@ -403,7 +395,7 @@ export function MailMessageView({
)}
</div>
<MailThreadDialog open={threadOpen} onOpenChange={setThreadOpen} />
<MailThreadDialog open={threadOpen} onOpenChange={setThreadOpen} currentEnvelope={envelope} />
<NestedEmailDialog
open={!!nestedEmlFile}
onOpenChange={(open: boolean) => !open && setNestedEmlFile(null)}
@@ -58,7 +58,7 @@ import { cn } from '@/lib/utils';
import { list_mailboxes } from '@/api/mailbox/api';
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
import { useSearchContext } from './context';
import { useAttachmentContext } from './context';
import { buildTree, ExtendedTreeItemProps } from '@/lib/build-tree';
const CustomCollapse = styled(Collapse)({ padding: 0 });
@@ -149,7 +149,7 @@ function CustomLabel({
export function MailboxPopover() {
const { t } = useTranslation();
const { filter, setFilter, setOpen, setDeleteMailboxId, setSelectedAccountId } = useSearchContext();
const { filter, setFilter, setOpen, setDeleteMailboxId, setSelectedAccountId } = useAttachmentContext();
const { minimalList = [] } = useMinimalAccountList();
const [localOpen, setLocalOpen] = React.useState(false);
@@ -22,7 +22,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Separator } from "@/components/ui/separator"
import { Info, ListFilter } from "lucide-react"
import { useTranslation } from "react-i18next"
import { useSearchContext } from "./context"
import { useAttachmentContext } from "./context"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
@@ -49,7 +49,7 @@ const getPresetFromSize = (min?: number, max?: number) => {
export function MoreFiltersPopover() {
const { t } = useTranslation();
const { filter, setFilter } = useSearchContext();
const { filter, setFilter } = useAttachmentContext();
const [open, setOpen] = React.useState(false);
const [localState, setLocalState] = React.useState({
@@ -24,8 +24,9 @@ import { useMutation } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
import { ToastAction } from '@/components/ui/toast'
import { useSearchContext } from './context'
import { EmailEnvelope } from '@/api'
import { useAttachmentContext } from './context'
import { useEnvelope } from '@/hooks/use-envelope'
function MessageSummary({ envelope, t }: { envelope: EmailEnvelope, t: (key: string) => string }) {
return (
@@ -82,7 +83,7 @@ export function RestoreMessageDialog({
onOpenChange
}: RestoreMessageDialogProps) {
const { t } = useTranslation()
const { currentEnvelope, selected } = useSearchContext()
const { selected, currentAttachment } = useAttachmentContext()
const accountsWithSelection = Array.from(selected.entries()).filter(([_, ids]) => ids.size > 0);
const selectedCount = accountsWithSelection.reduce((sum, [_, set]) => sum + set.size, 0);
@@ -90,6 +91,10 @@ export function RestoreMessageDialog({
const isBulk = selectedCount > 0;
const {
data: currentEnvelope,
} = useEnvelope(currentAttachment?.account_id, currentAttachment?.envelope_id);
const restoreMutation = useMutation({
mutationFn: async () => {
@@ -22,14 +22,14 @@ import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { ChevronDown, Mail } from "lucide-react"
import { useTranslation } from 'react-i18next'
import { useSearchContext } from "./context"
import { useAttachmentContext } from "./context"
import { userAttachmentSenders } from "@/hooks/use-attachment-senders"
import { Group } from "@/api/system/api"
import { MetadataSelectorField } from "./attachment-metadata-selector"
export function SenderFilterPopover() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const { filter, setFilter } = useAttachmentContext()
const { senders, isLoading } = userAttachmentSenders("")
const activeCount = filter.from ? 1 : 0
@@ -23,21 +23,43 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { MoreVertical, TagIcon } from 'lucide-react'
import { useSearchContext } from '../context'
import { Copy, Download, MoreVertical } from 'lucide-react'
import { AttachmentModel } from '@/api/attachment/api'
import { useSearchAttachments } from '@/hooks/use-search-attachments'
import { useToast } from '@/hooks/use-toast'
import { useMutation } from '@tanstack/react-query'
import { download_attachment } from '@/api/mailbox/envelope/api'
interface DataTableRowActionsProps {
row: Row<AttachmentModel>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentEnvelope, setSelected } = useSearchContext()
const { setFilter } = useSearchAttachments();
const { t } = useTranslation()
const { toast } = useToast();
const downloadMutation = useMutation({
mutationFn: (content_hash: string) =>
download_attachment(
row.original.account_id,
row.original.envelope_id,
content_hash,
row.original.name ?? row.original.id
),
onError: (error: any) => {
toast({
title: t('mail.failedToDownloadFile'),
description: error.message,
variant: 'destructive',
});
},
});
return (
<>
@@ -51,17 +73,34 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<span className='sr-only'>Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuContent align='end' className='w-[180px]'>
<DropdownMenuItem
className='text-xs'
onClick={(e) => {
e.stopPropagation()
setCurrentEnvelope(row.original)
setOpen("edit-tags")
setFilter((prev: any) => ({ ...prev, content_hash: row.original.content_hash }));
}}
>
{t('attachment.editTag')}
{t('attachment.showDuplicates')}
<DropdownMenuShortcut>
<TagIcon size={16} />
<Copy size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className='text-xs'
disabled={downloadMutation.isPending}
onClick={(e) => {
e.stopPropagation()
e.preventDefault();
downloadMutation.mutate(row.original.content_hash);
}}
>
{downloadMutation.isPending
? t('attachment.downloading')
: t('attachment.download')}
<DropdownMenuShortcut>
<Download size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
+2 -2
View File
@@ -42,7 +42,7 @@ import {
} from '@/components/ui/table'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useSearchContext } from '../context'
import { useAttachmentContext } from '../context'
import { ScrollArea } from '@/components/ui/scroll-area'
import { AttachmentModel } from '@/api/attachment/api'
@@ -65,7 +65,7 @@ interface DataTableProps {
}
export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder, children }: DataTableProps) {
const { sorting, setSorting } = useSearchContext()
const { sorting, setSorting } = useAttachmentContext()
const { t } = useTranslation()
const [rowSelection, setRowSelection] = useState({})
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
@@ -1,6 +1,5 @@
import { type Table } from '@tanstack/react-table'
import { DataTableViewOptions } from './view-options'
import { TagFilterPopover } from '../tag-filter-popover'
import { TimePopover } from '../time-popover'
import { SenderFilterPopover } from '../sender-popover'
import { TextSearchInput } from '../text-search-input'
@@ -31,8 +30,6 @@ export function DataTableToolbar<TData>({
<AccountPopover />
<MailboxPopover />
<SenderFilterPopover />
<TagFilterPopover />
<MetadataFilter
type="extension"
icon={<FileType className="h-3.5 w-3.5" />}
@@ -1,209 +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 { Tag, ChevronDown, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { useSearchContext } from './context'
import { useAvailableAttachmentTags } from '@/hooks/use-available-attachment-tags'
export function TagFilterPopover() {
const { t } = useTranslation()
const [search, setSearch] = React.useState('')
const { filter, setFilter } = useSearchContext()
const selectedTags = (filter?.tags as string[]) || []
const {
tagsCount = [],
isLoading,
} = useAvailableAttachmentTags()
const handleTagToggle = (tag: string) => {
setFilter(prev => {
const next = { ...prev }
const currentTags = (next.tags as string[]) || []
const isSelected = currentTags.includes(tag)
const nextTags = isSelected
? currentTags.filter(t => t !== tag)
: [...currentTags, tag]
if (nextTags.length > 0) {
next.tags = nextTags
} else {
delete next.tags
}
return next
})
}
const clearAllTags = () => {
setFilter(prev => {
const next = { ...prev }
delete next.tags
return next
})
}
const filteredTags = React.useMemo(() => {
const q = search.toLowerCase()
return tagsCount
.filter(t =>
!q || t.tag.toLowerCase().includes(q)
)
.sort((a, b) => {
const aSelected = selectedTags.includes(a.tag)
const bSelected = selectedTags.includes(b.tag)
if (aSelected && !bSelected) return -1
if (!aSelected && bSelected) return 1
return b.count - a.count
})
}, [tagsCount, search, selectedTags])
return (
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
className={cn(
'h-6 gap-1.5 px-3 rounded-none border-l-0',
selectedTags.length > 0 &&
'bg-primary/10 border-primary text-primary'
)}
>
<Tag className="h-4 w-4" />
{t('tag.label')}
{selectedTags.length > 0 && (
<Badge
variant="secondary"
className="ml-1 h-5 px-1.5 text-xs"
>
{selectedTags.length}
</Badge>
)}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-96 p-1"
>
<div className="p-1 pb-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('tag.search_placeholder')}
className="h-8 text-sm"
autoFocus
/>
</div>
<ScrollArea className="h-96 p-1">
{!search && selectedTags.length > 0 && (
<>
<div
onClick={clearAllTags}
className="flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer text-destructive hover:bg-destructive/10 transition-colors"
>
<div className="flex h-4 w-4 items-center justify-center">
<X className="h-3 w-3" />
</div>
<span className="flex-1 text-xs font-medium">
{t('tag.clear_all')}
</span>
<span className="text-[10px] opacity-60">({selectedTags.length})</span>
</div>
<div className="my-1 h-px bg-border" />
</>
)}
{isLoading ? (
<div className="space-y-2 p-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-4 rounded bg-muted animate-pulse"
/>
))}
</div>
) : filteredTags.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
{t('tag.no_tags_found')}
</p>
) : (
filteredTags.map(({ tag, count }) => {
const checked = selectedTags.includes(tag)
const id = `tag-${tag}`
return (
<div
key={tag}
onClick={() => handleTagToggle(tag)}
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer',
'hover:bg-accent transition-colors'
)}
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={() =>
handleTagToggle(tag)
}
onClick={(e) =>
e.stopPropagation()
}
/>
<Label
htmlFor={id}
className="flex-1 truncate text-xs cursor-pointer"
title={tag}
>
{tag}
</Label>
<Badge
variant="secondary"
className="h-5 px-1.5 text-xs"
>
{count}
</Badge>
</div>
)
})
)}
</ScrollArea>
</PopoverContent>
</Popover>
)
}
@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Search, X, Clock, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { useSearchContext } from "./context"
import { useAttachmentContext } from "./context"
import { useTranslation } from "react-i18next"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -34,7 +34,7 @@ const SEARCH_FIELDS: SearchField[] = ["text", "subject", "attachment_name", "fro
export function TextSearchInput() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const { filter, setFilter } = useAttachmentContext()
const [value, setValue] = useState("")
const [field, setField] = useState<SearchField>("text")
@@ -30,18 +30,18 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { get_thread_messages } from '@/api/mailbox/envelope/api';
import { MailMessageView } from './mail-message-view';
import { useSearchContext } from './context';
import { useTranslation } from 'react-i18next';
import { format } from 'date-fns';
import { EmailEnvelope } from '@/api';
import { MailMessageView } from './mail-message-view';
interface MailThreadDialogProps {
open: boolean;
currentEnvelope: EmailEnvelope
onOpenChange: (open: boolean) => void;
}
export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps) {
const { currentEnvelope } = useSearchContext();
export function MailThreadDialog({ open, onOpenChange, currentEnvelope }: MailThreadDialogProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const { t } = useTranslation();
+6 -6
View File
@@ -28,14 +28,14 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useSearchContext } from './context'
import { useAttachmentContext } from './context'
import { DatePicker } from '@/components/date-picker'
const DAY = 86400000
export function TimePopover() {
const { t } = useTranslation()
const { filter, setFilter } = useSearchContext()
const { filter, setFilter } = useAttachmentContext()
const [customDays, setCustomDays] = React.useState<string>('')
const since = filter.since
@@ -163,10 +163,10 @@ export function TimePopover() {
<Section title={t('time.absolute_range')}>
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-3 w-full">
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
<span className="w-20 text-right text-[10px] font-medium">
{t('time.since').toUpperCase()}:
</span>
<div className="flex-1">
<div className="w-full">
<DatePicker
placeholder={t('time.start_date')}
selected={since ? new Date(since) : undefined}
@@ -176,10 +176,10 @@ export function TimePopover() {
</div>
<div className="flex items-center gap-3 w-full">
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
<span className="w-20 text-right text-[10px] font-medium">
{t('time.before').toUpperCase()}:
</span>
<div className="flex-1">
<div className="w-full">
<DatePicker
placeholder={t('time.end_date')}
selected={before ? new Date(before) : undefined}
@@ -77,6 +77,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
className='text-xs'
onClick={(e) => {
e.stopPropagation()
setCurrentEnvelope(row.original)
@@ -90,6 +91,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className='text-xs'
onClick={(e) => {
e.stopPropagation()
setCurrentEnvelope(row.original)
@@ -104,11 +106,12 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
handleDelete(row.original)
}}
className='!text-red-500'
className='!text-red-500 text-xs'
>
{t('common.delete')}
<DropdownMenuShortcut>
+4 -4
View File
@@ -163,10 +163,10 @@ export function TimePopover() {
<Section title={t('time.absolute_range')}>
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-3 w-full">
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
<span className="w-20 text-right text-[10px] font-medium">
{t('time.since').toUpperCase()}:
</span>
<div className="flex-1">
<div className="w-full">
<DatePicker
placeholder={t('time.start_date')}
selected={since ? new Date(since) : undefined}
@@ -176,10 +176,10 @@ export function TimePopover() {
</div>
<div className="flex items-center gap-3 w-full">
<span className="w-20 text-right text-[10px] opacity-50 font-medium">
<span className="w-20 text-right text-[10px] font-medium">
{t('time.before').toUpperCase()}:
</span>
<div className="flex-1">
<div className="w-full">
<DatePicker
placeholder={t('time.end_date')}
selected={before ? new Date(before) : undefined}
+15
View File
@@ -0,0 +1,15 @@
import { get_envelope } from '@/api/mailbox/envelope/api';
import { useQuery } from '@tanstack/react-query';
export const useEnvelope = (
accountId: number | undefined,
envelopeId: string | undefined
) => {
return useQuery({
queryKey: ['envelope', accountId, envelopeId],
queryFn: () => get_envelope(accountId!, envelopeId!),
enabled: !!accountId && !!envelopeId,
staleTime: 10000,
retry: false
});
};
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "مطابقة اسم المرفق، موضوع الرسالة، والمرسل",
"date": "التاريخ",
"download": "تنزيل",
"downloading": "جاري التنزيل...",
"emailMessageNotFound": "تعذر العثور على رسالة البريد الإلكتروني الأصلية. ربما تم حذفها.",
"name": "اسم الملف",
"search_input_placeholder": "بحث عن المرفقات (استخدم \" \" للبحث عن عبارة)",
"sender": "المرسل",
"sender_with_count": "المرسل ({{count}})",
"showDuplicates": "إظهار الملفات المكررة",
"size": "حجم الملف",
"source": "المصدر",
"subject": "الموضوع"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Matcher vedhæftet filnavn, emne og afsender",
"date": "Dato",
"download": "Download",
"downloading": "Downloader...",
"emailMessageNotFound": "Kan ikke finde den originale e-mail. Den er muligvis blevet slettet.",
"name": "Filnavn",
"search_input_placeholder": "Søg efter vedhæftede filer (brug \" \" til frasesøgning)",
"sender": "Afsender",
"sender_with_count": "Afsender ({{count}})",
"showDuplicates": "Vis dubletter",
"size": "Filstørrelse",
"source": "Kilde",
"subject": "Emne"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Gleicht Anhangsname, Betreff und Absender ab",
"date": "Datum",
"download": "Herunterladen",
"downloading": "Herunterladen...",
"emailMessageNotFound": "Die ursprüngliche E-Mail wurde nicht gefunden. Sie wurde möglicherweise gelöscht.",
"name": "Dateiname",
"search_input_placeholder": "Anhänge durchsuchen (verwenden Sie \" \" für die Phrasensuche)",
"sender": "Absender",
"sender_with_count": "Absender ({{count}})",
"showDuplicates": "Duplikate anzeigen",
"size": "Dateigröße",
"source": "Quelle",
"subject": "Betreff"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Matches attachment name, subject, and sender",
"date": "Date",
"download": "Download",
"downloading": "Downloading...",
"emailMessageNotFound": "Unable to find the original email. It may have been deleted.",
"name": "Filename",
"search_input_placeholder": "Search attachments (use \" \" for phrase search)",
"sender": "Sender",
"sender_with_count": "Sender ({{count}})",
"showDuplicates": "Show duplicates",
"size": "File size",
"source": "Source",
"subject": "Subject"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Coincide con el nombre del adjunto, asunto y remitente",
"date": "Fecha",
"download": "Descargar",
"downloading": "Descargando...",
"emailMessageNotFound": "No se pudo encontrar el correo electrónico original. Es posible que se haya eliminado.",
"name": "Nombre de archivo",
"search_input_placeholder": "Buscar adjuntos (use \" \" para búsqueda de frases)",
"sender": "Remitente",
"sender_with_count": "Remitente ({{count}})",
"showDuplicates": "Mostrar duplicados",
"size": "Tamaño del archivo",
"source": "Fuente",
"subject": "Asunto"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Täsmää liitteen nimeen, aiheeseen ja lähettäjään",
"date": "Päivämäärä",
"download": "Lataa",
"downloading": "Ladataan...",
"emailMessageNotFound": "Alkuperäistä sähköpostia ei löytynyt. Se on ehkä poistettu.",
"name": "Tiedostonimi",
"search_input_placeholder": "Hae liitteitä (käytä \" \" lausehakuun)",
"sender": "Lähettäjä",
"sender_with_count": "Lähettäjä ({{count}})",
"showDuplicates": "Näytä kaksoiskappaleet",
"size": "Tiedostokoko",
"source": "Lähde",
"subject": "Aihe"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Correspond au nom de la pièce jointe, à l'objet et à l'expéditeur",
"date": "Date",
"download": "Télécharger",
"downloading": "Téléchargement en cours...",
"emailMessageNotFound": "Impossible de trouver l'e-mail original. Il a peut-être été supprimé.",
"name": "Nom du fichier",
"search_input_placeholder": "Rechercher des pièces jointes (utilisez \" \" pour la recherche par expression)",
"sender": "Expéditeur",
"sender_with_count": "Expéditeur ({{count}})",
"showDuplicates": "Afficher les doublons",
"size": "Taille du fichier",
"source": "Source",
"subject": "Objet"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Corrisponde al nome dell'allegato, all'oggetto e al mittente",
"date": "Data",
"download": "Scarica",
"downloading": "Download in corso...",
"emailMessageNotFound": "Impossibile trovare l'email originale. Potrebbe essere stata eliminata.",
"name": "Nome file",
"search_input_placeholder": "Cerca allegati (usa \" \" per la ricerca di frasi)",
"sender": "Mittente",
"sender_with_count": "Mittente ({{count}})",
"showDuplicates": "Mostra duplicati",
"size": "Dimensione file",
"source": "Origine",
"subject": "Oggetto"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "添付ファイル名、件名、送信者を照合",
"date": "日付",
"download": "ダウンロード",
"downloading": "ダウンロード中...",
"emailMessageNotFound": "元のメールが見つかりません。削除された可能性があります。",
"name": "ファイル名",
"search_input_placeholder": "添付ファイルを検索 (フレーズ検索は \" \" を使用)",
"sender": "送信者",
"sender_with_count": "送信者 ({{count}})",
"showDuplicates": "重複ファイルを表示",
"size": "ファイルサイズ",
"source": "ソース",
"subject": "件名"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "첨부 파일 이름, 메일 제목 및 발신자 일치",
"date": "날짜",
"download": "다운로드",
"downloading": "다운로드 중...",
"emailMessageNotFound": "원본 메일을 찾을 수 없습니다. 삭제되었을 수 있습니다.",
"name": "파일 이름",
"search_input_placeholder": "첨부 파일 검색 (구문 검색은 \" \" 사용)",
"sender": "보낸 사람",
"sender_with_count": "보낸 사람 ({{count}})",
"showDuplicates": "중복 파일 표시",
"size": "파일 크기",
"source": "출처",
"subject": "제목"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Komt overeen met bijlagennaam, onderwerp en afzender",
"date": "Datum",
"download": "Downloaden",
"downloading": "Downloaden...",
"emailMessageNotFound": "Kan de originele e-mail niet vinden. Deze is mogelijk verwijderd.",
"name": "Bestandsnaam",
"search_input_placeholder": "Zoek bijlagen (gebruik \" \" voor woordgroepen)",
"sender": "Afzender",
"sender_with_count": "Afzender ({{count}})",
"showDuplicates": "Duplicaten weergeven",
"size": "Bestandsgrootte",
"source": "Bron",
"subject": "Onderwerp"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Matcher vedleggsnavn, emne og avsender",
"date": "Dato",
"download": "Last ned",
"downloading": "Laster ned...",
"emailMessageNotFound": "Fant ikke den originale e-posten. Den kan ha blitt slettet.",
"name": "Filnavn",
"search_input_placeholder": "Søk etter vedlegg (bruk \" \" for frasesøk)",
"sender": "Avsender",
"sender_with_count": "Avsender ({{count}})",
"showDuplicates": "Vis duplikater",
"size": "Filstørrelse",
"source": "Kilde",
"subject": "Emne"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Dopasuj nazwę załącznika, temat i nadawcę",
"date": "Data",
"download": "Pobierz",
"downloading": "Pobieranie...",
"emailMessageNotFound": "Nie można znaleźć oryginalnej wiadomości e-mail. Mogła zostać usunięta.",
"name": "Nazwa pliku",
"search_input_placeholder": "Wyszukaj załączniki (użyj \" \" do wyszukiwania fraz)",
"sender": "Nadawca",
"sender_with_count": "Nadawca ({{count}})",
"showDuplicates": "Pokaż duplikaty",
"size": "Rozmiar pliku",
"source": "Źródło",
"subject": "Temat"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Corresponde ao nome do anexo, assunto e remetente",
"date": "Data",
"download": "Baixar",
"downloading": "Baixando...",
"emailMessageNotFound": "Não foi possível encontrar o e-mail original. Ele pode ter sido excluído.",
"name": "Nome do arquivo",
"search_input_placeholder": "Pesquisar anexos (use \" \" para pesquisa de frases)",
"sender": "Remetente",
"sender_with_count": "Remetente ({{count}})",
"showDuplicates": "Mostrar duplicados",
"size": "Tamanho do arquivo",
"source": "Origem",
"subject": "Assunto"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Поиск по имени вложения, теме и отправителю",
"date": "Дата",
"download": "Скачать",
"downloading": "Загрузка...",
"emailMessageNotFound": "Не удалось найти исходное письмо. Возможно, оно было удалено.",
"name": "Имя файла",
"search_input_placeholder": "Поиск вложений (используйте \" \" для фразового поиска)",
"sender": "Отправитель",
"sender_with_count": "Отправитель ({{count}})",
"showDuplicates": "Показать дубликаты",
"size": "Размер файла",
"source": "Источник",
"subject": "Тема"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "Matchar bilagenamn, ämne och avsändare",
"date": "Datum",
"download": "Ladda ner",
"downloading": "Laddar ner...",
"emailMessageNotFound": "Det går inte att hitta det ursprungliga e-postmeddelandet. Det kan ha raderats.",
"name": "Filnamn",
"search_input_placeholder": "Sök efter bilagor (använd \" \" för frassökning)",
"sender": "Avsändare",
"sender_with_count": "Avsändare ({{count}})",
"showDuplicates": "Visa dubbletter",
"size": "Filstorlek",
"source": "Källa",
"subject": "Ämne"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "匹配附件名稱、郵件主題和發件人",
"date": "日期",
"download": "下載",
"downloading": "正在下載...",
"emailMessageNotFound": "找不到原始郵件。它可能已被刪除。",
"name": "檔案名稱",
"search_input_placeholder": "搜尋附件(使用 \" \" 進行短語搜尋)",
"sender": "寄件人",
"sender_with_count": "寄件人 ({{count}})",
"showDuplicates": "顯示重複檔案",
"size": "檔案大小",
"source": "來源",
"subject": "主旨"
+4
View File
@@ -365,10 +365,14 @@
"attachment": {
"all_fields_desc": "匹配附件名称、邮件主题和发件人",
"date": "日期",
"download": "下载",
"downloading": "正在下载...",
"emailMessageNotFound": "找不到原始邮件。它可能已被删除。",
"name": "文件名",
"search_input_placeholder": "搜索附件(使用 \" \" 进行短语搜索)",
"sender": "发件人",
"sender_with_count": "发件人 ({{count}})",
"showDuplicates": "显示重复文件",
"size": "文件大小",
"source": "来源",
"subject": "主题"