diff --git a/src/modules/envelope/extractor.rs b/src/modules/envelope/extractor.rs index ed80aab..3ad42ca 100644 --- a/src/modules/envelope/extractor.rs +++ b/src/modules/envelope/extractor.rs @@ -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!("<>", &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); diff --git a/src/modules/logger/file.rs b/src/modules/logger/file.rs index f570c85..001bc03 100644 --- a/src/modules/logger/file.rs +++ b/src/modules/logger/file.rs @@ -16,8 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - -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> = 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::().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(); diff --git a/src/modules/logger/mod.rs b/src/modules/logger/mod.rs index ff63b50..e0851a3 100644 --- a/src/modules/logger/mod.rs +++ b/src/modules/logger/mod.rs @@ -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::().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::().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::() { + 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); + } } } diff --git a/src/modules/message/attachment.rs b/src/modules/message/attachment.rs index b3df9c4..0ae718d 100644 --- a/src/modules/message/attachment.rs +++ b/src/modules/message/attachment.rs @@ -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 ) })?; diff --git a/src/modules/message/search.rs b/src/modules/message/search.rs index 231e48d..8121b01 100644 --- a/src/modules/message/search.rs +++ b/src/modules/message/search.rs @@ -122,6 +122,7 @@ pub struct AttachmentSearchFilter { pub max_size: Option, pub attachment_name: Option, + pub content_hash: Option, pub tags: Option>, pub attachment_extension: Option, diff --git a/src/modules/store/tantivy/attachment.rs b/src/modules/store/tantivy/attachment.rs index b260c97..7472df7 100644 --- a/src/modules/store/tantivy/attachment.rs +++ b/src/modules/store/tantivy/attachment.rs @@ -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]); diff --git a/src/modules/store/tantivy/envelope.rs b/src/modules/store/tantivy/envelope.rs index e3856c1..695ca66 100644 --- a/src/modules/store/tantivy/envelope.rs +++ b/src/modules/store/tantivy/envelope.rs @@ -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 { diff --git a/web/src/api/mailbox/envelope/api.ts b/web/src/api/mailbox/envelope/api.ts index df998df..1e6dd87 100644 --- a/web/src/api/mailbox/envelope/api.ts +++ b/web/src/api/mailbox/envelope/api.ts @@ -137,4 +137,11 @@ export interface AttachmentMetadata { export const get_attachment_meta = async () => { const response = await axiosInstance.get("api/v1/attachment_metadata"); return response.data; -}; \ No newline at end of file +}; + +export const get_envelope = async (accountId: number, id: string) => { + const response = await axiosInstance.get(`api/v1/envelope/${accountId}/${id}`); + return response.data; +}; + + diff --git a/web/src/features/attachment/account-popover.tsx b/web/src/features/attachment/account-popover.tsx index 29b4a54..ba475ac 100644 --- a/web/src/features/attachment/account-popover.tsx +++ b/web/src/features/attachment/account-popover.tsx @@ -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() diff --git a/web/src/features/attachment/attachment-filter.tsx b/web/src/features/attachment/attachment-filter.tsx deleted file mode 100644 index 6c9334a..0000000 --- a/web/src/features/attachment/attachment-filter.tsx +++ /dev/null @@ -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 . - - -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 ( - - ) -} \ No newline at end of file diff --git a/web/src/features/attachment/attachment-metadata-filter.tsx b/web/src/features/attachment/attachment-metadata-filter.tsx index be2f1eb..ab0ae66 100644 --- a/web/src/features/attachment/attachment-metadata-filter.tsx +++ b/web/src/features/attachment/attachment-metadata-filter.tsx @@ -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) diff --git a/web/src/features/attachment/bulk-actions.tsx b/web/src/features/attachment/bulk-actions.tsx deleted file mode 100644 index 4c7b4e6..0000000 --- a/web/src/features/attachment/bulk-actions.tsx +++ /dev/null @@ -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 . - - - -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(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 ( - <> -
-
- - - - - - -
- - {selectedCount} - {' '} -
- - - - - - - {t('search.bulkActions.manageTags')} - - - {children} -
-
- - ) -} - diff --git a/web/src/features/attachment/bulk-add-tag-dialog.tsx b/web/src/features/attachment/bulk-add-tag-dialog.tsx deleted file mode 100644 index c21114d..0000000 --- a/web/src/features/attachment/bulk-add-tag-dialog.tsx +++ /dev/null @@ -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 . - -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([]); - const [inputValue, setInputValue] = useState(''); - const [commandOpen, setCommandOpen] = useState(false); - const [action, setAction] = useState('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 = {}; - - 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: ( -
- - {t('search.updateTags.updatedDesc')} -
- ), - }); - 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 ( - - - - - - {t('search.updateTags.title')} - - - setAction(v as TagAction)} className="w-full"> - - {t('search.updateTags.actionAdd', "Add")} - {t('search.updateTags.actionRemove', "remove")} - {t('search.updateTags.actionOverwrite', "overwrite")} - - -
-
- {selectedTags.length === 0 ? ( -

{t('search.updateTags.none')}

- ) : ( - selectedTags.map(tag => ( - - {tag} - - - )) - )} -
- e.stopPropagation()} > -
-
- setCommandOpen(true)} - className="h-9 pr-10" - onKeyDown={(e) => { - if (e.key === 'Enter' && inputValue.trim()) { - e.preventDefault(); - e.stopPropagation(); - handleAddTag(inputValue); - } - }} - /> - {inputValue.trim() && ( - - )} -
- {inputValue.trim() && filteredSuggestions.length === 0 && ( -
- {t('search.updateTags.createHint', { tag: inputValue })} -
- )} - {commandOpen && inputValue && filteredSuggestions.length > 0 && ( - - - {filteredSuggestions.map(tag => ( - handleAddTag(tag)} - className="cursor-pointer" - > - - {tag} - - ))} - - - )} -
-
-
- -
-

