From e6d35fc808d3c8c83ea177bee8d8ebbf608915cd Mon Sep 17 00:00:00 2001 From: rustmailer Date: Sun, 5 Oct 2025 15:50:21 +0800 Subject: [PATCH] feat(frontend): adapt message list logic for Gmail API compatibility --- web/src/api/mailbox/envelope/api.ts | 9 +- .../mailbox/components/delete-dialog.tsx | 14 +- .../components/mail-display-drawer.tsx | 70 ++++++--- .../features/mailbox/components/mail-list.tsx | 36 +++-- web/src/features/mailbox/components/mail.tsx | 140 +++++++++++------- .../features/mailbox/components/move-to.tsx | 10 +- web/src/features/mailbox/context/index.tsx | 4 +- web/src/hooks/use-copy-messages.ts | 30 ++++ web/src/hooks/use-move-messages.ts | 30 ++++ 9 files changed, 238 insertions(+), 105 deletions(-) create mode 100644 web/src/hooks/use-copy-messages.ts create mode 100644 web/src/hooks/use-move-messages.ts diff --git a/web/src/api/mailbox/envelope/api.ts b/web/src/api/mailbox/envelope/api.ts index 4837c32..464f76d 100644 --- a/web/src/api/mailbox/envelope/api.ts +++ b/web/src/api/mailbox/envelope/api.ts @@ -122,8 +122,13 @@ export const move_messages = async (accountId: number, payload: Record { - const response = await axiosInstance.get(`/api/v1/full-message/${accountId}?mailbox=${mailbox}&uid=${uid}`, { responseType: 'blob' }); +export const copy_messages = async (accountId: number, payload: Record) => { + const response = await axiosInstance.post(`/api/v1/copy-messages/${accountId}`, payload); + return response.data; +}; + +export const get_full_message = async (accountId: number, mailbox: string, id: string, subject: string) => { + const response = await axiosInstance.get(`/api/v1/raw-message/${accountId}?mailbox=${mailbox}&id=${id}`, { responseType: 'blob' }); const blob = new Blob([response.data]); let filename = subject.replace(/\.eml$/i, ''); saveAs(blob, `${filename}.eml`); diff --git a/web/src/features/mailbox/components/delete-dialog.tsx b/web/src/features/mailbox/components/delete-dialog.tsx index 29f3bec..bcf3575 100644 --- a/web/src/features/mailbox/components/delete-dialog.tsx +++ b/web/src/features/mailbox/components/delete-dialog.tsx @@ -14,14 +14,14 @@ import { delete_messages } from '@/api/mailbox/envelope/api' interface Props { open: boolean onOpenChange: (open: boolean) => void - deleteUids: number[], - setDeleteUids: React.Dispatch>; + deleteIds: string[], + setDeleteIds: React.Dispatch>; accountId?: number, mailbox?: string, - selectedUids: number[], + selectedIds: string[], } -export function EnvelopeDeleteDialog({ open, onOpenChange, deleteUids, setDeleteUids, accountId, mailbox, selectedUids }: Props) { +export function EnvelopeDeleteDialog({ open, onOpenChange, deleteIds, setDeleteIds, accountId, mailbox, selectedIds }: Props) { const queryClient = useQueryClient(); const deleteMutation = useMutation({ @@ -30,7 +30,7 @@ export function EnvelopeDeleteDialog({ open, onOpenChange, deleteUids, setDelete onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['mailbox-list-messages', accountId, mailbox] }); onOpenChange(false); - setDeleteUids([]) + setDeleteIds([]) toast({ title: 'Messages deleted successfully', description: 'The messages have been deleted.', @@ -48,7 +48,7 @@ export function EnvelopeDeleteDialog({ open, onOpenChange, deleteUids, setDelete const handleDelete = () => { if (accountId && mailbox) { let payload = { - uids: deleteUids.length > 0 ? deleteUids : selectedUids, + ids: deleteIds.length > 0 ? deleteIds : selectedIds, mailbox }; deleteMutation.mutate({ accountId, payload }) @@ -79,7 +79,7 @@ export function EnvelopeDeleteDialog({ open, onOpenChange, deleteUids, setDelete Are you sure you want to move{' '} {(() => { - const emailCount = deleteUids.length > 0 ? deleteUids.length : selectedUids.length; + const emailCount = deleteIds.length > 0 ? deleteIds.length : selectedIds.length; return emailCount > 1 ? `this ${emailCount} emails` : 'this email'; })()} {' '} diff --git a/web/src/features/mailbox/components/mail-display-drawer.tsx b/web/src/features/mailbox/components/mail-display-drawer.tsx index 049aa4f..9445c94 100644 --- a/web/src/features/mailbox/components/mail-display-drawer.tsx +++ b/web/src/features/mailbox/components/mail-display-drawer.tsx @@ -29,6 +29,8 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { MailboxData } from '@/api/mailbox/api' import { AxiosError } from 'axios' import { EmailAction, EmailActionDialog } from './email-action-dialog' +import { useMoveMessageMutation } from '@/hooks/use-move-messages' +import { useCopyMessageMutation } from '@/hooks/use-copy-messages' interface MultilinesProps { title: string, @@ -69,11 +71,11 @@ interface Props { currentEnvelope?: EmailEnvelope | undefined currentMailbox?: MailboxData | undefined currentAccountId?: number | undefined - setDeleteUids: React.Dispatch>; + setDeleteIds: React.Dispatch>; setOpen: (str: MailboxDialogType | null) => void } -export function MailDisplayDrawer({ open, setOpen, onOpenChange, currentEnvelope, setDeleteUids, currentMailbox, currentAccountId }: Props) { +export function MailDisplayDrawer({ open, setOpen, onOpenChange, currentEnvelope, setDeleteIds, currentMailbox, currentAccountId }: Props) { const [downloadingAttachmentId, setDownloadingAttachmentId] = useState(null); const [content, setContent] = useState(null); @@ -86,6 +88,11 @@ export function MailDisplayDrawer({ open, setOpen, onOpenChange, currentEnvelope const [dialogAction, setDialogAction] = useState('reply'); const { mutate: flagMessage } = useFlagMessageMutation(); + const { mutate: moveMessage } = useMoveMessageMutation(); + const { mutate: copyMessage } = useCopyMessageMutation(); + + const isGmailApi = currentEnvelope?.mid && currentEnvelope?.uid === 0; + const downloadMutation = useMutation({ mutationFn: ({ accountId, fileName, payload }: { accountId: number, fileName: string | undefined, payload: Record }) => download_attachment(accountId, fileName, payload), retry: false, @@ -135,8 +142,9 @@ export function MailDisplayDrawer({ open, setOpen, onOpenChange, currentEnvelope const onDownload = (attachment: Attachment) => { if (currentEnvelope) { + let payload = { - uid: currentEnvelope.uid, + id: isGmailApi ? currentEnvelope.mid! : currentEnvelope.uid.toString(), mailbox: currentMailbox?.name, attachment }; @@ -210,33 +218,51 @@ export function MailDisplayDrawer({ open, setOpen, onOpenChange, currentEnvelope const onMarkAsRead = () => { if (currentEnvelope) { - let payload = { - uids: [currentEnvelope.uid], - mailbox: currentMailbox?.name, - action: { - add: [{ flag: "Seen" }] - } - }; - flagMessage({ accountId: currentAccountId!, payload }) + if (isGmailApi) { + let payload = { + ids: [currentEnvelope.mid!], + current_mailbox: "UNREAD", + target_mailbox: currentMailbox?.name + }; + moveMessage({ accountId: currentAccountId!, payload }) + } else { + let payload = { + uids: [currentEnvelope.uid], + mailbox: currentMailbox?.name, + action: { + add: [{ flag: "Seen" }] + } + }; + flagMessage({ accountId: currentAccountId!, payload }) + } } } const onMarkAsUnread = () => { if (currentEnvelope) { - let payload = { - uids: [currentEnvelope.uid], - mailbox: currentMailbox?.name, - action: { - remove: [{ flag: "Seen" }] - } - }; - flagMessage({ accountId: currentAccountId!, payload }) + if (isGmailApi) { + let payload = { + ids: [currentEnvelope.mid!], + current_mailbox: currentMailbox?.name, + target_mailbox: "UNREAD" + }; + copyMessage({ accountId: currentAccountId!, payload }) + } else { + let payload = { + uids: [currentEnvelope.uid], + mailbox: currentMailbox?.name, + action: { + remove: [{ flag: "Seen" }] + } + }; + flagMessage({ accountId: currentAccountId!, payload }) + } } } const handleDelete = () => { if (currentEnvelope) { - setDeleteUids([currentEnvelope.uid]) + isGmailApi ? setDeleteIds([currentEnvelope.mid!]) : setDeleteIds([currentEnvelope.uid.toString()]) setOpen('move-to-trash') } } @@ -294,7 +320,7 @@ export function MailDisplayDrawer({ open, setOpen, onOpenChange, currentEnvelope async function downloadEmlFile() { const filename = currentEnvelope?.subject || 'email_message'; - if (!currentAccountId || !currentEnvelope?.mailbox_name || !currentEnvelope?.uid) { + if (!currentAccountId || !currentEnvelope?.mailbox_name || !currentEnvelope) { toast({ title: 'Download failed', description: 'Cannot download - missing required message information', @@ -310,7 +336,7 @@ export function MailDisplayDrawer({ open, setOpen, onOpenChange, currentEnvelope }); // Fetch the message - await get_full_message(currentAccountId!, currentEnvelope?.mailbox_name, currentEnvelope?.uid, filename); + await get_full_message(currentAccountId!, currentEnvelope?.mailbox_name, isGmailApi ? currentEnvelope?.mid! : currentEnvelope?.uid.toString(), filename); // Optional: Show download complete notification toast({ title: 'Download complete', diff --git a/web/src/features/mailbox/components/mail-list.tsx b/web/src/features/mailbox/components/mail-list.tsx index d480f07..8245125 100644 --- a/web/src/features/mailbox/components/mail-list.tsx +++ b/web/src/features/mailbox/components/mail-list.tsx @@ -19,31 +19,37 @@ interface MailListProps { currentEnvelope: EmailEnvelope | undefined onEnvelopeChanged: (envelope: EmailEnvelope) => void setOpen: (str: MailboxDialogType | null) => void, - setSelectedUids: React.Dispatch>; - setDeleteUids: React.Dispatch>; - selectedUids: number[]; + setSelectedIds: React.Dispatch>; + setDeleteIds: React.Dispatch>; + selectedIds: string[]; } export function MailList({ setOpen, items, currentEnvelope, - setDeleteUids, + setDeleteIds, isLoading, onEnvelopeChanged, - setSelectedUids, - selectedUids + setSelectedIds, + selectedIds }: MailListProps) { const handleDelete = (envelope: EmailEnvelope) => { - setDeleteUids([envelope.uid]); + if (envelope.mid && envelope.uid === 0) { + setDeleteIds([envelope.mid!]) + } else { + setDeleteIds([envelope.uid.toString()]) + } setOpen("move-to-trash"); } - const handleCheckboxChange = (value: boolean | 'indeterminate', uid: number) => { + const isGmailApi = items.some(item => item.mid && item.uid === 0); + + const handleCheckboxChange = (value: boolean | 'indeterminate', id: string) => { if (value === true) { - setSelectedUids((prev) => [...prev, uid]); + setSelectedIds((prev) => [...prev, id]); } else if (value === false) { - setSelectedUids((prev) => prev.filter((id) => id !== uid)); + setSelectedIds((prev) => prev.filter((x) => x !== id)); } }; @@ -82,14 +88,18 @@ export function MailList({ "flex flex-col gap-1.5 p-2 rounded-lg border transition-all cursor-pointer", "hover:bg-accent/50", currentEnvelope?.uid === item.uid && "bg-accent", - selectedUids.includes(item.uid) && "bg-primary/5" + (isGmailApi ? selectedIds.includes(item.mid!) : selectedIds.includes(item.uid.toString())) && "bg-primary/5" )} onClick={() => onEnvelopeChanged(item)} >
handleCheckboxChange(checked, item.uid)} + checked={ + isGmailApi ? selectedIds.includes(item.mid!) : selectedIds.includes(item.uid.toString()) + } + onCheckedChange={(checked) => { + isGmailApi ? handleCheckboxChange(checked, item.mid!) : handleCheckboxChange(checked, item.uid.toString()) + }} onClick={(e) => e.stopPropagation()} className="h-4 w-3 shrink-0" /> diff --git a/web/src/features/mailbox/components/mail.tsx b/web/src/features/mailbox/components/mail.tsx index 93048a5..f940076 100644 --- a/web/src/features/mailbox/components/mail.tsx +++ b/web/src/features/mailbox/components/mail.tsx @@ -41,6 +41,9 @@ import { useFlagMessageMutation } from "@/hooks/use-flag-messages" import { EnvelopeFilterDialog } from "./envelope-filter-dialog" import Logo from '@/assets/logo.svg' import { PaginatedResponse } from "@/api" +import { useMoveMessageMutation } from "@/hooks/use-move-messages" +import { useCopyMessageMutation } from "@/hooks/use-copy-messages" +import { number } from "zod" interface MailProps { defaultLayout: number[] | undefined @@ -176,12 +179,15 @@ export function Mail({ const [page, setPage] = React.useState(0); const [pageSize, setPageSize] = React.useState(10); - const [selectedUids, setSelectedUids] = React.useState([]); - const [deleteUids, setDeleteUids] = React.useState([]); + const [selectedIds, setSelectedIds] = React.useState([]); + const [deleteIds, setDeleteIds] = React.useState([]); const [currentFilter, setCurrentFilter] = React.useState(undefined); const [isSearching, setIsSearching] = React.useState(false); // const [customFlags, setCustomFlags] = React.useState([]); const { mutate: flagMessage } = useFlagMessageMutation(); + const { mutate: moveMessage } = useMoveMessageMutation(); + const { mutate: copyMessage } = useCopyMessageMutation(); + const queryClient = useQueryClient(); const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({ @@ -191,6 +197,8 @@ export function Mail({ }) + const isGmailApi = envelopes?.items.some(item => item.mid && item.uid === 0); + React.useEffect(() => { if (!selectedAccountId || !selectedMailbox) { return; @@ -245,7 +253,7 @@ export function Mail({ try { setPage(0); setPageSize(10); - setSelectedUids([]); + setSelectedIds([]); setCurrentFilter(data); const payload = { @@ -271,48 +279,66 @@ export function Mail({ setCurrentFilter(undefined); setPage(0); setPageSize(10); - setSelectedUids([]); + setSelectedIds([]); } const hasNextPage = () => { return !!pageTokenMapRef.current[page + 1]; } - + const handlePageChange = (newPage: number) => { setPage(newPage); - setSelectedUids([]); + setSelectedIds([]); } const handlePageSizeChange = (newSize: number) => { setPage(0); setPageSize(newSize); - setSelectedUids([]); + setSelectedIds([]); } const handleMarkFolderRead = () => { if (selectedAccountId && selectedMailbox) { - let payload = { - uids: selectedUids, - mailbox: selectedMailbox?.name, - action: { - add: [{ flag: "Seen" }] - } - }; - flagMessage({ accountId: selectedAccountId, payload }) + if (isGmailApi) { + let payload = { + ids: selectedIds, + current_mailbox: "UNREAD", + target_mailbox: selectedMailbox?.name + }; + moveMessage({ accountId: selectedAccountId, payload }) + } else { + let payload = { + uids: selectedIds.map(id => Number(id)), + mailbox: selectedMailbox?.name, + action: { + add: [{ flag: "Seen" }] + } + }; + flagMessage({ accountId: selectedAccountId, payload }) + } } } const handleMarkFolderUnread = () => { if (selectedAccountId && selectedMailbox) { - let payload = { - uids: selectedUids, - mailbox: selectedMailbox?.name, - action: { - remove: [{ flag: "Seen" }] - } - }; - flagMessage({ accountId: selectedAccountId, payload }) + if (isGmailApi) { + let payload = { + ids: selectedIds, + current_mailbox: selectedMailbox?.name, + target_mailbox: "UNREAD" + }; + copyMessage({ accountId: selectedAccountId, payload }) + } else { + let payload = { + uids: selectedIds.map(id => Number(id)), + mailbox: selectedMailbox?.name, + action: { + remove: [{ flag: "Seen" }] + } + }; + flagMessage({ accountId: selectedAccountId, payload }) + } } } @@ -327,7 +353,7 @@ export function Mail({ }, [isError, error]); return ( - +
@@ -407,7 +433,7 @@ export function Mail({ onSelectChange={(item) => { if (item) { setSelectedMailbox(mailboxes?.find(m => m.id === parseInt(item.id, 10))) - setSelectedUids([]); + setSelectedIds([]); setPage(0); } else { setSelectedMailbox(undefined) @@ -425,20 +451,20 @@ export function Mail({

setOpen("mailbox")}> {selectedMailbox?.name}

- {selectedUids.length > 0 && } - {selectedUids.length > 0 &&
- {selectedUids.length} {selectedUids.length > 1 ? 'emails' : 'email'} selected + {selectedIds.length > 0 && } + {selectedIds.length > 0 &&
+ {selectedIds.length} {selectedIds.length > 1 ? 'emails' : 'email'} selected
}
- {selectedUids.length > 0 ? ( + {selectedIds.length > 0 ? (
{ e.stopPropagation(); - setSelectedUids([]); + setSelectedIds([]); }} >
@@ -450,7 +476,11 @@ export function Mail({ className="peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground" onClick={(e) => { e.stopPropagation(); - setSelectedUids(envelopes?.items.map((e) => e.uid) ?? []); + if (isGmailApi) { + setSelectedIds(envelopes?.items.map((e) => e.mid!) ?? []); + } else { + setSelectedIds(envelopes?.items.map((e) => e.uid.toString()) ?? []); + } }} /> )} @@ -465,7 +495,7 @@ export function Mail({ Clear Filters )} - {selectedUids.length > 0 && ( + {selectedIds.length > 0 && ( <> - - - - - - - - - + {!isGmailApi && <> + + + + + + + Number(id))} /> + + + }