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();