diff --git a/src/modules/imap/executor.rs b/src/modules/imap/executor.rs index 95200d5..e694d26 100644 --- a/src/modules/imap/executor.rs +++ b/src/modules/imap/executor.rs @@ -79,12 +79,26 @@ impl ImapExecutor { Ok(result) } + pub async fn append( + &self, + mailbox_name: impl AsRef, + flags: Option<&str>, + internaldate: Option<&str>, + content: impl AsRef<[u8]>, + ) -> BichonResult<()> { + let mut session = self.get_connection().await?; + session + .append(mailbox_name, flags, internaldate, content) + .await + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)) + } + pub async fn fetch_new_mail( &self, account: &AccountModel, mailbox: &MailBox, start_uid: u64, - before: Option<&str> + before: Option<&str>, ) -> BichonResult<()> { assert!(start_uid > 0, "start_uid must be greater than 0"); @@ -93,12 +107,7 @@ impl ImapExecutor { None => format!("UID {start_uid}:*"), }; - let uid_list = self - .uid_search( - &mailbox.encoded_name(), - &query, - ) - .await?; + let uid_list = self.uid_search(&mailbox.encoded_name(), &query).await?; let len = uid_list.len(); if len == 0 { diff --git a/src/modules/message/append.rs b/src/modules/message/append.rs new file mode 100644 index 0000000..52914a5 --- /dev/null +++ b/src/modules/message/append.rs @@ -0,0 +1,104 @@ +use crate::{ + encode_mailbox_name, + modules::{ + account::migration::{AccountModel, AccountType}, + context::executors::MAIL_CONTEXT, + error::{code::ErrorCode, BichonResult}, + indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER}, + }, + raise_error, +}; +use poem_openapi::Object; +use serde::{Deserialize, Serialize}; + +const MAX_RESTORE_COUNT: usize = 100; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] +pub struct RestoreMessagesRequest { + /// Message IDs to restore (max 100) + pub message_ids: Vec, +} + +pub async fn restore_emails(account_id: u64, message_ids: Vec) -> BichonResult<()> { + if message_ids.len() > MAX_RESTORE_COUNT { + return Err(raise_error!( + format!( + "Too many messages to restore: {} (max {})", + message_ids.len(), + MAX_RESTORE_COUNT + ), + ErrorCode::InvalidParameter + )); + } + + let account = AccountModel::check_account_exists(account_id).await?; + if !matches!(account.account_type, AccountType::IMAP) { + return Err(raise_error!( + "Account type is not IMAP".into(), + ErrorCode::Incompatible + )); + } + let executor = MAIL_CONTEXT.imap(account.id).await?; + + let mut failed = Vec::new(); + + for message_id in message_ids { + let result: BichonResult<()> = async { + let envelope = ENVELOPE_INDEX_MANAGER + .get_envelope_by_id(account_id, message_id) + .await? + .ok_or_else(|| { + raise_error!( + format!( + "Envelope not found: account_id={} message_id={}", + account_id, message_id + ), + ErrorCode::ResourceNotFound + ) + })?; + + let eml = EML_INDEX_MANAGER + .get(account_id, message_id) + .await? + .ok_or_else(|| { + raise_error!( + format!( + "Email record not found: account_id={} id={}", + account_id, message_id + ), + ErrorCode::ResourceNotFound + ) + })?; + + if let Some(mailbox_name) = envelope.mailbox_name { + executor + .append(encode_mailbox_name!(&mailbox_name), None, None, &eml) + .await?; + } + + Ok(()) + } + .await; + + if let Err(err) = result { + failed.push(message_id); + tracing::warn!( + account_id = account_id, + message_id = message_id, + error = ?err, + "Failed to restore email" + ); + } + } + + if !failed.is_empty() { + tracing::info!( + account_id = account_id, + failed_count = failed.len(), + failed_message_ids = ?failed, + "Restore emails finished with partial failures" + ); + } + + Ok(()) +} diff --git a/src/modules/message/mod.rs b/src/modules/message/mod.rs index 4ff2941..41bf666 100644 --- a/src/modules/message/mod.rs +++ b/src/modules/message/mod.rs @@ -16,7 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - +pub mod append; pub mod content; pub mod delete; pub mod list; diff --git a/src/modules/rest/api/message.rs b/src/modules/rest/api/message.rs index c1fd6e7..da33889 100644 --- a/src/modules/rest/api/message.rs +++ b/src/modules/rest/api/message.rs @@ -21,6 +21,8 @@ use crate::modules::common::auth::ClientContext; use crate::modules::indexer::envelope::Envelope; use crate::modules::indexer::manager::EML_INDEX_MANAGER; use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER; +use crate::modules::message::append::restore_emails; +use crate::modules::message::append::RestoreMessagesRequest; use crate::modules::message::content::{retrieve_email_content, FullMessageContent}; use crate::modules::message::delete::delete_messages_impl; use crate::modules::message::list::{get_thread_messages, list_messages_impl}; @@ -227,6 +229,25 @@ impl MessageApi { Ok(attachment) } + #[oai( + path = "/restore-messages/:account_id", + method = "post", + operation_id = "restore_messages" + )] + async fn restore_messages( + &self, + account_id: Path, + /// Message IDs to restore. + payload: Json, + context: ClientContext, + ) -> ApiResult<()> { + let account_id = account_id.0; + context + .require_permission(Some(account_id), Permission::DATA_EXPORT_BATCH) + .await?; + Ok(restore_emails(account_id, payload.0.message_ids).await?) + } + /// Downloads a specific attachment from an email. Requires `name` query parameter. #[oai( path = "/download-attachment/:account_id/:message_id", diff --git a/web/src/api/mailbox/envelope/api.ts b/web/src/api/mailbox/envelope/api.ts index 69accde..03821dd 100644 --- a/web/src/api/mailbox/envelope/api.ts +++ b/web/src/api/mailbox/envelope/api.ts @@ -97,3 +97,12 @@ export const download_message = async (accountId: number, id: number) => { const blob = new Blob([response.data]); saveAs(blob, `${id}.eml`); }; + + + +export const restore_message = async (accountId: number, messageIds: number[]) => { + const response = await axiosInstance.post(`/api/v1/restore-messages/${accountId}`, { + message_ids: messageIds, + }); + return response.data; +}; \ No newline at end of file diff --git a/web/src/components/confirm-dialog.tsx b/web/src/components/confirm-dialog.tsx index 7d1a4e9..f2b706e 100644 --- a/web/src/components/confirm-dialog.tsx +++ b/web/src/components/confirm-dialog.tsx @@ -29,6 +29,7 @@ import { } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { useTranslation } from 'react-i18next' +import { Loader2 } from 'lucide-react' interface ConfirmDialogProps { open: boolean @@ -79,6 +80,7 @@ export function ConfirmDialog(props: ConfirmDialogProps) { onClick={handleConfirm} disabled={disabled || isLoading} > + {isLoading && } {confirmText ?? t('dialogs.continue')} diff --git a/web/src/features/mailbox/components/mail-list.tsx b/web/src/features/mailbox/components/mail-list.tsx index d018200..fd502cb 100644 --- a/web/src/features/mailbox/components/mail-list.tsx +++ b/web/src/features/mailbox/components/mail-list.tsx @@ -19,7 +19,7 @@ import { cn, dateFnsLocaleMap, formatBytes } from "@/lib/utils" import { formatDistanceToNow } from "date-fns" -import { MailIcon, Paperclip, Trash2 } from "lucide-react" +import { MailIcon, MoreVertical, Paperclip, TagIcon, Trash2 } from "lucide-react" import { Skeleton } from "@/components/ui/skeleton" import { EmailEnvelope } from "@/api" import { useMailboxContext } from "../context" @@ -28,6 +28,8 @@ import { MailBulkActions } from "./bulk-actions" import { Badge } from "@/components/ui/badge" import { useTranslation } from 'react-i18next' import { enUS } from "date-fns/locale" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" interface MailListProps { items: EmailEnvelope[] @@ -179,15 +181,44 @@ export function MailList({ {item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true, locale })} - + + + + + + + + e.stopPropagation()} + onSelect={(e) => { + e.stopPropagation(); + setSelected(new Set([item.id])); + setOpen("restore"); + }} + > + + {t('restore_message.restore_to_imap', 'Restore Mail')} + + e.stopPropagation()} + onSelect={(e) => { + e.stopPropagation(); + handleDelete(item); + }} + > + + {t('common.delete')} + + + diff --git a/web/src/features/mailbox/components/mail.tsx b/web/src/features/mailbox/components/mail.tsx index 90266b3..3f737c6 100644 --- a/web/src/features/mailbox/components/mail.tsx +++ b/web/src/features/mailbox/components/mail.tsx @@ -50,6 +50,7 @@ import { animated, useSpring } from "@react-spring/web" import { TransitionProps } from "@mui/material/transitions" import Collapse from "@mui/material/Collapse" import { FolderIcon } from "lucide-react" +import { RestoreMessageDialog } from "./restore-message-dialog" interface MailProps { @@ -402,6 +403,13 @@ export function Mail({ open={open === 'move-to-trash'} onOpenChange={() => setOpen('move-to-trash')} /> + + setOpen('restore')} + /> + ) } \ No newline at end of file diff --git a/web/src/features/mailbox/components/restore-message-dialog.tsx b/web/src/features/mailbox/components/restore-message-dialog.tsx new file mode 100644 index 0000000..3e346c1 --- /dev/null +++ b/web/src/features/mailbox/components/restore-message-dialog.tsx @@ -0,0 +1,106 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +import { restore_message } from '@/api/mailbox/envelope/api' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { toast } from '@/hooks/use-toast' +import { useMutation } from '@tanstack/react-query' +import { AxiosError } from 'axios' +import { useTranslation } from 'react-i18next' +import { useMailboxContext } from '../context' +import { ToastAction } from '@/components/ui/toast' + +interface RestoreMessageDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function RestoreMessageDialog({ + open, + onOpenChange +}: RestoreMessageDialogProps) { + const { t } = useTranslation() + const { selectedAccountId, selected, setSelected } = useMailboxContext(); + + + const restoreMutation = useMutation({ + mutationFn: (messageIds: number[]) => + restore_message(selectedAccountId!, messageIds), + onSuccess: handleRestoreSuccess, + onError: handleRestoreError, + }); + + function handleRestoreSuccess() { + toast({ + title: t('restore_message.success', 'Messages restored'), + description: t( + 'restore_message.successDesc', + 'The selected messages have been restored to the IMAP server.' + ), + action: ( + + {t('common.close')} + + ), + }); + setSelected(new Set()); + onOpenChange(false); + } + + function handleRestoreError(error: AxiosError) { + const errorMessage = + (error.response?.data as { message?: string })?.message || + error.message || + t('restore_message.failed', 'Failed to restore messages'); + + toast({ + variant: 'destructive', + title: t( + 'restore_message.failedTitle', + 'Restore failed' + ), + description: errorMessage, + action: ( + + {t('common.tryAgain')} + + ), + }); + + console.error(error); + } + + + return ( + restoreMutation.mutate(Array.from(selected))} + className="sm:max-w-sm" + isLoading={restoreMutation.isPending} + disabled={restoreMutation.isPending} + /> + ) +} diff --git a/web/src/features/mailbox/context/index.tsx b/web/src/features/mailbox/context/index.tsx index 230fcb3..5bfb98f 100644 --- a/web/src/features/mailbox/context/index.tsx +++ b/web/src/features/mailbox/context/index.tsx @@ -21,7 +21,7 @@ import React from 'react' import { MailboxData } from '@/api/mailbox/api' import { EmailEnvelope } from '@/api' -export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters' +export type MailboxDialogType = 'mailbox' | 'display' | 'move-to-trash' | 'filters' | 'restore' interface MailboxContextType { open: MailboxDialogType | null diff --git a/web/src/features/search/add-tag-dialog.tsx b/web/src/features/search/add-tag-dialog.tsx index e3320a7..63fc935 100644 --- a/web/src/features/search/add-tag-dialog.tsx +++ b/web/src/features/search/add-tag-dialog.tsx @@ -25,18 +25,17 @@ import { useState, useEffect } from 'react'; import { useAvailableTags } from '@/hooks/use-available-tags'; import { useUpdateTags } from '@/hooks/use-update-tags'; import { toast } from '@/hooks/use-toast'; -import { EmailEnvelope } from '@/api'; import { validateTag } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; +import { useSearchContext } from './context'; interface Props { open: boolean onOpenChange: (open: boolean) => void - currentEnvelope: EmailEnvelope | undefined } -export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) { +export function EditTagsDialog({ open, onOpenChange }: Props) { const { tags: availableTags } = useAvailableTags(); const queryClient = useQueryClient(); const { mutate, isPending } = useUpdateTags(); @@ -45,6 +44,8 @@ export function EditTagsDialog({ open, onOpenChange, currentEnvelope }: Props) { const [commandOpen, setCommandOpen] = useState(false); const { t } = useTranslation(); + const { currentEnvelope } = useSearchContext() + useEffect(() => { if (open && currentEnvelope) { setSelectedTags(currentEnvelope.tags || []); diff --git a/web/src/features/search/context/index.tsx b/web/src/features/search/context/index.tsx index 0605a71..f8e5150 100644 --- a/web/src/features/search/context/index.tsx +++ b/web/src/features/search/context/index.tsx @@ -20,7 +20,7 @@ import React from 'react' import { EmailEnvelope } from '@/api' -export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' +export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore' interface SearchContextType { open: SearchDialogType | null diff --git a/web/src/features/search/index.tsx b/web/src/features/search/index.tsx index e2b4ab7..49ded07 100644 --- a/web/src/features/search/index.tsx +++ b/web/src/features/search/index.tsx @@ -38,6 +38,7 @@ import { EnvelopeTags } from './tag-facet'; import { EditTagsDialog } from './add-tag-dialog'; import { useTranslation } from 'react-i18next'; import Logo from '@/assets/logo.svg' +import { RestoreMessageDialog } from './restore-message-dialog'; export default function Search() { const { t } = useTranslation() @@ -187,7 +188,7 @@ export default function Search() { setOpen('edit-tags')} currentEnvelope={selectedEnvelope} + onOpenChange={() => setOpen('edit-tags')} /> setOpen('search-form')} /> + + setOpen('restore')} + /> diff --git a/web/src/features/search/mail-list.tsx b/web/src/features/search/mail-list.tsx index 8ed08ac..d157ae2 100644 --- a/web/src/features/search/mail-list.tsx +++ b/web/src/features/search/mail-list.tsx @@ -243,7 +243,17 @@ export function MailList({ {t('search.editTag')} - + e.stopPropagation()} + onSelect={(e) => { + e.stopPropagation(); + setCurrentEnvelope(item); + setOpen("restore"); + }} + > + + {t('restore_message.restore_to_imap', 'Restore Mail')} + e.stopPropagation()} diff --git a/web/src/features/search/restore-message-dialog.tsx b/web/src/features/search/restore-message-dialog.tsx new file mode 100644 index 0000000..e4aa609 --- /dev/null +++ b/web/src/features/search/restore-message-dialog.tsx @@ -0,0 +1,105 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +import { restore_message } from '@/api/mailbox/envelope/api' +import { ConfirmDialog } from '@/components/confirm-dialog' +import { toast } from '@/hooks/use-toast' +import { useMutation } from '@tanstack/react-query' +import { AxiosError } from 'axios' +import { useTranslation } from 'react-i18next' +import { ToastAction } from '@/components/ui/toast' +import { useSearchContext } from './context' + +interface RestoreMessageDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function RestoreMessageDialog({ + open, + onOpenChange +}: RestoreMessageDialogProps) { + const { t } = useTranslation() + const { currentEnvelope } = useSearchContext() + + + const restoreMutation = useMutation({ + mutationFn: () => + restore_message(currentEnvelope!.account_id, [currentEnvelope!.id]), + onSuccess: handleRestoreSuccess, + onError: handleRestoreError, + }); + + function handleRestoreSuccess() { + toast({ + title: t('restore_message.success', 'Messages restored'), + description: t( + 'restore_message.successDesc', + 'The selected messages have been restored to the IMAP server.' + ), + action: ( + + {t('common.close')} + + ), + }); + onOpenChange(false); + } + + function handleRestoreError(error: AxiosError) { + const errorMessage = + (error.response?.data as { message?: string })?.message || + error.message || + t('restore_message.failed', 'Failed to restore messages'); + + toast({ + variant: 'destructive', + title: t( + 'restore_message.failedTitle', + 'Restore failed' + ), + description: errorMessage, + action: ( + + {t('common.tryAgain')} + + ), + }); + + console.error(error); + } + + + return ( + restoreMutation.mutate()} + className="sm:max-w-sm" + isLoading={restoreMutation.isPending} + disabled={restoreMutation.isPending} + /> + ) +} diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index 831d99b..c0588fd 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -1439,5 +1439,15 @@ "title": "تسجيل الخروج", "desc": "هل أنت متأكد أنك تريد تسجيل الخروج؟ ستحتاج إلى تسجيل الدخول مرة أخرى للوصول إلى حسابك.", "confirm": "تسجيل الخروج" + }, + "restore_message": { + "title": "استعادة الرسائل", + "desc": "سيقوم هذا الإجراء بإعادة رفع الرسائل المختارة من Bichon واستعادتها إلى صناديق البريد المقابلة لها على خادم IMAP.", + "confirm": "تنفيذ الاستعادة", + "restore_to_imap": "استعادة البريد", + "success": "تمت استعادة الرسائل بنجاح", + "successDesc": "تمت استعادة الرسائل المختارة إلى خادم IMAP بنجاح.", + "failed": "فشلت استعادة الرسائل", + "failedTitle": "فشل الاستعادة" } } \ No newline at end of file diff --git a/web/src/locales/da.json b/web/src/locales/da.json index bb00326..ff8529b 100644 --- a/web/src/locales/da.json +++ b/web/src/locales/da.json @@ -1439,5 +1439,15 @@ "title": "Log ud", "desc": "Er du sikker på, at du vil logge ud? Du skal logge ind igen for at få adgang til din konto.", "confirm": "Log ud" + }, + "restore_message": { + "title": "Gendan meddelelser", + "desc": "Denne handling vil uploade de valgte meddelelser fra Bichon og gendanne dem til deres tilsvarende postkasser på IMAP-serveren.", + "confirm": "Gendan", + "restore_to_imap": "Gendan e-mail", + "success": "Meddelelser gendannet", + "successDesc": "De valgte meddelelser er blevet gendannet til IMAP-serveren.", + "failed": "Kunne ikke gendanne meddelelser", + "failedTitle": "Gendannelse mislykkedes" } } \ No newline at end of file diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 338c309..683e35e 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -1439,5 +1439,15 @@ "title": "Abmelden", "desc": "Möchten Sie sich wirklich abmelden? Sie müssen sich erneut anmelden, um auf Ihr Konto zuzugreifen.", "confirm": "Abmelden" + }, + "restore_message": { + "title": "Nachrichten wiederherstellen", + "desc": "Diese Aktion lädt die ausgewählten Nachrichten von Bichon hoch und stellt sie in den entsprechenden Postfächern auf dem IMAP-Server wieder her.", + "confirm": "Wiederherstellen", + "restore_to_imap": "E-Mail wiederherstellen", + "success": "Nachrichten wiederhergestellt", + "successDesc": "Die ausgewählten Nachrichten wurden erfolgreich auf dem IMAP-Server wiederhergestellt.", + "failed": "Wiederherstellung fehlgeschlagen", + "failedTitle": "Fehler bei der Wiederherstellung" } } \ No newline at end of file diff --git a/web/src/locales/en.json b/web/src/locales/en.json index e277784..dd86b61 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -1439,5 +1439,15 @@ "title": "Sign out", "desc": "Are you sure you want to sign out? You will need to sign in again to access your account.", "confirm": "Sign out" + }, + "restore_message": { + "title": "Restore Messages", + "desc": "This action will append the selected messages from Bichon back to their corresponding mailboxes on the IMAP server.", + "confirm": "Restore", + "restore_to_imap": "Restore Mail", + "success": "Messages Restored", + "successDesc": "The selected messages have been successfully restored to the IMAP server.", + "failed": "Failed to restore messages", + "failedTitle": "Restore Failed" } } \ No newline at end of file diff --git a/web/src/locales/es.json b/web/src/locales/es.json index 61eefd9..8fa946c 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -1439,5 +1439,15 @@ "title": "Cerrar sesión", "desc": "¿Está seguro de que desea cerrar sesión? Necesitará iniciar sesión nuevamente para acceder a su cuenta.", "confirm": "Cerrar sesión" + }, + "restore_message": { + "title": "Restaurar mensajes", + "desc": "Esta acción volverá a cargar los mensajes seleccionados de Bichon y los restaurará en sus carpetas correspondientes en el servidor IMAP.", + "confirm": "Restaurar", + "restore_to_imap": "Restaurar correo", + "success": "Mensajes restaurados", + "successDesc": "Los mensajes seleccionados se han restaurado correctamente en el servidor IMAP.", + "failed": "Error al restaurar los mensajes", + "failedTitle": "Error de restauración" } } \ No newline at end of file diff --git a/web/src/locales/fi.json b/web/src/locales/fi.json index 55f810a..0c1d32c 100644 --- a/web/src/locales/fi.json +++ b/web/src/locales/fi.json @@ -1439,5 +1439,15 @@ "title": "Kirjaudu ulos", "desc": "Haluatko varmasti kirjautua ulos? Sinun täytyy kirjautua uudelleen päästäksesi tilillesi.", "confirm": "Kirjaudu ulos" + }, + "restore_message": { + "title": "Palauta viestit", + "desc": "Tämä toiminto lataa valitut viestit Bichonista ja palauttaa ne vastaaviin postilaatikoihin IMAP-palvelimella.", + "confirm": "Palauta", + "restore_to_imap": "Palauta sähköposti", + "success": "Viestit palautettu", + "successDesc": "Valitut viestit on palautettu onnistuneesti IMAP-palvelimelle.", + "failed": "Viestien palautus epäonnistui", + "failedTitle": "Palautus epäonnistui" } } \ No newline at end of file diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 7787cc6..4f40a89 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -1439,5 +1439,15 @@ "title": "Déconnexion", "desc": "Êtes-vous sûr de vouloir vous déconnecter ? Vous devrez vous reconnecter pour accéder à votre compte.", "confirm": "Déconnexion" + }, + "restore_message": { + "title": "Restaurer les messages", + "desc": "Cette action téléchargera les messages sélectionnés depuis Bichon et les restaurera dans leurs boîtes aux lettres correspondantes sur le serveur IMAP.", + "confirm": "Restaurer", + "restore_to_imap": "Restaurer le courrier", + "success": "Messages restaurés", + "successDesc": "Les messages sélectionnés ont été restaurés avec succès sur le serveur IMAP.", + "failed": "Échec de la restauration des messages", + "failedTitle": "Échec de la restauration" } } \ No newline at end of file diff --git a/web/src/locales/it.json b/web/src/locales/it.json index 0a6fd02..bd9ea1b 100644 --- a/web/src/locales/it.json +++ b/web/src/locales/it.json @@ -1439,5 +1439,15 @@ "title": "Disconnetti", "desc": "Sei sicuro di voler disconnetterti? Dovrai accedere di nuovo per usare il tuo account.", "confirm": "Disconnetti" + }, + "restore_message": { + "title": "Ripristina messaggi", + "desc": "Questa azione caricherà i messaggi selezionati da Bichon e li ripristinerà nelle rispettive caselle di posta sul server IMAP.", + "confirm": "Ripristina", + "restore_to_imap": "Ripristina posta", + "success": "Messaggi ripristinati", + "successDesc": "I messaggi selezionati sono stati ripristinati con successo sul server IMAP.", + "failed": "Impossibile ripristinare i messaggi", + "failedTitle": "Ripristino fallito" } } \ No newline at end of file diff --git a/web/src/locales/jp.json b/web/src/locales/jp.json index c46af3c..4457022 100644 --- a/web/src/locales/jp.json +++ b/web/src/locales/jp.json @@ -1439,5 +1439,15 @@ "title": "サインアウト", "desc": "本当にサインアウトしますか? アカウントにアクセスするには再度サインインが必要です。", "confirm": "サインアウト" + }, + "restore_message": { + "title": "メッセージを復元", + "desc": "この操作により、選択したメッセージをBichonから再アップロードし、IMAPサーバー上の対応するメールボックスに復元します。", + "confirm": "復元を実行", + "restore_to_imap": "メールを復元", + "success": "メッセージを復元しました", + "successDesc": "選択したメッセージがIMAPサーバーに正常に復元されました。", + "failed": "メッセージの復元に失敗しました", + "failedTitle": "復元失敗" } } \ No newline at end of file diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 85fe769..42d9111 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -1439,5 +1439,15 @@ "title": "로그아웃", "desc": "로그아웃하시겠습니까? 계정에 접근하려면 다시 로그인해야 합니다.", "confirm": "로그아웃" + }, + "restore_message": { + "title": "메시지 복원", + "desc": "이 작업은 선택한 메시지를 Bichon에서 다시 업로드하여 IMAP 서버의 해당 사서함으로 복원합니다.", + "confirm": "복원 실행", + "restore_to_imap": "메일 복원", + "success": "메시지 복원 완료", + "successDesc": "선택한 메시지가 IMAP 서버로 성공적으로 복원되었습니다.", + "failed": "메시지 복원 실패", + "failedTitle": "복원 실패" } } \ No newline at end of file diff --git a/web/src/locales/nl.json b/web/src/locales/nl.json index 6103907..6c70c4e 100644 --- a/web/src/locales/nl.json +++ b/web/src/locales/nl.json @@ -1439,5 +1439,15 @@ "title": "Uitloggen", "desc": "Weet je zeker dat je wilt uitloggen? Je moet opnieuw inloggen om toegang te krijgen tot je account.", "confirm": "Uitloggen" + }, + "restore_message": { + "title": "Berichten herstellen", + "desc": "Deze actie uploadt de geselecteerde berichten van Bichon en herstelt ze in de bijbehorende mailboxen op de IMAP-server.", + "confirm": "Herstellen", + "restore_to_imap": "E-mail herstellen", + "success": "Berichten hersteld", + "successDesc": "De geselecteerde berichten zijn succesvol hersteld op de IMAP-server.", + "failed": "Herstellen van berichten mislukt", + "failedTitle": "Herstel mislukt" } } \ No newline at end of file diff --git a/web/src/locales/no.json b/web/src/locales/no.json index 48d7338..2fe5a5c 100644 --- a/web/src/locales/no.json +++ b/web/src/locales/no.json @@ -1439,5 +1439,15 @@ "title": "Logg ut", "desc": "Er du sikker på at du vil logge ut? Du må logge inn igjen for å få tilgang til kontoen din.", "confirm": "Logg ut" + }, + "restore_message": { + "title": "Gjenopprett meldinger", + "desc": "Denne handlingen vil laste opp de valgte meldingene fra Bichon og gjenopprette dem til deres tilsvarende postbokser på IMAP-serveren.", + "confirm": "Gjenopprett", + "restore_to_imap": "Gjenopprett e-post", + "success": "Meldinger gjenopprettet", + "successDesc": "De valgte meldingene har blitt gjenopprettet til IMAP-serveren.", + "failed": "Kunne ikke gjenopprette meldinger", + "failedTitle": "Gjenoppretting mislyktes" } } \ No newline at end of file diff --git a/web/src/locales/pl.json b/web/src/locales/pl.json index 3e25459..35cfe11 100644 --- a/web/src/locales/pl.json +++ b/web/src/locales/pl.json @@ -1439,5 +1439,15 @@ "title": "Wyloguj się", "desc": "Czy na pewno chcesz się wylogować? Aby uzyskać dostęp do konta, będziesz musiał zalogować się ponownie.", "confirm": "Wyloguj się" + }, + "restore_message": { + "title": "Przywróć wiadomości", + "desc": "Ta operacja prześle wybrane wiadomości z Bichon i przywróci je do odpowiednich skrzynek pocztowych na serwerze IMAP.", + "confirm": "Przywróć", + "restore_to_imap": "Przywróć pocztę", + "success": "Wiadomości przywrócone", + "successDesc": "Wybrane wiadomości zostały pomyślnie przywrócone na serwer IMAP.", + "failed": "Nie udało się przywrócić wiadomości", + "failedTitle": "Przywracanie nie powiodło się" } } \ No newline at end of file diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index 4e709f3..b525d37 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -1439,5 +1439,15 @@ "title": "Sair", "desc": "Tem certeza de que deseja sair? Você precisará entrar novamente para acessar sua conta.", "confirm": "Sair" + }, + "restore_message": { + "title": "Restaurar mensagens", + "desc": "Esta ação irá carregar as mensagens selecionadas do Bichon e restaurá-las nas respetivas caixas de correio no servidor IMAP.", + "confirm": "Restaurar", + "restore_to_imap": "Restaurar e-mail", + "success": "Mensagens restauradas", + "successDesc": "As mensagens selecionadas foram restauradas com sucesso para o servidor IMAP.", + "failed": "Falha ao restaurar mensagens", + "failedTitle": "Falha na restauração" } } \ No newline at end of file diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 956f2b2..f5430fb 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -1439,5 +1439,15 @@ "title": "Выйти", "desc": "Вы уверены, что хотите выйти? Вам нужно будет снова войти, чтобы получить доступ к аккаунту.", "confirm": "Выйти" + }, + "restore_message": { + "title": "Восстановить сообщения", + "desc": "Это действие загрузит выбранные сообщения из Bichon и восстановит их в соответствующих почтовых ящиках на IMAP-сервере.", + "confirm": "Восстановить", + "restore_to_imap": "Восстановить почту", + "success": "Сообщения восстановлены", + "successDesc": "Выбранные сообщения были успешно восстановлены на IMAP-сервере.", + "failed": "Не удалось восстановить сообщения", + "failedTitle": "Ошибка восстановления" } } \ No newline at end of file diff --git a/web/src/locales/sv.json b/web/src/locales/sv.json index 4f22b5b..4aa7e0e 100644 --- a/web/src/locales/sv.json +++ b/web/src/locales/sv.json @@ -1439,5 +1439,15 @@ "title": "Logga ut", "desc": "Är du säker på att du vill logga ut? Du måste logga in igen för att få åtkomst till ditt konto.", "confirm": "Logga ut" + }, + "restore_message": { + "title": "Återställ meddelanden", + "desc": "Denna åtgärd kommer att ladda upp de valda meddelandena från Bichon och återställa dem till deras motsvarande brevlådor på IMAP-servern.", + "confirm": "Återställ", + "restore_to_imap": "Återställ e-post", + "success": "Meddelanden återställda", + "successDesc": "De valda meddelandena har återställts till IMAP-servern.", + "failed": "Misslyckades med att återställa meddelanden", + "failedTitle": "Återställning misslyckades" } } \ No newline at end of file diff --git a/web/src/locales/zh-tw.json b/web/src/locales/zh-tw.json index 284676e..c1c37da 100644 --- a/web/src/locales/zh-tw.json +++ b/web/src/locales/zh-tw.json @@ -1439,5 +1439,15 @@ "title": "登出", "desc": "您確定要登出嗎?您需要重新登入才能存取帳戶。", "confirm": "登出" + }, + "restore_message": { + "title": "還原郵件", + "desc": "此操作將把選定的郵件從 Bichon 系統重新上傳並還原到其在 IMAP 伺服器上對應的信箱中。", + "confirm": "執行還原", + "restore_to_imap": "還原郵件", + "success": "郵件還原成功", + "successDesc": "選定的郵件已成功還原到 IMAP 伺服器。", + "failed": "還原郵件失敗", + "failedTitle": "還原失敗" } } \ No newline at end of file diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index cc28add..d05d6eb 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -1439,5 +1439,15 @@ "title": "退出登录", "desc": "您确定要退出登录吗?您需要重新登录才能访问账户。", "confirm": "退出登录" + }, + "restore_message": { + "title": "还原邮件", + "desc": "此操作将把选定的邮件从 Bichon 系统重新上传并恢复到其在 IMAP 服务器上对应的邮箱中。", + "confirm": "执行还原", + "restore_to_imap": "还原邮件", + "success": "邮件还原成功", + "successDesc": "选定的邮件已成功恢复到 IMAP 服务器。", + "failed": "还原邮件失败", + "failedTitle": "还原失败" } } \ No newline at end of file