From f02dbf3a40334ced5795c70eef36a362069b762c Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Thu, 1 Oct 2026 12:53:14 +0200 Subject: [PATCH] feat: copy messages to a folder of another connected account With several accounts connected, mail could only be moved between them (drag to another account's folder). There was no way to file a copy in another account and keep the original where it is. crossAccountMoveEmails and copyEmailAcrossAccounts take an optional `{ keepOriginal: true }`. With it the source message is not destroyed, and the store leaves the list, the open message and the selection as they are; only the destination's folders are refreshed. Without it nothing changes. The message menu gets "Copy to account..." when more than one account is connected. It lists the writable folders of the other accounts (their folder lists are fetched when the menu opens) and copies the selection, or the message the menu was opened on, through the new copyEmailsToAccount action. That groups the messages by the login and JMAP account they live in and refuses a copy into the account a message is already in. --- .../email-context-menu-copy.test.tsx | 73 +++++++++ components/email/email-context-menu.tsx | 130 ++++++++++------ components/email/email-list.tsx | 43 +++++- lib/__tests__/jmap-cross-account-move.test.ts | 7 + lib/jmap/client-interface.ts | 5 +- lib/jmap/client.ts | 2 + locales/ar/common.json | 3 + locales/ca/common.json | 3 + locales/cs/common.json | 3 + locales/da/common.json | 3 + locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/fa/common.json | 3 + locales/fr/common.json | 3 + locales/he/common.json | 3 + locales/hu/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/ko/common.json | 3 + locales/lv/common.json | 3 + locales/mn/common.json | 3 + locales/nb/common.json | 3 + locales/nl/common.json | 3 + locales/pl/common.json | 3 + locales/pt/common.json | 3 + locales/ro/common.json | 3 + locales/ru/common.json | 3 + locales/sk/common.json | 3 + locales/tr/common.json | 3 + locales/uk/common.json | 3 + locales/zh-TW/common.json | 3 + locales/zh/common.json | 3 + .../email-store-cross-account-copy.test.ts | 144 ++++++++++++++++++ stores/email-store.ts | 76 ++++++++- 35 files changed, 508 insertions(+), 53 deletions(-) create mode 100644 components/email/__tests__/email-context-menu-copy.test.tsx create mode 100644 stores/__tests__/email-store-cross-account-copy.test.ts diff --git a/components/email/__tests__/email-context-menu-copy.test.tsx b/components/email/__tests__/email-context-menu-copy.test.tsx new file mode 100644 index 000000000..fcc02d220 --- /dev/null +++ b/components/email/__tests__/email-context-menu-copy.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('../rules-menu', () => ({ RulesContextSubMenu: () => null })); +vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() })); + +const { EmailContextMenu } = await import('../email-context-menu'); + +const rights = { mayAddItems: true } as Mailbox['myRights']; +function mailbox(id: string, extra: Partial = {}): Mailbox { + return { id, name: id, role: undefined, parentId: undefined, myRights: rights, ...extra } as unknown as Mailbox; +} + +const email = { id: 'e1', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email; +const ownMailboxes = [mailbox('inbox', { role: 'inbox', name: 'Inbox' }), mailbox('archive', { role: 'archive', name: 'Archive' })]; +const otherMailboxes = [ + mailbox('b-inbox', { role: 'inbox', name: 'Inbox' }), + mailbox('b-drafts', { role: 'drafts', name: 'Drafts' }), + mailbox('b-work', { name: 'Work' }), + mailbox('b-readonly', { name: 'Read only', myRights: { mayAddItems: false } as Mailbox['myRights'] }), +]; + +function renderMenu(props: Partial> = {}) { + return render( + {}} + menuRef={{ current: null }} + mailboxes={ownMailboxes} + selectedMailbox="inbox" + {...props} + />, + ); +} + +describe('Copy to entry of the message menu', () => { + it('is hidden without another connected account', () => { + renderMenu({ onCopyToAccount: vi.fn(), copyTargets: [] }); + expect(screen.queryByTestId('ctx-copy-to')).toBeNull(); + }); + + it('lists the writable folders of the other accounts and copies into the picked one', () => { + const onCopyToAccount = vi.fn(); + renderMenu({ + onCopyToAccount, + copyTargets: [{ accountId: 'local-B', label: 'bob@example.org', mailboxes: otherMailboxes }], + }); + fireEvent.mouseEnter(screen.getByTestId('ctx-copy-to').parentElement!); + + expect(screen.getByText('bob@example.org')).toBeTruthy(); + expect(screen.getByTestId('copy-to:local-B:b-inbox')).toBeTruthy(); + expect(screen.getByTestId('copy-to:local-B:b-work')).toBeTruthy(); + expect(screen.queryByTestId('copy-to:local-B:b-drafts')).toBeNull(); + expect(screen.queryByTestId('copy-to:local-B:b-readonly')).toBeNull(); + + fireEvent.click(screen.getByTestId('copy-to:local-B:b-work')); + expect(onCopyToAccount).toHaveBeenCalledWith('local-B', 'b-work'); + }); + + it('leaves Move to on the current account', () => { + renderMenu({ + onCopyToAccount: vi.fn(), + copyTargets: [{ accountId: 'local-B', label: 'bob@example.org', mailboxes: otherMailboxes }], + }); + fireEvent.mouseEnter(screen.getByTestId('ctx-move-to').parentElement!); + expect(screen.getByTestId('move-to:archive')).toBeTruthy(); + expect(screen.queryByTestId('move-to:b-work')).toBeNull(); + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index c04e298e7..2d8feaec7 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -35,6 +35,7 @@ import { Paperclip, Link as LinkIcon, MessagesSquare, + Copy, } from "@/components/icons"; import { buildMailPath } from "@/lib/deep-links"; import { useCopyLink } from "@/hooks/use-copy-link"; @@ -49,6 +50,15 @@ interface Position { y: number; } +/** Another connected account the "Copy to" entry can copy into. */ +export interface CopyTargetAccount { + /** The login (AccountEntry.id). */ + accountId: string; + label: string; + /** That account's own folders, as its client returns them. */ + mailboxes: Mailbox[]; +} + interface EmailContextMenuProps { email: Email; position: Position; @@ -90,6 +100,12 @@ interface EmailContextMenuProps { onBatchMoveToMailbox?: (mailboxId: string) => void; onBatchMarkAsSpam?: () => void; onBatchUndoSpam?: () => void; + /** + * Other connected accounts to offer under "Copy to". Copies the selection + * (or the single message) and keeps the originals. Hidden when empty. + */ + copyTargets?: CopyTargetAccount[]; + onCopyToAccount?: (accountId: string, mailboxId: string) => void; } // Get mailbox icon based on role @@ -144,6 +160,8 @@ export function EmailContextMenu({ onEditDraft, onCancelScheduledForEdit, onRescheduleScheduled, + copyTargets, + onCopyToAccount, }: EmailContextMenuProps) { const t = useTranslations("context_menu"); const tSidebar = useTranslations("sidebar"); @@ -177,17 +195,62 @@ export function EmailContextMenu({ ); const mailboxTree = buildMailboxTree(mailboxes); - // Filter tree to only include branches that contain valid move targets - const filterTree = (nodes: MailboxNode[]): MailboxNode[] => { + // Filter tree to only include branches that contain valid targets + const filterTree = (nodes: MailboxNode[], targetIds: Set): MailboxNode[] => { return nodes.reduce((acc, node) => { - const filteredChildren = filterTree(node.children); - if (moveTargetIds.has(node.id) || filteredChildren.length > 0) { + const filteredChildren = filterTree(node.children, targetIds); + if (targetIds.has(node.id) || filteredChildren.length > 0) { acc.push({ ...node, children: filteredChildren }); } return acc; }, []); }; - const moveTree = filterTree(mailboxTree); + const moveTree = filterTree(mailboxTree, moveTargetIds); + + // Folders of the other connected accounts a copy can land in. + const copyTrees = (copyTargets ?? []) + .map((target) => { + const targetIds = new Set( + target.mailboxes + .filter((m) => !m.isShared && m.role !== "drafts" && m.myRights?.mayAddItems) + .map((m) => m.id) + ); + return { ...target, targetIds, tree: filterTree(buildMailboxTree(target.mailboxes), targetIds) }; + }) + .filter((target) => target.tree.length > 0); + + const renderMailboxNodes = ( + nodes: MailboxNode[], + targetIds: Set, + testIdPrefix: string, + onPick: (mailboxId: string) => void, + ): React.ReactNode => + nodes.map((node) => { + const Icon = getMailboxIcon(node.role); + const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); + return ( +
+ {targetIds.has(node.id) ? ( + handleAction(() => onPick(node.id))} + /> + ) : ( +
+ + {nodeLabel} +
+ )} + {node.children.length > 0 && ( +
+ {renderMailboxNodes(node.children, targetIds, testIdPrefix, onPick)} +
+ )} +
+ ); + }); const handleAction = (action: () => void) => { action(); @@ -327,44 +390,25 @@ export function EmailContextMenu({ {/* Move to submenu */} {moveTree.length > 0 && ( - {(() => { - const renderNodes = (nodes: MailboxNode[]) => { - return nodes.map((node) => { - const Icon = getMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); - return ( -
- {isTarget ? ( - - handleAction(() => - showBatchActions - ? onBatchMoveToMailbox?.(node.id) - : onMoveToMailbox?.(node.id) - ) - } - /> - ) : ( -
- - {nodeLabel} -
- )} - {node.children.length > 0 && ( -
- {renderNodes(node.children)} -
- )} -
- ); - }); - }; - return renderNodes(moveTree); - })()} + {renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => + showBatchActions + ? onBatchMoveToMailbox?.(mailboxId) + : onMoveToMailbox?.(mailboxId) + )} +
+ )} + + {/* Copy to another connected account, keeping the originals */} + {onCopyToAccount && copyTrees.length > 0 && ( + + {copyTrees.map((target) => ( +
+ {target.label} + {renderMailboxNodes(target.tree, target.targetIds, `copy-to:${target.accountId}:`, (mailboxId) => + onCopyToAccount(target.accountId, mailboxId) + )} +
+ ))}
)} diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 0e878a9f6..08d2f2ef6 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -6,7 +6,7 @@ import type { Attachment } from "@/lib/jmap/types"; import type { LoadListAttachments } from "@/lib/list-attachments"; import { listRowShowsChips } from "./attachment-chips"; import { listVerificationCode } from "@/lib/verification-code"; -import { EmailContextMenu } from "./email-context-menu"; +import { EmailContextMenu, type CopyTargetAccount } from "./email-context-menu"; import { cn, cleanPreview } from "@/lib/utils"; import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "@/components/icons"; import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react"; @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useEmailStore, ArchiveMailboxNotFoundError } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; +import { useAccountStore } from "@/stores/account-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useMessageListTabsStore } from "@/stores/message-list-tabs-store"; @@ -194,6 +195,35 @@ export function EmailList({ }, [contextMenuEmail, selectedEmailIds, emails, threadGroups, threadEmailsCache]); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); + // "Copy to" lists the folders of the other connected accounts. Only with + // more than one account connected; the menu's own account is left out. + const accounts = useAccountStore((state) => state.accounts); + const accountMailboxes = useEmailStore((state) => state.accountMailboxes); + const fetchAccountMailboxes = useEmailStore((state) => state.fetchAccountMailboxes); + const copyEmailsToAccount = useEmailStore((state) => state.copyEmailsToAccount); + const copySourceLogin = contextMenuEmail + ? contextMenuEmail.sourceClientAccountId ?? viewingAccountId ?? activeAccountId + : null; + const copyAccounts = useMemo(() => { + if (!contextMenu.isOpen) return []; + const connected = useAuthStore.getState().getAllConnectedClients(); + if (connected.size < 2) return []; + return accounts.filter((account) => account.id !== copySourceLogin && connected.has(account.id)); + }, [contextMenu.isOpen, accounts, copySourceLogin]); + useEffect(() => { + for (const account of copyAccounts) { + if (accountMailboxes[account.id]) continue; + const accountClient = useAuthStore.getState().getClientForAccount(account.id); + if (accountClient) void fetchAccountMailboxes(accountClient, account.id); + } + }, [copyAccounts, accountMailboxes, fetchAccountMailboxes]); + const copyTargets = useMemo( + () => copyAccounts + .filter((account) => accountMailboxes[account.id]) + .map((account) => ({ accountId: account.id, label: account.label || account.email || account.id, mailboxes: accountMailboxes[account.id] })), + [copyAccounts, accountMailboxes], + ); + const [isProcessing, setIsProcessing] = useState(false); const parentRef = useRef(null); const batchToolbarRef = useRef(null); @@ -736,6 +766,17 @@ export function EmailList({ onEditDraft={() => onEditDraft?.(contextMenuEmail!)} onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined} onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined} + copyTargets={copyTargets} + onCopyToAccount={async (accountId, mailboxId) => { + const ids = selectedEmailIds.has(contextMenuEmail!.id) && selectedEmailIds.size > 1 + ? Array.from(selectedEmailIds) + : [contextMenuEmail!.id]; + const target = accountMailboxes[accountId]?.find((mb) => mb.id === mailboxId); + await runBatchEmailAction(() => copyEmailsToAccount(ids, accountId, target?.originalId ?? mailboxId), { + success: tNotifications('emails_copied', { count: ids.length }), + error: tNotifications('copy_failed'), + }); + }} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchDelete={async () => { if (!client) return; diff --git a/lib/__tests__/jmap-cross-account-move.test.ts b/lib/__tests__/jmap-cross-account-move.test.ts index 462abe5b8..921a8422c 100644 --- a/lib/__tests__/jmap-cross-account-move.test.ts +++ b/lib/__tests__/jmap-cross-account-move.test.ts @@ -29,6 +29,13 @@ describe('copyEmailAcrossAccounts', () => { expect(calls.find(([m]) => m === 'Email/set')![1]).toEqual({ accountId: 'me', destroy: ['m1'] }); }); + it('keeps the source when asked for a plain copy', async () => { + const { client, calls } = clientWithCopyResult({ created: { c: { id: 'new1' } } }); + await expect(client.copyEmailAcrossAccounts('m1', 'me', 'grp', 'inbox', { keepOriginal: true })).resolves.toBe('new1'); + expect(calls.some(([m]) => m === 'Email/copy')).toBe(true); + expect(calls.some(([m]) => m === 'Email/set')).toBe(false); + }); + it('keeps the source when the copy fails', async () => { const { client, calls } = clientWithCopyResult({ notCreated: { c: { type: 'overQuota' } } }); await expect(client.copyEmailAcrossAccounts('m1', 'me', 'grp', 'inbox')).rejects.toThrow('overQuota'); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 9316f2ee0..3fb391ed7 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -521,7 +521,8 @@ export interface IJMAPClient { * (JMAP `Email/copy` + destroy-original). Used for delegated/shared folders, * where the two accounts share a client but a client can't stage a blob in a * delegated account (so the blob copy+import path doesn't work). Returns the - * new email id in the destination account. + * new email id in the destination account. `keepOriginal` makes it a plain + * copy: the source message is left where it is. */ - copyEmailAcrossAccounts(emailId: string, fromAccountId: string, toAccountId: string, destMailboxId: string): Promise; + copyEmailAcrossAccounts(emailId: string, fromAccountId: string, toAccountId: string, destMailboxId: string, options?: { keepOriginal?: boolean }): Promise; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 1ce01fa06..88def9af5 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -9009,6 +9009,7 @@ export class JMAPClient implements IJMAPClient { fromAccountId: string, toAccountId: string, destMailboxId: string, + options?: { keepOriginal?: boolean }, ): Promise { // Email/copy drops keywords unless the create sets them, so carry the // source's over — otherwise the moved message shows up as unread. @@ -9040,6 +9041,7 @@ export class JMAPClient implements IJMAPClient { if (!id) { throw new Error("Email/copy succeeded but no ID returned"); } + if (options?.keepOriginal) return id; const destroyResponse = await this.request([ ["Email/set", { accountId: fromAccountId, destroy: [emailId] }, "0"], diff --git a/locales/ar/common.json b/locales/ar/common.json index 3f0e6749c..9e8a60c53 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -819,6 +819,8 @@ "emails_moved": "تم نقل {count} رسالة", "moved_to_mailbox": "تم النقل إلى {mailbox}", "move_failed": "فشل النقل", + "emails_copied": "تم نسخ {count} رسالة", + "copy_failed": "فشل النسخ", "move_error": "تعذّر نقل الرسائل إلى المجلد المحدد", "email_tagged": "تم وسم الرسالة", "emails_tagged": "تم وسم {count} رسالة", @@ -2355,6 +2357,7 @@ "pin": "تثبيت", "unpin": "إلغاء التثبيت", "move_to": "نقل إلى...", + "copy_to_account": "نسخ إلى حساب...", "archive": "أرشفة", "delete": "حذف", "mark_as_spam": "الإبلاغ عن بريد مزعج", diff --git a/locales/ca/common.json b/locales/ca/common.json index 670d0e7c3..3bc5869ee 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} correus moguts", "moved_to_mailbox": "Mogut a {mailbox}", "move_failed": "No s'ha pogut moure", + "emails_copied": "{count} correus copiats", + "copy_failed": "No s'ha pogut copiar", "move_error": "No s'han pogut moure els correus a la carpeta seleccionada", "email_tagged": "Correu etiquetat", "emails_tagged": "{count} correus etiquetats", @@ -2355,6 +2357,7 @@ "pin": "Fixa", "unpin": "Desfixa", "move_to": "Mou a...", + "copy_to_account": "Copia a un compte...", "archive": "Arxiva", "delete": "Suprimeix", "mark_as_spam": "Denuncia com a brossa", diff --git a/locales/cs/common.json b/locales/cs/common.json index 3b50ffa25..5e55be2b1 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} zpráv bylo přesunuto", "moved_to_mailbox": "Přesunuto do složky {mailbox}", "move_failed": "Přesun selhal", + "emails_copied": "{count} zpráv bylo zkopírováno", + "copy_failed": "Kopírování selhalo", "move_error": "Zprávu nelze přesunout do vybrané složky", "email_tagged": "Zpráva byla označena štítkem", "emails_tagged": "{count} zpráv bylo označeno štítkem", @@ -2355,6 +2357,7 @@ "pin": "Připnout", "unpin": "Odepnout", "move_to": "Přesunout do...", + "copy_to_account": "Kopírovat do účtu...", "archive": "Archivovat", "delete": "Odstranit", "mark_as_spam": "Nahlásit spam", diff --git a/locales/da/common.json b/locales/da/common.json index 15d368faa..73bf5b78c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} e-mails flyttet", "moved_to_mailbox": "Flyttet til {mailbox}", "move_failed": "Flytning mislykkedes", + "emails_copied": "{count} e-mails kopieret", + "copy_failed": "Kopiering mislykkedes", "move_error": "Kunne ikke flytte e-mails til den valgte mappe", "email_tagged": "E-mail tagget", "emails_tagged": "{count} e-mails tagget", @@ -2355,6 +2357,7 @@ "pin": "Fastgør", "unpin": "Frigør", "move_to": "Flyt til...", + "copy_to_account": "Kopiér til konto...", "archive": "Arkivér", "delete": "Slet", "mark_as_spam": "Rapportér spam", diff --git a/locales/de/common.json b/locales/de/common.json index 1d8eaeb42..019abce93 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} E-Mails verschoben", "moved_to_mailbox": "Nach {mailbox} verschoben", "move_failed": "Verschieben fehlgeschlagen", + "emails_copied": "{count} E-Mails kopiert", + "copy_failed": "Kopieren fehlgeschlagen", "move_error": "E-Mails konnten nicht in den ausgewählten Ordner verschoben werden", "email_tagged": "E-Mail markiert", "emails_tagged": "{count} E-Mails markiert", @@ -2355,6 +2357,7 @@ "pin": "Anheften", "unpin": "Lösen", "move_to": "Verschieben nach...", + "copy_to_account": "In Konto kopieren...", "archive": "Archivieren", "delete": "Löschen", "mark_as_spam": "Spam melden", diff --git a/locales/en/common.json b/locales/en/common.json index 5be6e2466..dba9835ff 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} emails moved", "moved_to_mailbox": "Moved to {mailbox}", "move_failed": "Move failed", + "emails_copied": "{count} emails copied", + "copy_failed": "Copy failed", "move_error": "Could not move emails to the selected folder", "email_tagged": "Email tagged", "emails_tagged": "{count} emails tagged", @@ -2355,6 +2357,7 @@ "pin": "Pin", "unpin": "Unpin", "move_to": "Move to...", + "copy_to_account": "Copy to account...", "archive": "Archive", "delete": "Delete", "mark_as_spam": "Report spam", diff --git a/locales/es/common.json b/locales/es/common.json index 58e8f8be6..0911412f4 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} correos movidos", "moved_to_mailbox": "Movido a {mailbox}", "move_failed": "Error al mover", + "emails_copied": "{count} correos copiados", + "copy_failed": "Error al copiar", "move_error": "No se pudieron mover los correos a la carpeta seleccionada", "email_tagged": "Correo etiquetado", "emails_tagged": "{count} correos etiquetados", @@ -2355,6 +2357,7 @@ "pin": "Anclar", "unpin": "Desanclar", "move_to": "Mover a...", + "copy_to_account": "Copiar a cuenta...", "archive": "Archivar", "delete": "Eliminar", "mark_as_spam": "Reportar spam", diff --git a/locales/fa/common.json b/locales/fa/common.json index fa0c9abf2..f5a6a8885 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} ایمیل منتقل شد", "moved_to_mailbox": "منتقل شد به {mailbox}", "move_failed": "انتقال ناموفق بود", + "emails_copied": "{count} ایمیل کپی شد", + "copy_failed": "کپی ناموفق بود", "move_error": "انتقال ایمیل‌ها به پوشه انتخاب شده ممکن نیست", "email_tagged": "ایمیل برچسب‌گذاری شد", "emails_tagged": "{count} ایمیل برچسب‌گذاری شد", @@ -2355,6 +2357,7 @@ "pin": "سنجاق کردن", "unpin": "برداشتن سنجاق", "move_to": "انتقال به...", + "copy_to_account": "کپی به حساب...", "archive": "بایگانی", "delete": "حذف", "mark_as_spam": "گزارش هرزنامه", diff --git a/locales/fr/common.json b/locales/fr/common.json index e9468e9ae..82a941681 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} emails déplacés", "moved_to_mailbox": "Déplacé vers {mailbox}", "move_failed": "Échec du déplacement", + "emails_copied": "{count} emails copiés", + "copy_failed": "Échec de la copie", "move_error": "Impossible de déplacer les emails vers le dossier sélectionné", "email_tagged": "E-mail étiqueté", "emails_tagged": "{count} e-mails étiquetés", @@ -2355,6 +2357,7 @@ "pin": "Épingler", "unpin": "Désépingler", "move_to": "Déplacer vers...", + "copy_to_account": "Copier vers un compte...", "archive": "Archiver", "delete": "Supprimer", "mark_as_spam": "Signaler comme spam", diff --git a/locales/he/common.json b/locales/he/common.json index deb56c0aa..71b93a5b2 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -784,6 +784,8 @@ "emails_moved": "דוא\"ל{count}הועברו", "moved_to_mailbox": "הועבר אל{mailbox}", "move_failed": "המהלך נכשל", + "emails_copied": "{count} הודעות הועתקו", + "copy_failed": "ההעתקה נכשלה", "move_error": "לא ניתן היה להעביר הודעות דוא\"ל לתיקיה שנבחרה", "email_tagged": "אימייל מתויג", "emails_tagged": "דוא\"ל{count}מתויג", @@ -2321,6 +2323,7 @@ "pin": "הצמד", "unpin": "בטל הצמדה", "move_to": "העבר ל...", + "copy_to_account": "העתק לחשבון...", "archive": "ארכיון", "delete": "לִמְחוֹק", "mark_as_spam": "דווח על ספאם", diff --git a/locales/hu/common.json b/locales/hu/common.json index fc568be71..82370350e 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} e-mail áthelyezve", "moved_to_mailbox": "Áthelyezve ide: {mailbox}", "move_failed": "Áthelyezés sikertelen", + "emails_copied": "{count} e-mail másolva", + "copy_failed": "Másolás sikertelen", "move_error": "Nem sikerült áthelyezni az e-maileket a kiválasztott mappába", "email_tagged": "E-mail címkézve", "emails_tagged": "{count} e-mail címkézve", @@ -2355,6 +2357,7 @@ "pin": "Rögzítés", "unpin": "Rögzítés feloldása", "move_to": "Áthelyezés ide...", + "copy_to_account": "Másolás fiókba...", "archive": "Archiválás", "delete": "Törlés", "mark_as_spam": "Spam jelentése", diff --git a/locales/it/common.json b/locales/it/common.json index 8af92ffc4..0f52757b8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} messaggi spostati", "moved_to_mailbox": "Spostato in {mailbox}", "move_failed": "Spostamento non riuscito", + "emails_copied": "{count} messaggi copiati", + "copy_failed": "Copia non riuscita", "move_error": "Impossibile spostare i messaggi nella cartella selezionata", "email_tagged": "Email etichettata", "emails_tagged": "{count} email etichettate", @@ -2355,6 +2357,7 @@ "pin": "Fissa", "unpin": "Non fissare più", "move_to": "Sposta in...", + "copy_to_account": "Copia in account...", "archive": "Archivia", "delete": "Elimina", "mark_as_spam": "Segnala come spam", diff --git a/locales/ja/common.json b/locales/ja/common.json index 058a448cf..cce11686e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count}件のメールを移動しました", "moved_to_mailbox": "{mailbox}に移動しました", "move_failed": "移動に失敗しました", + "emails_copied": "{count}件のメールをコピーしました", + "copy_failed": "コピーに失敗しました", "move_error": "選択したフォルダにメールを移動できませんでした", "email_tagged": "メールにタグを付けました", "emails_tagged": "{count}件のメールにタグを付けました", @@ -2355,6 +2357,7 @@ "pin": "ピン留め", "unpin": "ピン留めを外す", "move_to": "移動...", + "copy_to_account": "アカウントにコピー...", "archive": "アーカイブ", "delete": "削除", "mark_as_spam": "迷惑メールを報告", diff --git a/locales/ko/common.json b/locales/ko/common.json index 68ac1065d..882324b1c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count}개의 메일이 이동되었어요", "moved_to_mailbox": "{mailbox}함으로 이동되었어요", "move_failed": "이동에 실패했어요", + "emails_copied": "{count}개의 메일이 복사되었어요", + "copy_failed": "복사에 실패했어요", "move_error": "선택한 폴더로 메일을 이동할 수 없어요", "email_tagged": "태그가 추가되었어요", "emails_tagged": "{count}개의 메일에 태그가 추가되었어요", @@ -2355,6 +2357,7 @@ "pin": "고정", "unpin": "고정 해제", "move_to": "이동...", + "copy_to_account": "계정으로 복사...", "archive": "보관", "delete": "삭제", "mark_as_spam": "스팸 신고", diff --git a/locales/lv/common.json b/locales/lv/common.json index 7c3c8f9ce..cf8aaacd2 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} vēstules pārvietotas", "moved_to_mailbox": "Pārvietots uz {mailbox}", "move_failed": "Pārvietošanas kļūda", + "emails_copied": "{count} vēstules nokopētas", + "copy_failed": "Kopēšanas kļūda", "move_error": "Neizdevās pārvietot vēstules uz izvēlēto mapi", "email_tagged": "Vēstulei pievienots tags", "emails_tagged": "{count} vēstulēm pievienots tags", @@ -2355,6 +2357,7 @@ "pin": "Piespraust", "unpin": "Atspraust", "move_to": "Pārvietot uz...", + "copy_to_account": "Kopēt uz kontu...", "archive": "Arhivēt", "delete": "Dzēst", "mark_as_spam": "Atzīmēt kā mēstuli", diff --git a/locales/mn/common.json b/locales/mn/common.json index 71ee7f043..791ea4ec4 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} имэйлийг зөөсөн", "moved_to_mailbox": "{mailbox}", "move_failed": "Зөөж чадсангүй", + "emails_copied": "{count} имэйлийг хуулсан", + "copy_failed": "Хуулж чадсангүй", "move_error": "Имэйлүүдийг сонгосон хавтас руу зөөж чадсангүй", "email_tagged": "И-мэйл хаяглагдсан", "emails_tagged": "{count} мэйл хаяглагдсан", @@ -2355,6 +2357,7 @@ "pin": "Бэхлэх", "unpin": "Буулгах", "move_to": "...", + "copy_to_account": "Бүртгэл рүү хуулах...", "archive": "Архив", "delete": "Устгах", "mark_as_spam": "Спам мэдээлэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index 7593f4441..fda080f06 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} e-poster er flyttet", "moved_to_mailbox": "Flyttet til {mailbox}", "move_failed": "Kunne ikke flytte e-posten", + "emails_copied": "{count} e-poster er kopiert", + "copy_failed": "Kunne ikke kopiere", "move_error": "Kunne ikke flytte e-poster til den valgte mappen", "email_tagged": "E-posten er merket med en etikett", "emails_tagged": "{count} e-poster er merket med en etikett", @@ -2355,6 +2357,7 @@ "pin": "Fest", "unpin": "Løsne", "move_to": "Flytt til...", + "copy_to_account": "Kopier til konto...", "archive": "Arkiver", "delete": "Slett", "mark_as_spam": "Rapporter som søppelpost", diff --git a/locales/nl/common.json b/locales/nl/common.json index 584620f58..ccd96ad0a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} e-mails verplaatst", "moved_to_mailbox": "Verplaatst naar {mailbox}", "move_failed": "Verplaatsen mislukt", + "emails_copied": "{count} e-mails gekopieerd", + "copy_failed": "Kopiëren mislukt", "move_error": "Kan e-mails niet verplaatsen naar de geselecteerde map", "email_tagged": "E-mail gelabeld", "emails_tagged": "{count} e-mails gelabeld", @@ -2355,6 +2357,7 @@ "pin": "Vastmaken", "unpin": "Losmaken", "move_to": "Verplaatsen naar...", + "copy_to_account": "Kopiëren naar account...", "archive": "Archiveren", "delete": "Verwijderen", "mark_as_spam": "Spam melden", diff --git a/locales/pl/common.json b/locales/pl/common.json index 34284534e..f1f2c92ce 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} wiadomości przeniesionych", "moved_to_mailbox": "Przeniesiono do {mailbox}", "move_failed": "Przenoszenie nie powiodło się", + "emails_copied": "{count} wiadomości skopiowanych", + "copy_failed": "Kopiowanie nie powiodło się", "move_error": "Nie można przenieść wiadomości do wybranego folderu", "email_tagged": "Wiadomość oznaczona etykietą", "emails_tagged": "{count} wiadomości oznaczonych etykietą", @@ -2355,6 +2357,7 @@ "pin": "Przypnij", "unpin": "Odepnij", "move_to": "Przenieś do...", + "copy_to_account": "Kopiuj do konta...", "archive": "Archiwizuj", "delete": "Usuń", "mark_as_spam": "Zgłoś spam", diff --git a/locales/pt/common.json b/locales/pt/common.json index 13dc88684..83b81e3d5 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} e-mails movidos", "moved_to_mailbox": "Movido para {mailbox}", "move_failed": "Falha ao mover", + "emails_copied": "{count} e-mails copiados", + "copy_failed": "Falha ao copiar", "move_error": "Não foi possível mover os e-mails para a pasta selecionada", "email_tagged": "E-mail etiquetado", "emails_tagged": "{count} e-mails etiquetados", @@ -2355,6 +2357,7 @@ "pin": "Fixar", "unpin": "Desafixar", "move_to": "Mover para...", + "copy_to_account": "Copiar para conta...", "archive": "Arquivar", "delete": "Excluir", "mark_as_spam": "Reportar spam", diff --git a/locales/ro/common.json b/locales/ro/common.json index e41cc7af7..7afe5085c 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} e-mailuri mutate", "moved_to_mailbox": "Mutată la {mailbox}", "move_failed": "Mutarea a eșuat", + "emails_copied": "{count} e-mailuri copiate", + "copy_failed": "Copierea a eșuat", "move_error": "Nu s-au putut muta e-mailurile în folderul selectat", "email_tagged": "E-mail etichetat", "emails_tagged": "{count} e-mailuri etichetate", @@ -2355,6 +2357,7 @@ "pin": "Fixează", "unpin": "Anulează fixarea", "move_to": "Mergi la...", + "copy_to_account": "Copiază în cont...", "archive": "Arhivează", "delete": "Șterge", "mark_as_spam": "Raportează spamul", diff --git a/locales/ru/common.json b/locales/ru/common.json index df02b292d..5adc4240b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} писем перемещено", "moved_to_mailbox": "Перемещено в {mailbox}", "move_failed": "Ошибка перемещения", + "emails_copied": "{count} писем скопировано", + "copy_failed": "Ошибка копирования", "move_error": "Не удалось переместить письма в выбранную папку", "email_tagged": "Письмо помечено тегом", "emails_tagged": "{count} писем помечено тегом", @@ -2355,6 +2357,7 @@ "pin": "Закрепить", "unpin": "Открепить", "move_to": "Переместить в...", + "copy_to_account": "Копировать в аккаунт...", "archive": "В архив", "delete": "Удалить", "mark_as_spam": "Отметить как спам", diff --git a/locales/sk/common.json b/locales/sk/common.json index b456da2ad..bb80cbe40 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} e-mailov bolo presunutých", "moved_to_mailbox": "Presunuté do {mailbox}", "move_failed": "Presun zlyhal", + "emails_copied": "{count} e-mailov bolo skopírovaných", + "copy_failed": "Kopírovanie zlyhalo", "move_error": "E-maily sa nedá presunúť do vybratej priečinky", "email_tagged": "E-mail bol označený štítkom", "emails_tagged": "{count} e-mailov bolo označených štítkom", @@ -2355,6 +2357,7 @@ "pin": "Pripnúť", "unpin": "Odopnúť", "move_to": "Presunúť do...", + "copy_to_account": "Kopírovať do účtu...", "archive": "Archivovať", "delete": "Odstrániť", "mark_as_spam": "Nahlásiť spam", diff --git a/locales/tr/common.json b/locales/tr/common.json index b7f633357..74d2578d8 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} e-posta taşındı", "moved_to_mailbox": "{mailbox} klasörüne taşındı", "move_failed": "Taşıma başarısız", + "emails_copied": "{count} e-posta kopyalandı", + "copy_failed": "Kopyalama başarısız", "move_error": "E-postalar seçilen klasöre taşınamadı", "email_tagged": "E-posta etiketlendi", "emails_tagged": "{count} e-posta etiketlendi", @@ -2355,6 +2357,7 @@ "pin": "Sabitle", "unpin": "Sabitlemeyi kaldır", "move_to": "Şuraya taşı...", + "copy_to_account": "Hesaba kopyala...", "archive": "Arşivle", "delete": "Sil", "mark_as_spam": "Spam bildir", diff --git a/locales/uk/common.json b/locales/uk/common.json index 44bde0e2c..331205829 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} електронних листів переміщено", "moved_to_mailbox": "Переміщено до {mailbox}", "move_failed": "Не вдалося перемістити", + "emails_copied": "{count} електронних листів скопійовано", + "copy_failed": "Не вдалося скопіювати", "move_error": "Не вдалося перемістити електронні листи до вибраної папки", "email_tagged": "Електронна пошта позначена тегами", "emails_tagged": "{count} електронних листів позначено тегами", @@ -2355,6 +2357,7 @@ "pin": "Закріпити", "unpin": "Відкріпити", "move_to": "Перейти до...", + "copy_to_account": "Копіювати в обліковий запис...", "archive": "Архів", "delete": "Видалити", "mark_as_spam": "Повідомити про спам", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index d89feafc2..74110aff8 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} 封郵件已移動", "moved_to_mailbox": "已移至 {mailbox}", "move_failed": "移動郵件失敗", + "emails_copied": "{count} 封郵件已複製", + "copy_failed": "複製失敗", "move_error": "無法將郵件移至選取的信件匣", "email_tagged": "郵件已加標籤", "emails_tagged": "{count} 封郵件已加標籤", @@ -2355,6 +2357,7 @@ "pin": "固定", "unpin": "取消固定", "move_to": "移至…", + "copy_to_account": "複製到帳戶…", "archive": "封存", "delete": "刪除", "mark_as_spam": "回報垃圾郵件", diff --git a/locales/zh/common.json b/locales/zh/common.json index 976e3a0f5..1fc6ba8d6 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -819,6 +819,8 @@ "emails_moved": "{count} 封邮件已移动", "moved_to_mailbox": "已移至 {mailbox}", "move_failed": "移动失败", + "emails_copied": "{count} 封邮件已复制", + "copy_failed": "复制失败", "move_error": "无法将邮件移至所选文件夹", "email_tagged": "邮件已加标签", "emails_tagged": "{count} 封邮件已加标签", @@ -2355,6 +2357,7 @@ "pin": "固定", "unpin": "取消固定", "move_to": "移动到…", + "copy_to_account": "复制到账户…", "archive": "归档", "delete": "删除", "mark_as_spam": "举报垃圾邮件", diff --git a/stores/__tests__/email-store-cross-account-copy.test.ts b/stores/__tests__/email-store-cross-account-copy.test.ts new file mode 100644 index 000000000..168921ba0 --- /dev/null +++ b/stores/__tests__/email-store-cross-account-copy.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import type { Email } from '@/lib/jmap/types'; + +type Store = ReturnType; + +function makeEmail(id: string, extra: Partial = {}): Email { + return { + id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: { $seen: true }, + size: 100, receivedAt: new Date().toISOString(), blobId: `blob-${id}`, + from: [{ name: 'X', email: 'x@example.com' }], to: [{ name: 'Y', email: 'y@example.com' }], + subject: id, preview: '', hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {}, + ...extra, + }; +} + +function makeClient(jmapAccountId: string) { + return { + getAccountId: () => jmapAccountId, + getEmail: vi.fn(async (id: string) => makeEmail(id)), + fetchBlob: vi.fn(async () => new Blob(['raw'])), + importRawEmail: vi.fn(async () => 'imported'), + deleteEmail: vi.fn(async () => {}), + copyEmailAcrossAccounts: vi.fn(async () => 'copied'), + }; +} + +describe('email-store crossAccountMoveEmails keepOriginal', () => { + let source: ReturnType; + let dest: ReturnType; + + beforeEach(() => { + source = makeClient('jmap-A'); + dest = makeClient('jmap-B'); + const clients: Record = { 'local-A': source, 'local-B': dest }; + useAuthStore.setState({ + activeAccountId: 'local-A', + getClientForAccount: (id: string) => clients[id] as never, + } as never); + const e1 = makeEmail('e1'); + useEmailStore.setState({ + emails: [e1, makeEmail('e2')], + selectedEmail: e1, + selectedEmailIds: new Set(['e1', 'e2']), + selectedMailbox: 'inbox', + viewingAccountId: null, + mailboxes: [], + accountMailboxes: {}, + error: null, + fetchMailboxes: vi.fn(async () => {}) as unknown as Store['fetchMailboxes'], + fetchAccountMailboxes: vi.fn(async () => {}) as unknown as Store['fetchAccountMailboxes'], + }); + }); + + it('a move imports, deletes the source and drops it from the view', async () => { + await useEmailStore.getState().crossAccountMoveEmails(new Map([['local-A', ['e1']]]), 'local-B', 'dest-inbox'); + + expect(dest.importRawEmail).toHaveBeenCalledWith(expect.any(Blob), { 'dest-inbox': true }, { $seen: true }, undefined); + expect(source.deleteEmail).toHaveBeenCalledWith('e1', undefined); + const state = useEmailStore.getState(); + expect(state.emails.map((e) => e.id)).toEqual(['e2']); + expect(state.selectedEmail).toBeNull(); + expect(state.selectedEmailIds.has('e1')).toBe(false); + }); + + it('a copy imports but keeps the source, the view and the selection', async () => { + await useEmailStore.getState().crossAccountMoveEmails( + new Map([['local-A', ['e1', 'e2']]]), 'local-B', 'dest-inbox', undefined, undefined, { keepOriginal: true }, + ); + + expect(dest.importRawEmail).toHaveBeenCalledTimes(2); + expect(source.deleteEmail).not.toHaveBeenCalled(); + const state = useEmailStore.getState(); + expect(state.emails.map((e) => e.id)).toEqual(['e1', 'e2']); + expect(state.selectedEmail?.id).toBe('e1'); + expect([...state.selectedEmailIds]).toEqual(['e1', 'e2']); + expect(state.isLoading).toBe(false); + // Only the destination's folders changed. + expect(useEmailStore.getState().fetchAccountMailboxes).toHaveBeenCalledWith(dest, 'local-B'); + expect(useEmailStore.getState().fetchMailboxes).not.toHaveBeenCalled(); + }); + + it('a copy through one client asks Email/copy to keep the original', async () => { + await useEmailStore.getState().crossAccountMoveEmails( + new Map([['local-A', ['e1']]]), 'local-A', 'team-inbox', 'jmap-team', undefined, { keepOriginal: true }, + ); + expect(source.copyEmailAcrossAccounts).toHaveBeenCalledWith('e1', 'jmap-A', 'jmap-team', 'team-inbox', { keepOriginal: true }); + }); + + it('a move through one client keeps calling Email/copy without options', async () => { + await useEmailStore.getState().crossAccountMoveEmails( + new Map([['local-A', ['e1']]]), 'local-A', 'team-inbox', 'jmap-team', + ); + expect(source.copyEmailAcrossAccounts).toHaveBeenCalledWith('e1', 'jmap-A', 'jmap-team', 'team-inbox', undefined); + }); + + it('reports a failed copy as a copy', async () => { + dest.importRawEmail.mockRejectedValueOnce(new Error('overQuota')); + await expect(useEmailStore.getState().crossAccountMoveEmails( + new Map([['local-A', ['e1']]]), 'local-B', 'dest-inbox', undefined, undefined, { keepOriginal: true }, + )).rejects.toThrow('Failed to copy email: overQuota'); + expect(useEmailStore.getState().emails).toHaveLength(2); + }); +}); + +describe('email-store copyEmailsToAccount', () => { + let crossSpy: ReturnType; + + beforeEach(() => { + const clients: Record = { 'local-A': makeClient('jmap-A'), 'local-B': makeClient('jmap-B') }; + useAuthStore.setState({ + activeAccountId: 'local-A', + getClientForAccount: (id: string) => clients[id] as never, + } as never); + crossSpy = vi.fn().mockResolvedValue(undefined); + useEmailStore.setState({ + emails: [ + makeEmail('e1'), + makeEmail('e2', { sourceClientAccountId: 'local-B', sourceAccountId: 'jmap-B' }), + ], + selectedEmail: null, + selectedMailbox: 'inbox', + viewingAccountId: null, + mailboxes: [], + accountMailboxes: {}, + error: null, + crossAccountMoveEmails: crossSpy as unknown as Store['crossAccountMoveEmails'], + }); + }); + + it('copies through crossAccountMoveEmails with keepOriginal', async () => { + await useEmailStore.getState().copyEmailsToAccount(['e1'], 'local-B', 'dest-inbox'); + expect(crossSpy).toHaveBeenCalledWith( + new Map([['local-A', ['e1']]]), 'local-B', 'dest-inbox', undefined, undefined, { keepOriginal: true }, + ); + }); + + it('refuses to copy messages into the account they are in', async () => { + await expect(useEmailStore.getState().copyEmailsToAccount(['e1', 'e2'], 'local-B', 'dest-inbox')) + .rejects.toThrow('already in that account'); + expect(crossSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 2c89261ab..6c554fbf1 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -273,6 +273,8 @@ interface EmailStore { * emails live in a delegated/shared mailbox accessed through the source * client, the copy/delete must target the owner's JMAP account rather than * the source client's primary one. + * `options.keepOriginal` turns the move into a copy: the originals stay in + * place and in the current view. */ crossAccountMoveEmails: ( emailIdsBySource: Map, @@ -280,7 +282,16 @@ interface EmailStore { destMailboxId: string, destJmapAccountId?: string, sourceJmapAccountId?: string, + options?: { keepOriginal?: boolean }, ) => Promise; + /** + * Copy emails into a folder of another connected account, keeping the + * originals. `destAccountId` is the destination login (AccountEntry.id) and + * `destMailboxId` the raw JMAP id of one of its own folders. Emails are + * grouped by the login and JMAP account they live in, then copied through + * crossAccountMoveEmails with `keepOriginal`. + */ + copyEmailsToAccount: (emailIds: string[], destAccountId: string, destMailboxId: string) => Promise; searchEmails: (client: IJMAPClient, query: string) => Promise; advancedSearch: (client: IJMAPClient) => Promise; setSearchFilters: (filters: Partial) => void; @@ -2877,8 +2888,9 @@ export const useEmailStore = create((set, get) => ({ } }, - crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId, sourceJmapAccountId) => { + crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId, sourceJmapAccountId, options) => { if (emailIdsBySource.size === 0) return; + const keepOriginal = options?.keepOriginal === true; set({ isLoading: true, error: null }); try { const destClient = useAuthStore.getState().getClientForAccount(destAccountId); @@ -2915,6 +2927,7 @@ export const useEmailStore = create((set, get) => ({ sourceJmapAccountId ?? sourceClient.getAccountId(), destJmapAccountId ?? destClient.getAccountId(), destMailboxId, + keepOriginal ? { keepOriginal } : undefined, ); return emailId; } @@ -2928,7 +2941,7 @@ export const useEmailStore = create((set, get) => ({ const blob = await sourceClient.fetchBlob(full.blobId, undefined, undefined, sourceJmapAccountId); const keywords: Record = { ...(full.keywords ?? {}) }; await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId); - await sourceClient.deleteEmail(emailId, sourceJmapAccountId); + if (!keepOriginal) await sourceClient.deleteEmail(emailId, sourceJmapAccountId); return emailId; }), ); @@ -2948,9 +2961,11 @@ export const useEmailStore = create((set, get) => ({ } // Drop the moved emails from the current view and clear stale selection - // entries. Counter accuracy comes from the mailbox refresh below. + // entries. Counter accuracy comes from the mailbox refresh below. A copy + // leaves the view and the selection as they are. const movedSet = new Set(movedIds); - set((state) => ({ + if (keepOriginal) set({ isLoading: false }); + else set((state) => ({ emails: state.emails.filter((e) => !movedSet.has(e.id)), selectedEmail: state.selectedEmail && movedSet.has(state.selectedEmail.id) @@ -2967,7 +2982,7 @@ export const useEmailStore = create((set, get) => ({ // Refresh mailbox folder lists/counters for every account we touched. // Background-only so the move feels instant - counters will catch up. const activeAccountId = useAuthStore.getState().activeAccountId; - const touched = new Set([destAccountId, ...emailIdsBySource.keys()]); + const touched = new Set(keepOriginal ? [destAccountId] : [destAccountId, ...emailIdsBySource.keys()]); for (const acctId of touched) { const c = useAuthStore.getState().getClientForAccount(acctId); if (!c) continue; @@ -2980,10 +2995,11 @@ export const useEmailStore = create((set, get) => ({ if (failures.length > 0) { const first = failures[0]; + const verb = keepOriginal ? 'copy' : 'move'; throw new Error( failures.length === 1 - ? `Failed to move email: ${first.error}` - : `Failed to move ${failures.length} email(s); first error: ${first.error}`, + ? `Failed to ${verb} email: ${first.error}` + : `Failed to ${verb} ${failures.length} email(s); first error: ${first.error}`, ); } } catch (error) { @@ -2998,6 +3014,52 @@ export const useEmailStore = create((set, get) => ({ } }, + copyEmailsToAccount: async (emailIds, destAccountId, destMailboxId) => { + const state = get(); + const auth = useAuthStore.getState(); + const viewLogin = state.viewingAccountId ?? auth.activeAccountId; + const currentMailbox = resolveActionMailboxes().find((mb) => mb.id === state.selectedMailbox); + // One group per (login, JMAP account): crossAccountMoveEmails takes a + // single source JMAP account per call. + const groups = new Map(); + for (const id of emailIds) { + const email = state.emails.find((e) => e.id === id) + ?? (state.selectedEmail?.id === id ? state.selectedEmail : undefined); + // Aggregate views stamp each email with the login that reaches it and + // its owning JMAP account; otherwise it lives in the open folder. + const stamped = !!(email?.sourceClientAccountId && email.sourceAccountId); + const login = stamped ? email!.sourceClientAccountId! : viewLogin; + const jmapAccountId = stamped + ? email!.sourceAccountId + : (currentMailbox?.isShared ? currentMailbox.accountId : undefined); + if (!login) continue; + const key = `${login}|${jmapAccountId ?? ''}`; + if (!groups.has(key)) groups.set(key, { login, jmapAccountId, ids: [] }); + groups.get(key)!.ids.push(id); + } + + // Copying a message into its own account is not a cross-account copy. + const destPrimary = auth.getClientForAccount(destAccountId)?.getAccountId(); + for (const { login, jmapAccountId } of groups.values()) { + if (login === destAccountId && (!jmapAccountId || jmapAccountId === destPrimary)) { + const error = new Error('The messages are already in that account'); + set({ error: error.message }); + throw error; + } + } + + for (const { login, jmapAccountId, ids } of groups.values()) { + await get().crossAccountMoveEmails( + new Map([[login, ids]]), + destAccountId, + destMailboxId, + undefined, + jmapAccountId, + { keepOriginal: true }, + ); + } + }, + moveThreadToMailbox: async (client, emailId, destinationMailboxId) => { try { const state = get();