- {t('search.updateTags.selectedCount', { count: selectedTags.length })} -

-
- - -
-
- {action == "Overwrite" &&
- -

- {t('search.updateTags.overwriteWarning')} -

-
} -
-
- ); -} diff --git a/web/src/features/attachment/context/index.tsx b/web/src/features/attachment/context/index.tsx index facd492..845136a 100644 --- a/web/src/features/attachment/context/index.tsx +++ b/web/src/features/attachment/context/index.tsx @@ -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> +interface AttachmentContextType { + open: AttachmentDialogType | null + setOpen: (str: AttachmentDialogType | null) => void + currentAttachment: AttachmentModel | undefined + setCurrentAttachment: React.Dispatch> + toDelete: Map> + setToDelete: React.Dispatch>>> selected: Map> setSelected: React.Dispatch>>> deleteMailboxId: string | undefined @@ -42,25 +44,25 @@ interface SearchContextType { handleTagToggle: (tag: string) => void } -const SearchContext = React.createContext(null) +const AttachmentContext = React.createContext(null) interface Props { children: React.ReactNode - value: SearchContextType + value: AttachmentContextType } -export default function SearchProvider({ children, value }: Props) { - return {children} +export default function AttachmentProvider({ children, value }: Props) { + return {children} } -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 ' + 'useAttachmentContext has to be used within ' ) } - return searchContext + return attachmentContext } diff --git a/web/src/features/attachment/delete-dialog.tsx b/web/src/features/attachment/delete-dialog.tsx index 77e2173..20b8771 100644 --- a/web/src/features/attachment/delete-dialog.tsx +++ b/web/src/features/attachment/delete-dialog.tsx @@ -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()) diff --git a/web/src/features/attachment/delete-mailbox-dialog.tsx b/web/src/features/attachment/delete-mailbox-dialog.tsx deleted file mode 100644 index dafb2db..0000000 --- a/web/src/features/attachment/delete-mailbox-dialog.tsx +++ /dev/null @@ -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 . - -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 ( - { - onOpenChange(isOpen); - if (!isOpen) setDeleteMailboxId(undefined); - }} - handleConfirm={handleDelete} - className="max-w-xl" - isLoading={isLoading} - title={ - - {' '} - {t('mailbox.deleteMailboxDialog.title')} - - } - desc={ -
-

- {t('mailbox.deleteMailboxDialog.desc')} -

- - {t('mailbox.deleteMailboxDialog.warningTitle')} - {t('mailbox.deleteMailboxDialog.warningDesc')} - -
- } - confirmText={t('mailbox.deleteMailboxDialog.confirm')} - destructive - /> - ); -} diff --git a/web/src/features/attachment/edit-tag-dialog.tsx b/web/src/features/attachment/edit-tag-dialog.tsx deleted file mode 100644 index 832dfef..0000000 --- a/web/src/features/attachment/edit-tag-dialog.tsx +++ /dev/null @@ -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 . - -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([]); - 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: ( -
- - {t('search.addTags.updatedDesc')} -
- ), - }); - 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 ( - - - - - - {t('search.addTags.title')} - - - -
-
- {selectedTags.length === 0 ? ( -

{t('search.addTags.none')}

- ) : ( - selectedTags.map(tag => ( - - {tag} - - - )) - )} -
- e.stopPropagation()}> -
-
- setCommandOpen(true)} - className="h-9 pr-10" - onKeyDown={(e) => { - if (e.key === 'Enter' && inputValue.trim()) { - e.preventDefault(); - e.stopPropagation(); - handleAddTag(inputValue); - } - }} - /> - {inputValue.trim() && ( - - )} -
- {inputValue.trim() && filteredSuggestions.length === 0 && ( -
- {t('search.addTags.createHint', { tag: inputValue })} -
- )} - {commandOpen && inputValue && filteredSuggestions.length > 0 && ( - - - {filteredSuggestions.map(tag => ( - handleAddTag(tag)} - className="cursor-pointer" - > - - {tag} - - ))} - - - )} -
-
-
- -
-

