feat: support restoring single message to IMAP #77

This commit is contained in:
rustmailer
2025-12-31 22:56:06 +08:00
parent 1c58b516dd
commit a6216c2ce6
33 changed files with 618 additions and 25 deletions
+4 -3
View File
@@ -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 || []);
+1 -1
View File
@@ -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
+8 -1
View File
@@ -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() {
<EditTagsDialog
key='edit-tags-dialog'
open={open === 'edit-tags'}
onOpenChange={() => setOpen('edit-tags')} currentEnvelope={selectedEnvelope}
onOpenChange={() => setOpen('edit-tags')}
/>
<SearchFormDialog
@@ -196,6 +197,12 @@ export default function Search() {
open={open === 'search-form'}
onOpenChange={() => setOpen('search-form')}
/>
<RestoreMessageDialog
key='restore-mail-dialog'
open={open === 'restore'}
onOpenChange={() => setOpen('restore')}
/>
</SearchProvider>
</Main>
</>
+11 -1
View File
@@ -243,7 +243,17 @@ export function MailList({
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('search.editTag')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => e.stopPropagation()}
onSelect={(e) => {
e.stopPropagation();
setCurrentEnvelope(item);
setOpen("restore");
}}
>
<TagIcon className="ml-2 h-3.5 w-3.5" />
{t('restore_message.restore_to_imap', 'Restore Mail')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => e.stopPropagation()}
@@ -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 <http://www.gnu.org/licenses/>.
import { restore_message } from '@/api/mailbox/envelope/api'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { toast } from '@/hooks/use-toast'
import { useMutation } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
import { 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: (
<ToastAction altText={t('common.close')}>
{t('common.close')}
</ToastAction>
),
});
onOpenChange(false);
}
function handleRestoreError(error: AxiosError) {
const errorMessage =
(error.response?.data as { message?: string })?.message ||
error.message ||
t('restore_message.failed', 'Failed to restore messages');
toast({
variant: 'destructive',
title: t(
'restore_message.failedTitle',
'Restore failed'
),
description: errorMessage,
action: (
<ToastAction altText={t('common.tryAgain')}>
{t('common.tryAgain')}
</ToastAction>
),
});
console.error(error);
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={t('restore_message.title', 'Restore messages')}
desc={t(
'restore_message.desc',
'This action will append the selected messages from Bichon to their corresponding mailboxes on the IMAP server.'
)}
confirmText={t('restore_message.confirm', 'Restore')}
handleConfirm={() => restoreMutation.mutate()}
className="sm:max-w-sm"
isLoading={restoreMutation.isPending}
disabled={restoreMutation.isPending}
/>
)
}