mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: act on the ticked account's mail in batch actions #1061
Batch delete grouped the selection by bare email id, so with the same id in two accounts, permanent-deleting B's row destroyed A's message and left B's alone. Copy had the same lookup and went out through the wrong account. - resolve the selection to email objects by key, including ticked members of an expanded thread that are not list rows - delete, move, mark read, tag and copy group by those objects - rows, thread checkboxes, drag and drop compare by key, so ticked rows show as checked in unified and tag views - the Mountain View toolbar uses the same resolver
This commit is contained in:
@@ -4,7 +4,7 @@ import { ThreadListItem } from '../thread-list-item';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { groupEmailsByThread } from '@/lib/thread-utils';
|
||||
import { emailKeyFor, groupEmailsByThread } from '@/lib/thread-utils';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
@@ -439,3 +439,67 @@ describe('ThreadListItem account row tint', () => {
|
||||
expect(row(container)!.className).toContain('bg-teal-100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem selection in a merged view', () => {
|
||||
// Unified and tag views stamp each email with its owning account, and the
|
||||
// selection holds `emailKeyFor` keys. A bare-id check never matches them.
|
||||
const stamp = { sourceClientAccountId: 'account-b', sourceAccountId: 'account-b' };
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
density: 'regular',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
isUnifiedView: true,
|
||||
selectedEmailKeys: new Set<string>(),
|
||||
lastSelectedEmailKey: null,
|
||||
selectedMailbox: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a ticked stamped row as checked', () => {
|
||||
const email = makeEmail({ ...stamp });
|
||||
useEmailStore.setState({ emails: [email], selectedEmailKeys: new Set([emailKeyFor(email)]) });
|
||||
renderRow(email);
|
||||
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('does not show a namesake row from another account as checked', () => {
|
||||
const mine = makeEmail({ sourceClientAccountId: 'account-a', sourceAccountId: 'account-a' });
|
||||
const theirs = makeEmail({ ...stamp });
|
||||
useEmailStore.setState({ emails: [mine, theirs], selectedEmailKeys: new Set([emailKeyFor(mine)]) });
|
||||
renderRow(theirs);
|
||||
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('ticks a whole thread by key and shows it checked', () => {
|
||||
const e1 = makeEmail({ id: 'e1', threadId: 't1', receivedAt: '2024-01-15T10:00:00Z', ...stamp });
|
||||
const e2 = makeEmail({ id: 'e2', threadId: 't1', receivedAt: '2024-01-15T11:00:00Z', ...stamp });
|
||||
useEmailStore.setState({ emails: [e1, e2] });
|
||||
const [thread] = groupEmailsByThread([e1, e2]);
|
||||
const ui = (
|
||||
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} />
|
||||
);
|
||||
const { rerender } = render(ui);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('checkbox').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
const { selectedEmailKeys, lastSelectedEmailKey } = useEmailStore.getState();
|
||||
expect([...selectedEmailKeys].sort()).toEqual([emailKeyFor(e1), emailKeyFor(e2)].sort());
|
||||
expect(lastSelectedEmailKey).toBe(emailKeyFor(e2));
|
||||
rerender(ui);
|
||||
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('checkbox').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, Calenda
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useEmailStore, ArchiveMailboxNotFoundError } from "@/stores/email-store";
|
||||
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -400,7 +400,7 @@ export function EmailList({
|
||||
if (!client || isProcessing) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const emailIds = emails.filter(e => selectedEmailKeys.has(emailKeyFor(e))).map(e => e.id);
|
||||
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
|
||||
await batchUndoSpam(client, emailIds);
|
||||
toast.success(tSpam('toast_not_spam_batch', { count: emailIds.length }));
|
||||
} catch {
|
||||
@@ -812,9 +812,11 @@ export function EmailList({
|
||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
|
||||
copyTargets={copyTargets}
|
||||
onCopyToAccount={async (accountId, mailboxId) => {
|
||||
// Pass the emails, not their ids: an id alone cannot say which
|
||||
// account's namesake to copy.
|
||||
const ids = selectedEmailKeys.has(emailKeyFor(contextMenuEmail!)) && selectedEmailKeys.size > 1
|
||||
? emails.filter((email) => selectedEmailKeys.has(emailKeyFor(email))).map((email) => email.id)
|
||||
: [contextMenuEmail!.id];
|
||||
? resolveSelectedEmails(useEmailStore.getState())
|
||||
: [contextMenuEmail!];
|
||||
const target = accountMailboxes[accountId]?.find((mb) => mb.id === mailboxId);
|
||||
await runBatchEmailAction(() => copyEmailsToAccount(ids, accountId, target?.originalId ?? mailboxId), {
|
||||
success: tNotifications('emails_copied', { count: ids.length }),
|
||||
@@ -854,7 +856,7 @@ export function EmailList({
|
||||
}}
|
||||
onBatchMarkAsSpam={async () => {
|
||||
if (client) {
|
||||
const emailIds = emails.filter(e => selectedEmailKeys.has(emailKeyFor(e))).map(e => e.id);
|
||||
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
|
||||
try {
|
||||
await batchMarkAsSpam(client, emailIds);
|
||||
toast.success(
|
||||
@@ -867,7 +869,7 @@ export function EmailList({
|
||||
}}
|
||||
onBatchUndoSpam={async () => {
|
||||
if (client) {
|
||||
const emailIds = emails.filter(e => selectedEmailKeys.has(emailKeyFor(e))).map(e => e.id);
|
||||
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
|
||||
try {
|
||||
await batchUndoSpam(client, emailIds);
|
||||
toast.success(
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@/components/icons";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore, ArchiveMailboxNotFoundError } from "@/stores/email-store";
|
||||
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails } from "@/stores/email-store";
|
||||
import { runBatchEmailAction } from "@/lib/email-action-toast";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
@@ -247,7 +247,7 @@ export function MvListToolbar({
|
||||
});
|
||||
});
|
||||
|
||||
const selectedEmails = emails.filter((email) => selectedEmailKeys.has(email.id));
|
||||
const selectedEmails = resolveSelectedEmails(useEmailStore.getState());
|
||||
|
||||
// The same targets the row's own context menu offers, with the account of
|
||||
// the selection leading (#1149).
|
||||
@@ -320,7 +320,7 @@ export function MvListToolbar({
|
||||
const handleSpam = () =>
|
||||
run(async () => {
|
||||
if (!client) return;
|
||||
const ids = Array.from(selectedEmailKeys);
|
||||
const ids = resolveSelectedEmails(useEmailStore.getState()).map((email) => email.id);
|
||||
try {
|
||||
if (isInJunk) {
|
||||
await batchUndoSpam(client, ids);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { emailKeyFor, getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
@@ -47,7 +47,7 @@ export function ThreadEmailItem({
|
||||
const sender = email.from?.[0];
|
||||
const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const isChecked = selectedEmailKeys.has(email.id);
|
||||
const isChecked = selectedEmailKeys.has(emailKeyFor(email));
|
||||
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
email,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { accountTintKey, generateAvatarColor } from "@/lib/account-utils";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||
import { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { emailKeyFor, getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
|
||||
@@ -306,7 +306,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const account = email.accountId ? getAccountById(email.accountId) : undefined;
|
||||
const accountColor = account?.avatarColor;
|
||||
const accountDescription = describeAccount(email.accountLabel, account);
|
||||
const isChecked = selectedEmailKeys.has(email.id);
|
||||
const isChecked = selectedEmailKeys.has(emailKeyFor(email));
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
@@ -879,7 +879,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
|
||||
const isChecked = thread.emails.some(e => selectedEmailKeys.has(e.id));
|
||||
const isChecked = thread.emails.some(e => selectedEmailKeys.has(emailKeyFor(e)));
|
||||
|
||||
if (emailCount === 1) {
|
||||
return (
|
||||
@@ -909,16 +909,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
|
||||
// Toggle selection for all emails in this thread.
|
||||
const toggleThreadSelection = () => {
|
||||
const allSelected = thread.emails.every(em => selectedEmailKeys.has(em.id));
|
||||
const allSelected = thread.emails.every(em => selectedEmailKeys.has(emailKeyFor(em)));
|
||||
const newSelection = new Set(selectedEmailKeys);
|
||||
thread.emails.forEach(em => {
|
||||
if (allSelected) {
|
||||
newSelection.delete(em.id);
|
||||
newSelection.delete(emailKeyFor(em));
|
||||
} else {
|
||||
newSelection.add(em.id);
|
||||
newSelection.add(emailKeyFor(em));
|
||||
}
|
||||
});
|
||||
useEmailStore.setState({ selectedEmailKeys: newSelection, lastSelectedEmailKey: latestEmail.id });
|
||||
useEmailStore.setState({ selectedEmailKeys: newSelection, lastSelectedEmailKey: emailKeyFor(latestEmail) });
|
||||
};
|
||||
|
||||
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
|
||||
|
||||
@@ -32,7 +32,7 @@ import { usePolicyStore } from "@/stores/policy-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveUnstampedEmailAccountId, resolveEmailBlobContext, emptyFolderMovesToTrash } from "@/stores/email-store";
|
||||
import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, emptyFolderMovesToTrash } from "@/stores/email-store";
|
||||
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { formatRejectedRecipients, type JMAPClient } from "@/lib/jmap/client";
|
||||
@@ -59,7 +59,7 @@ import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
import { cn, getMailboxPath } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY, emailKeyFor, groupEmailsByThread, threadKeyFor } from "@/lib/thread-utils";
|
||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY, groupEmailsByThread, threadKeyFor } from "@/lib/thread-utils";
|
||||
import { resolveThreadRoute } from "@/lib/thread-routing";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
@@ -849,8 +849,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const isInJunk = currentMailbox?.role === 'junk';
|
||||
if (selectedEmailKeys.size > 0 && client) {
|
||||
// The spam actions take bare ids of the viewed account: resolve the
|
||||
// selection keys through the loaded rows.
|
||||
const ids = useEmailStore.getState().emails.filter(e => selectedEmailKeys.has(emailKeyFor(e))).map(e => e.id);
|
||||
// selection keys through the loaded rows and thread members.
|
||||
const ids = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
|
||||
try {
|
||||
if (isInJunk) {
|
||||
await batchUndoSpam(client, ids);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { emailKeyFor } from "@/lib/thread-utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
@@ -195,10 +196,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
if (!dragOutEnabled || !client) return;
|
||||
const isSelected = selectedEmailKeys.has(email.id);
|
||||
const isSelected = selectedEmailKeys.has(emailKeyFor(email));
|
||||
const isMulti = isSelected && selectedEmailKeys.size > 1;
|
||||
if (isMulti) {
|
||||
const selected = emails.filter((em) => selectedEmailKeys.has(em.id));
|
||||
const selected = emails.filter((em) => selectedEmailKeys.has(emailKeyFor(em)));
|
||||
// Only worth bundling when at least one selected email has a blobId.
|
||||
if (selected.some((em) => em.blobId)) {
|
||||
prefetchEmailBundle(client, selected, filenameOptions, bundleOptions);
|
||||
@@ -206,16 +207,16 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
} else {
|
||||
prefetchSingle();
|
||||
}
|
||||
}, [dragOutEnabled, client, selectedEmailKeys, email.id, emails, prefetchSingle, filenameOptions, bundleOptions]);
|
||||
}, [dragOutEnabled, client, selectedEmailKeys, email, emails, prefetchSingle, filenameOptions, bundleOptions]);
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
// Determine which emails to drag:
|
||||
// - If current email is selected, drag all selected
|
||||
// - If threadEmails provided (thread header), drag all thread emails
|
||||
// - Otherwise, drag only this email
|
||||
const isSelected = selectedEmailKeys.has(email.id);
|
||||
const isSelected = selectedEmailKeys.has(emailKeyFor(email));
|
||||
const emailsToDrag = isSelected
|
||||
? emails.filter(em => selectedEmailKeys.has(em.id))
|
||||
? emails.filter(em => selectedEmailKeys.has(emailKeyFor(em)))
|
||||
: threadEmails || [email];
|
||||
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useState, DragEvent } from "react";
|
||||
import { Mailbox, Email } from "@/lib/jmap/types";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { emailKeyFor } from "@/lib/thread-utils";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -183,8 +184,9 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
await moveEmailsToMailbox(client, emailIds, mailbox.id);
|
||||
}
|
||||
|
||||
// Clear selection if any selected emails were moved
|
||||
if (emailIds.some(id => selectedEmailKeys.has(id))) {
|
||||
// Clear selection if any selected emails were moved. The selection is
|
||||
// keyed by owning account, so compare the dragged emails, not their ids.
|
||||
if (draggedEmails.some(em => selectedEmailKeys.has(emailKeyFor(em)))) {
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore, resolveSelectedEmails } from '../email-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import { emailKeyFor } from '@/lib/thread-utils';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
/**
|
||||
* Batch actions over a selection that holds a namesake: the same email id in
|
||||
* two accounts of the unified view. Each action must reach the account of the
|
||||
* row that was ticked, through that account's client, and leave the other
|
||||
* account's message alone.
|
||||
*/
|
||||
const twin = (account: string, overrides: Partial<Email> = {}): Email => ({
|
||||
id: 'eiaaaabc', threadId: 'taaaabc', blobId: `blob-${account}`,
|
||||
mailboxIds: { [`inbox-${account}`]: true }, keywords: {},
|
||||
from: [{ email: `someone@${account}` }], to: [], subject: account,
|
||||
receivedAt: '2026-09-19T08:00:00Z', size: 42, preview: '', hasAttachment: false,
|
||||
sourceClientAccountId: account, sourceAccountId: account,
|
||||
...overrides,
|
||||
} as unknown as Email);
|
||||
|
||||
const mailbox = (overrides: Partial<Mailbox>): Mailbox => ({
|
||||
id: 'inbox', name: 'Inbox', sortOrder: 0,
|
||||
totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
|
||||
myRights: {
|
||||
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
|
||||
maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true,
|
||||
},
|
||||
isSubscribed: true, isShared: false,
|
||||
...overrides,
|
||||
} as Mailbox);
|
||||
|
||||
const makeClient = () => ({
|
||||
batchDeleteEmails: vi.fn().mockResolvedValue(undefined),
|
||||
batchMoveEmails: vi.fn().mockResolvedValue(undefined),
|
||||
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
|
||||
getMailboxes: vi.fn().mockResolvedValue([]),
|
||||
}) as unknown as IJMAPClient & Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
const a = twin('account-a');
|
||||
const b = twin('account-b');
|
||||
let clientA: ReturnType<typeof makeClient>;
|
||||
let clientB: ReturnType<typeof makeClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
clientA = makeClient();
|
||||
clientB = makeClient();
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'account-a',
|
||||
client: clientA,
|
||||
getClientForAccount: (id: string) =>
|
||||
(id === 'account-a' ? clientA : id === 'account-b' ? clientB : undefined) as never,
|
||||
} as never);
|
||||
useEmailStore.setState({
|
||||
isUnifiedView: true,
|
||||
selectedMailbox: '',
|
||||
selectedKeyword: null,
|
||||
emails: [a, b],
|
||||
threadEmailsCache: new Map(),
|
||||
selectedEmailKeys: new Set(),
|
||||
lastSelectedEmailKey: null,
|
||||
mailboxes: [],
|
||||
accountMailboxes: {
|
||||
'account-a': [mailbox({ id: 'trash-a', name: 'Trash', role: 'trash', accountId: 'account-a' })],
|
||||
'account-b': [mailbox({ id: 'trash-b', name: 'Trash', role: 'trash', accountId: 'account-b' })],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch actions with a namesake in another account', () => {
|
||||
it('permanently deletes only the ticked account copy', async () => {
|
||||
useEmailStore.getState().toggleEmailSelection(b);
|
||||
await useEmailStore.getState().batchDelete(clientA, true);
|
||||
|
||||
expect(clientB.batchDeleteEmails).toHaveBeenCalledWith(['eiaaaabc'], 'account-b');
|
||||
expect(clientA.batchDeleteEmails).not.toHaveBeenCalled();
|
||||
expect(useEmailStore.getState().emails.map(e => e.sourceAccountId)).toEqual(['account-a']);
|
||||
});
|
||||
|
||||
it('moves only the ticked account copy to its own trash', async () => {
|
||||
useEmailStore.getState().toggleEmailSelection(b);
|
||||
await useEmailStore.getState().batchDelete(clientA);
|
||||
|
||||
expect(clientB.batchMoveEmails).toHaveBeenCalledWith(['eiaaaabc'], 'trash-b', 'account-b', false);
|
||||
expect(clientA.batchMoveEmails).not.toHaveBeenCalled();
|
||||
expect(useEmailStore.getState().emails.map(e => e.sourceAccountId)).toEqual(['account-a']);
|
||||
});
|
||||
|
||||
it('keeps the namesake whose account has no trash when the other one moved', async () => {
|
||||
useEmailStore.setState({
|
||||
accountMailboxes: {
|
||||
'account-a': [],
|
||||
'account-b': [mailbox({ id: 'trash-b', name: 'Trash', role: 'trash', accountId: 'account-b' })],
|
||||
},
|
||||
});
|
||||
useEmailStore.getState().selectAllEmails();
|
||||
await useEmailStore.getState().batchDelete(clientA);
|
||||
|
||||
expect(clientB.batchMoveEmails).toHaveBeenCalledTimes(1);
|
||||
expect(clientA.batchMoveEmails).not.toHaveBeenCalled();
|
||||
const state = useEmailStore.getState();
|
||||
expect(state.emails.map(e => e.sourceAccountId)).toEqual(['account-a']);
|
||||
expect(state.error).toMatch(/trash folder missing/);
|
||||
});
|
||||
|
||||
it('marks only the ticked account copy read', async () => {
|
||||
useEmailStore.getState().toggleEmailSelection(b);
|
||||
await useEmailStore.getState().batchMarkAsRead(clientA, true);
|
||||
|
||||
expect(clientB.batchMarkAsRead).toHaveBeenCalledWith(['eiaaaabc'], true, 'account-b');
|
||||
expect(clientA.batchMarkAsRead).not.toHaveBeenCalled();
|
||||
const [rowA, rowB] = useEmailStore.getState().emails;
|
||||
expect(rowA.keywords.$seen).toBeUndefined();
|
||||
expect(rowB.keywords.$seen).toBe(true);
|
||||
});
|
||||
|
||||
it('copies the ticked account copy from its own account', async () => {
|
||||
const crossAccountMoveEmails = vi.fn().mockResolvedValue(undefined);
|
||||
useEmailStore.setState({ crossAccountMoveEmails });
|
||||
await useEmailStore.getState().copyEmailsToAccount([b], 'account-c', 'dest');
|
||||
|
||||
expect(crossAccountMoveEmails).toHaveBeenCalledTimes(1);
|
||||
const [bySource, , , , sourceJmap] = crossAccountMoveEmails.mock.calls[0];
|
||||
expect([...bySource]).toEqual([['account-b', ['eiaaaabc']]]);
|
||||
expect(sourceJmap).toBe('account-b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection reaching expanded thread members', () => {
|
||||
const row = { ...a, id: 'row', threadId: 'thr' } as Email;
|
||||
const member = { ...a, id: 'member', threadId: 'thr' } as Email;
|
||||
|
||||
it('resolves a ticked member that is not a list row, once', () => {
|
||||
const state = {
|
||||
emails: [row],
|
||||
threadEmailsCache: new Map([['k', [row, member]]]),
|
||||
selectedEmailKeys: new Set([emailKeyFor(row), emailKeyFor(member)]),
|
||||
};
|
||||
expect(resolveSelectedEmails(state).map(e => e.id)).toEqual(['row', 'member']);
|
||||
});
|
||||
|
||||
it('deletes a ticked member that lives outside the list', async () => {
|
||||
useEmailStore.setState({
|
||||
emails: [row],
|
||||
threadEmailsCache: new Map([['account-a/account-a:thr', [row, member]]]),
|
||||
selectedEmailKeys: new Set([emailKeyFor(member)]),
|
||||
});
|
||||
await useEmailStore.getState().batchDelete(clientA, true);
|
||||
|
||||
expect(clientA.batchDeleteEmails).toHaveBeenCalledWith(['member'], 'account-a');
|
||||
});
|
||||
});
|
||||
+53
-24
@@ -56,6 +56,28 @@ function dropFromSelection(
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The emails behind a key-based selection, each once. Looks in the list and in
|
||||
* the loaded members of expanded threads: a thread member ticked in a folder
|
||||
* view may live in another folder and so never be a list row.
|
||||
*/
|
||||
export function resolveSelectedEmails(state: {
|
||||
emails: Email[];
|
||||
threadEmailsCache: Map<string, Email[]>;
|
||||
selectedEmailKeys: Set<string>;
|
||||
}): Email[] {
|
||||
const { selectedEmailKeys } = state;
|
||||
if (selectedEmailKeys.size === 0) return [];
|
||||
const found = new Map<string, Email>();
|
||||
const visit = (email: Email) => {
|
||||
const key = emailKeyFor(email);
|
||||
if (selectedEmailKeys.has(key) && !found.has(key)) found.set(key, email);
|
||||
};
|
||||
state.emails.forEach(visit);
|
||||
for (const members of state.threadEmailsCache.values()) members.forEach(visit);
|
||||
return [...found.values()];
|
||||
}
|
||||
|
||||
/** Enough of an email to key a selection by its owning account. */
|
||||
type SelectableEmail = Pick<Email, 'id' | 'sourceClientAccountId' | 'sourceAccountId'>;
|
||||
|
||||
@@ -307,7 +329,8 @@ interface EmailStore {
|
||||
* grouped by the login and JMAP account they live in, then copied through
|
||||
* crossAccountMoveEmails with `keepOriginal`.
|
||||
*/
|
||||
copyEmailsToAccount: (emailIds: string[], destAccountId: string, destMailboxId: string) => Promise<void>;
|
||||
/** Pass emails rather than ids where you have them: an id alone can name another account's namesake. */
|
||||
copyEmailsToAccount: (emails: Array<string | Email>, destAccountId: string, destMailboxId: string) => Promise<void>;
|
||||
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
|
||||
advancedSearch: (client: IJMAPClient) => Promise<void>;
|
||||
setSearchFilters: (filters: Partial<SearchFilters>) => void;
|
||||
@@ -1822,7 +1845,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
selectEmail: (email) => {
|
||||
const prev = get().selectedEmail;
|
||||
set({ selectedEmail: email, lastSelectedEmailKey: email?.id ?? get().lastSelectedEmailKey });
|
||||
set({ selectedEmail: email, lastSelectedEmailKey: email ? emailKeyFor(email) : get().lastSelectedEmailKey });
|
||||
if (prev && (!email || email.id !== prev.id)) {
|
||||
emailHooks.onEmailClose.emitSync(prev);
|
||||
}
|
||||
@@ -3149,8 +3172,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// One group per (login, JMAP account): crossAccountMoveEmails takes a
|
||||
// single source JMAP account per call.
|
||||
const groups = new Map<string, { login: string; jmapAccountId?: string; ids: string[] }>();
|
||||
for (const id of emailIds) {
|
||||
const email = state.emails.find((e) => e.id === id)
|
||||
for (const entry of emailIds) {
|
||||
const id = typeof entry === 'string' ? entry : entry.id;
|
||||
const email = typeof entry !== 'string' ? entry : 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.
|
||||
@@ -3634,7 +3658,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// exactly those emails, never the keys and never a lookup by id (which
|
||||
// is what let a namesake in another account ride along). A targeted call
|
||||
// names its messages, and the account they belong to, itself.
|
||||
const selectedEmails = target ? [] : emails.filter(e => selectedEmailKeys.has(emailKeyFor(e)));
|
||||
const selectedEmails = target ? [] : resolveSelectedEmails(get());
|
||||
const targetIds = target ? new Set(target.emailIds) : null;
|
||||
const emailIdsArray = targetIds ? Array.from(targetIds) : selectedEmails.map(e => e.id);
|
||||
if (emailIdsArray.length === 0) return;
|
||||
@@ -3731,7 +3755,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
|
||||
batchSetTag: async (client, tagId, add) => {
|
||||
const { selectedEmailKeys, emails } = get();
|
||||
const { selectedEmailKeys } = get();
|
||||
if (selectedEmailKeys.size === 0) return;
|
||||
|
||||
// Both prefixes name the same tag when read, so taking one off clears
|
||||
@@ -3745,8 +3769,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// folder's owner in a shared folder. (#281) The selection is keyed by
|
||||
// owning account, so a namesake in another account is never written.
|
||||
const groups = new Map<IJMAPClient, Map<string | undefined, Email[]>>();
|
||||
for (const email of emails) {
|
||||
if (!selectedEmailKeys.has(emailKeyFor(email))) continue;
|
||||
for (const email of resolveSelectedEmails(get())) {
|
||||
// A message already in the wanted state needs no write.
|
||||
if (tagKeys.some(key => email.keywords?.[key] === true) === add) continue;
|
||||
const { client: actionClient, accountId } = resolveEmailActionContext(email, client);
|
||||
@@ -3797,8 +3820,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// The selection is keyed by owning account; JMAP takes the bare ids of
|
||||
// exactly those emails, never the keys and never a lookup by id (which
|
||||
// is what let a namesake in another account ride along).
|
||||
const selectedEmails = emails.filter(e => selectedEmailKeys.has(emailKeyFor(e)));
|
||||
const emailIdsArray = selectedEmails.map(e => e.id);
|
||||
const selectedEmails = resolveSelectedEmails(get());
|
||||
if (selectedEmails.length === 0) {
|
||||
set({ selectedEmailKeys: new Set(), isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if the current folder forces permanent deletion.
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
@@ -3813,12 +3839,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// `sourceClientAccountId` and routes JMAP via `sourceAccountId`. Undecorated
|
||||
// emails (normal single-mailbox view) fall into '__default__' = active client.
|
||||
const accountMailboxes = get().accountMailboxes;
|
||||
const bySource = new Map<string, { clientAccountId?: string; ids: string[] }>();
|
||||
for (const emailId of emailIdsArray) {
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
const key = email?.sourceAccountId || '__default__';
|
||||
if (!bySource.has(key)) bySource.set(key, { clientAccountId: email?.sourceClientAccountId, ids: [] });
|
||||
bySource.get(key)!.ids.push(emailId);
|
||||
// Group the selected emails themselves: looking an id up in the list
|
||||
// would find the first account's namesake and act on it instead.
|
||||
const bySource = new Map<string, { clientAccountId?: string; ids: string[]; keys: string[] }>();
|
||||
for (const email of selectedEmails) {
|
||||
const key = email.sourceAccountId || '__default__';
|
||||
if (!bySource.has(key)) bySource.set(key, { clientAccountId: email.sourceClientAccountId, ids: [], keys: [] });
|
||||
bySource.get(key)!.ids.push(email.id);
|
||||
bySource.get(key)!.keys.push(emailKeyFor(email));
|
||||
}
|
||||
|
||||
// Undecorated emails viewed in a shared/group folder directly (non-unified)
|
||||
@@ -3847,8 +3875,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
} else {
|
||||
// Move to trash per account.
|
||||
const failedAccounts: string[] = [];
|
||||
const movedEmailIds = new Set<string>();
|
||||
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
|
||||
// Keys, not ids: a namesake in an account that failed must stay.
|
||||
const movedKeys = new Set<string>();
|
||||
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids, keys }]) => {
|
||||
const acctClient = getClient(sourceAccountId, clientAccountId);
|
||||
if (!acctClient) {
|
||||
failedAccounts.push(sourceAccountId);
|
||||
@@ -3865,19 +3894,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||||
await acctClient.batchMoveEmails(ids, trashId, jmapIdFor(sourceAccountId), alsoMarkRead);
|
||||
ids.forEach(id => movedEmailIds.add(id));
|
||||
keys.forEach(key => movedKeys.add(key));
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
|
||||
if (failedAccounts.length > 0 && movedEmailIds.size === 0) {
|
||||
if (failedAccounts.length > 0 && movedKeys.size === 0) {
|
||||
// Nothing moved - bail out so the UI doesn't drop the emails from view.
|
||||
throw new Error('Trash mailbox not found - cannot move emails to trash');
|
||||
}
|
||||
|
||||
// Only remove successfully moved emails from local state.
|
||||
if (movedEmailIds.size < emailIdsArray.length) {
|
||||
const deletedEmails = emails.filter(e => movedEmailIds.has(e.id));
|
||||
const remainingEmails = emails.filter(e => !movedEmailIds.has(e.id));
|
||||
if (movedKeys.size < selectedEmails.length) {
|
||||
const deletedEmails = emails.filter(e => movedKeys.has(emailKeyFor(e)));
|
||||
const remainingEmails = emails.filter(e => !movedKeys.has(emailKeyFor(e)));
|
||||
const mailboxPatch = applyBatchMailboxCounterUpdate(get(), deletedEmails, applyDeleteCounters);
|
||||
set({
|
||||
emails: remainingEmails,
|
||||
@@ -3916,7 +3945,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
batchMoveToMailbox: async (client, toMailboxId, target) => {
|
||||
const { selectedEmailKeys, emails } = get();
|
||||
// Keyed selection as in batchMarkAsRead; a targeted call names its own.
|
||||
const selectedEmails = target ? [] : emails.filter(e => selectedEmailKeys.has(emailKeyFor(e)));
|
||||
const selectedEmails = target ? [] : resolveSelectedEmails(get());
|
||||
const targetIds = target ? new Set(target.emailIds) : null;
|
||||
const emailIdsArray = targetIds ? Array.from(targetIds) : selectedEmails.map(e => e.id);
|
||||
if (emailIdsArray.length === 0) return;
|
||||
|
||||
Reference in New Issue
Block a user