- {t('search.addTags.selectedCount', { count: selectedTags.length })} -

-
- - -
-
-
-
- ); -} diff --git a/web/src/features/attachment/filter-reset.tsx b/web/src/features/attachment/filter-reset.tsx index da8aec2..c0e70bf 100644 --- a/web/src/features/attachment/filter-reset.tsx +++ b/web/src/features/attachment/filter-reset.tsx @@ -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; diff --git a/web/src/features/attachment/index.tsx b/web/src/features/attachment/index.tsx index 4af99c4..c0c43f6 100644 --- a/web/src/features/attachment/index.tsx +++ b/web/src/features/attachment/index.tsx @@ -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(undefined); - const [open, setOpen] = useDialogState(null) + const [currentAttachment, setCurrentAttachment] = React.useState(undefined); + const [open, setOpen] = useDialogState(null) + const [toDelete, setToDelete] = React.useState>>(new Map()); const [selected, setSelected] = React.useState>>(new Map()); const [selectedTags, setSelectedTags] = React.useState([]); const [sorting, setSorting] = React.useState([{ id: "date", desc: true }]); @@ -72,13 +76,15 @@ export default function AttachmentSearch() { <>
- { - setOpen('display'); - setSelectedAttachment(att); - }} setSortBy={setSortBy} setSortOrder={setSortOrder} /> @@ -128,42 +130,24 @@ export default function AttachmentSearch() { - {/* setOpen('display')} /> setOpen('delete')} /> - setOpen('edit-tags')} - /> - - setOpen('update-tags')} - /> - setOpen('restore')} /> - - setOpen('delete-mailbox')} - /> */} - +
); diff --git a/web/src/features/attachment/mail-display-dialog.tsx b/web/src/features/attachment/mail-display-dialog.tsx index 246162a..77b622b 100644 --- a/web/src/features/attachment/mail-display-dialog.tsx +++ b/web/src/features/attachment/mail-display-dialog.tsx @@ -17,11 +17,12 @@ // along with this program. If not, see . -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 ( - + -
- - {t('mail.emailViewer')} - -
+ {t('mail.emailViewer')}
- + +
- {currentEnvelope ? ( - + {isLoading ? ( +
{t('common.loading')}...
+ ) : error ? ( +
{t('attachment.emailMessageNotFound')}
+ ) : envelope ? ( + ) : (
{t('mail.noMessageSelected')}
)}
-
) +
+ ) } \ No newline at end of file diff --git a/web/src/features/attachment/mail-list-table.tsx b/web/src/features/attachment/mail-list-table.tsx index 0462a3a..7023b86 100644 --- a/web/src/features/attachment/mail-list-table.tsx +++ b/web/src/features/attachment/mail-list-table.tsx @@ -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[] = [ { @@ -133,12 +129,34 @@ export function AttachmentListTable({ ); }, - meta: { className: 'text-left' } + meta: { className: 'text-left text-xs' } }, { accessorKey: "subject", header: t('attachment.subject'), - cell: ({ row }) => {row.original.subject}, + cell: ({ row }) => { + return ( +
+
+ +
+ + + +
+
+ ); + }, meta: { className: 'text-left text-xs' }, minSize: 300, maxSize: 300, @@ -277,11 +295,7 @@ export function AttachmentListTable({ { - 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({ }} - {totalSelected > 0 && } ) } diff --git a/web/src/features/attachment/mail-list.tsx b/web/src/features/attachment/mail-list.tsx deleted file mode 100644 index 24e5f56..0000000 --- a/web/src/features/attachment/mail-list.tsx +++ /dev/null @@ -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 . - - -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 ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- - - - -
- ))} -
- ) - } - - return ( -
- {items.length > 0 && ( -
- 0 - ? true - : totalSelected > 0 - ? "indeterminate" - : false - } - onCheckedChange={handleToggleAll} - className="h-4 w-4" - /> - - {totalSelected > 0 - ? `${t('search.bulkActions.selected', { count: totalSelected })}` - : t('common.selectAll')} - -
- )} - - {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 ( -
{ - const target = e.target as HTMLElement - if (target.closest('input[type="checkbox"], button')) return - onEnvelopeChanged(item) - }} - > - toggleSelected(item.account_id, item.id)} - onClick={(e) => e.stopPropagation()} - className="h-4 w-4 shrink-0" - /> - - -
- -
-
-

{item.from}

-

- {item.subject} -

-
-
- {item.account_email} - - {item.mailbox_name} -
-

- {item.subject} -

- -
- {item.tags?.map((tag, i) => ( - {tag} - ))} -
-
-
- - {hasAttachments && ( -
- - {item.regular_attachment_count} -
- )} - - {formatBytes(item.size)} - - - {item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })} - - - - - - - - - e.stopPropagation()} - onSelect={(e) => { - e.stopPropagation(); - setCurrentEnvelope(item); - setOpen("edit-tags"); - }} - > - - {t('search.editTag')} - - e.stopPropagation()} - onSelect={(e) => { - e.stopPropagation(); - setCurrentEnvelope(item); - setOpen("restore"); - }} - > - - {t('restore_message.restore_to_imap')} - - e.stopPropagation()} - onSelect={(e) => { - e.stopPropagation(); - handleDelete(item); - }} - > - - {t('common.delete')} - - - -
-
-
- ) - })} - {totalSelected > 0 && } -
- ) -} diff --git a/web/src/features/attachment/mail-message-view.tsx b/web/src/features/attachment/mail-message-view.tsx index bd3c24e..a137f52 100644 --- a/web/src/features/attachment/mail-message-view.tsx +++ b/web/src/features/attachment/mail-message-view.tsx @@ -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(null); const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null); const [attachments, setAttachments] = useState(null); @@ -403,7 +395,7 @@ export function MailMessageView({ )}
- + !open && setNestedEmlFile(null)} diff --git a/web/src/features/attachment/mailbox-popover.tsx b/web/src/features/attachment/mailbox-popover.tsx index 00fc39b..646d140 100644 --- a/web/src/features/attachment/mailbox-popover.tsx +++ b/web/src/features/attachment/mailbox-popover.tsx @@ -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); diff --git a/web/src/features/attachment/more-filters-popover.tsx b/web/src/features/attachment/more-filters-popover.tsx index 6c231a0..0fb62ba 100644 --- a/web/src/features/attachment/more-filters-popover.tsx +++ b/web/src/features/attachment/more-filters-popover.tsx @@ -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({ diff --git a/web/src/features/attachment/restore-message-dialog.tsx b/web/src/features/attachment/restore-message-dialog.tsx index 07a6a7c..384525d 100644 --- a/web/src/features/attachment/restore-message-dialog.tsx +++ b/web/src/features/attachment/restore-message-dialog.tsx @@ -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 () => { diff --git a/web/src/features/attachment/sender-popover.tsx b/web/src/features/attachment/sender-popover.tsx index c9bd3e0..c40ea9d 100644 --- a/web/src/features/attachment/sender-popover.tsx +++ b/web/src/features/attachment/sender-popover.tsx @@ -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 diff --git a/web/src/features/attachment/table/data-table-row-actions.tsx b/web/src/features/attachment/table/data-table-row-actions.tsx index 7d4fe81..57747fb 100644 --- a/web/src/features/attachment/table/data-table-row-actions.tsx +++ b/web/src/features/attachment/table/data-table-row-actions.tsx @@ -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 } 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) { Open menu - + { e.stopPropagation() - setCurrentEnvelope(row.original) - setOpen("edit-tags") + setFilter((prev: any) => ({ ...prev, content_hash: row.original.content_hash })); }} > - {t('attachment.editTag')} + {t('attachment.showDuplicates')} - + + + + + { + e.stopPropagation() + e.preventDefault(); + downloadMutation.mutate(row.original.content_hash); + }} + > + {downloadMutation.isPending + ? t('attachment.downloading') + : t('attachment.download')} + + diff --git a/web/src/features/attachment/table/table.tsx b/web/src/features/attachment/table/table.tsx index 8c10049..7ef8938 100644 --- a/web/src/features/attachment/table/table.tsx +++ b/web/src/features/attachment/table/table.tsx @@ -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([]) diff --git a/web/src/features/attachment/table/toolbar.tsx b/web/src/features/attachment/table/toolbar.tsx index 9f72552..738642f 100644 --- a/web/src/features/attachment/table/toolbar.tsx +++ b/web/src/features/attachment/table/toolbar.tsx @@ -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({ - - } diff --git a/web/src/features/attachment/tag-filter-popover.tsx b/web/src/features/attachment/tag-filter-popover.tsx deleted file mode 100644 index d48d867..0000000 --- a/web/src/features/attachment/tag-filter-popover.tsx +++ /dev/null @@ -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 . - -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 ( - - - - - - -
- setSearch(e.target.value)} - placeholder={t('tag.search_placeholder')} - className="h-8 text-sm" - autoFocus - /> -
- - {!search && selectedTags.length > 0 && ( - <> -
-
- -
- - {t('tag.clear_all')} - - ({selectedTags.length}) -
-
- - )} - {isLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( -
- ))} -
- ) : filteredTags.length === 0 ? ( -

- {t('tag.no_tags_found')} -

- ) : ( - filteredTags.map(({ tag, count }) => { - const checked = selectedTags.includes(tag) - const id = `tag-${tag}` - - return ( -
handleTagToggle(tag)} - className={cn( - 'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer', - 'hover:bg-accent transition-colors' - )} - > - - handleTagToggle(tag) - } - onClick={(e) => - e.stopPropagation() - } - /> - - - {count} - -
- ) - }) - )} - - - - ) -} \ No newline at end of file diff --git a/web/src/features/attachment/text-search-input.tsx b/web/src/features/attachment/text-search-input.tsx index 66bb070..2fb956d 100644 --- a/web/src/features/attachment/text-search-input.tsx +++ b/web/src/features/attachment/text-search-input.tsx @@ -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("text") diff --git a/web/src/features/attachment/thread-dialog.tsx b/web/src/features/attachment/thread-dialog.tsx index b7dadc2..0def779 100644 --- a/web/src/features/attachment/thread-dialog.tsx +++ b/web/src/features/attachment/thread-dialog.tsx @@ -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>(new Set()); const { t } = useTranslation(); diff --git a/web/src/features/attachment/time-popover.tsx b/web/src/features/attachment/time-popover.tsx index 65dc9eb..68e3f9f 100644 --- a/web/src/features/attachment/time-popover.tsx +++ b/web/src/features/attachment/time-popover.tsx @@ -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('') const since = filter.since @@ -163,10 +163,10 @@ export function TimePopover() {
- + {t('time.since').toUpperCase()}: -
+
- + {t('time.before').toUpperCase()}: -
+
{ e.stopPropagation() setCurrentEnvelope(row.original) @@ -90,6 +91,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { { e.stopPropagation() setCurrentEnvelope(row.original) @@ -104,11 +106,12 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { { e.stopPropagation() handleDelete(row.original) }} - className='!text-red-500' + className='!text-red-500 text-xs' > {t('common.delete')} diff --git a/web/src/features/search/time-popover.tsx b/web/src/features/search/time-popover.tsx index 65dc9eb..a5ab118 100644 --- a/web/src/features/search/time-popover.tsx +++ b/web/src/features/search/time-popover.tsx @@ -163,10 +163,10 @@ export function TimePopover() {
- + {t('time.since').toUpperCase()}: -
+
- + {t('time.before').toUpperCase()}: -
+
{ + return useQuery({ + queryKey: ['envelope', accountId, envelopeId], + queryFn: () => get_envelope(accountId!, envelopeId!), + enabled: !!accountId && !!envelopeId, + staleTime: 10000, + retry: false + }); +}; \ No newline at end of file diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index 3010a34..b17a23b 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -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": "الموضوع" diff --git a/web/src/locales/da.json b/web/src/locales/da.json index fc69f33..015fa8e 100644 --- a/web/src/locales/da.json +++ b/web/src/locales/da.json @@ -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" diff --git a/web/src/locales/de.json b/web/src/locales/de.json index c4e5631..12aac9e 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -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" diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 941f17f..c5b51c0 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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" diff --git a/web/src/locales/es.json b/web/src/locales/es.json index 579e7e6..11f345d 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -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" diff --git a/web/src/locales/fi.json b/web/src/locales/fi.json index 76a95ea..01cefeb 100644 --- a/web/src/locales/fi.json +++ b/web/src/locales/fi.json @@ -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" diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 351bd36..13b3f3e 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -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" diff --git a/web/src/locales/it.json b/web/src/locales/it.json index 04f0495..c7ea973 100644 --- a/web/src/locales/it.json +++ b/web/src/locales/it.json @@ -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" diff --git a/web/src/locales/jp.json b/web/src/locales/jp.json index b8ad565..0e867fc 100644 --- a/web/src/locales/jp.json +++ b/web/src/locales/jp.json @@ -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": "件名" diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 772c8b9..636966c 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -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": "제목" diff --git a/web/src/locales/nl.json b/web/src/locales/nl.json index 04856bc..6d291d4 100644 --- a/web/src/locales/nl.json +++ b/web/src/locales/nl.json @@ -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" diff --git a/web/src/locales/no.json b/web/src/locales/no.json index e59ce8e..2094bc3 100644 --- a/web/src/locales/no.json +++ b/web/src/locales/no.json @@ -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" diff --git a/web/src/locales/pl.json b/web/src/locales/pl.json index 6cb7ffb..151463f 100644 --- a/web/src/locales/pl.json +++ b/web/src/locales/pl.json @@ -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" diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index c51e74c..1244c79 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -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" diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index b2f6625..d008d96 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -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": "Тема" diff --git a/web/src/locales/sv.json b/web/src/locales/sv.json index 1abcf28..f544e52 100644 --- a/web/src/locales/sv.json +++ b/web/src/locales/sv.json @@ -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" diff --git a/web/src/locales/zh-tw.json b/web/src/locales/zh-tw.json index 52ecfb3..75d0b30 100644 --- a/web/src/locales/zh-tw.json +++ b/web/src/locales/zh-tw.json @@ -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": "主旨" diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index 6566b2b..a95ab39 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -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": "主题"