Files
SRCmail/stores/__tests__/email-store-shared-folder-actions.test.ts
T
KazNIISA ITandLinus Rath 88b07a1713 fix(email-store): route shared-folder batch actions to the owner account
Batch actions (delete, move, archive, mark-as-read) performed while
viewing a shared/group mailbox directly from the "Shared" sidebar section
were dispatched to the user's OWN account instead of the shared owner
account. Emails in that view are undecorated (no sourceAccountId, that is
only set in unified/cross-account views) and are reached through the
active client, so they fell into the '__default__' bucket / non-unified
else-branch, which defaults the JMAP accountId to the active account.
batchArchive independently picked the archive folder from the merged
mailbox list, where the user's own archive is listed first.

Stalwart then applies Email/set to the wrong account: because the ids
belong to the shared account it returns them as `updated: null` with an
unchanged state (a silent no-op, not `notUpdated`), so the UI drops the
rows optimistically and they reappear on the next reload. It only appears
to work when the own and shared folder ids happen to collide.

Add resolveViewAccountId() — the owner accountId of the directly-viewed
shared folder (from the selected namespaced mailbox), undefined for a
normal own-account view, mirroring fetchEmails and the single-email path.
Route the four batch actions to that owner account (via the active
client); batchMoveToMailbox also resolves the destination to its bare
originalId, and batchArchive scopes the archive folder to that account.
Own-account and unified/cross-account views are unchanged.

Adds email-store-shared-folder-actions.test.ts covering all four batch
actions in the non-unified shared view plus an own-account regression.
2026-07-21 20:55:26 +02:00

174 lines
7.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { useAuthStore } from '../auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// Regression coverage for batch actions performed while viewing a shared/group
// mailbox DIRECTLY (the "Shared" sidebar section), not through the unified inbox.
//
// In this view the emails are undecorated (no `sourceAccountId`): they are
// fetched from the owner account through the active login client, and only their
// `mailboxIds` are namespaced. The batch actions grouped such emails under the
// `'__default__'` bucket and dispatched them to the *user's own* account with no
// JMAP accountId. Stalwart then returns the ids as `updated: null` with an
// unchanged state (a silent no-op, no `notUpdated`), so the UI drops the rows
// optimistically and they reappear on the next reload. The action must instead
// carry the viewed shared folder's owner accountId (reached via the active
// client), exactly like the single-email path and `fetchEmails` already do.
function makeMailbox(overrides: Partial<Mailbox> = {}): Mailbox {
return {
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,
};
}
function makeEmail(overrides: Partial<Email> = {}): Email {
return {
id: 'email-1',
threadId: 'thread-1',
subject: 'Hi',
receivedAt: new Date().toISOString(),
keywords: {},
mailboxIds: {},
...overrides,
} as Email;
}
function makeClient() {
return {
markAsRead: vi.fn().mockResolvedValue(undefined),
toggleStar: vi.fn().mockResolvedValue(undefined),
moveEmail: vi.fn().mockResolvedValue(undefined),
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
batchDeleteEmails: vi.fn().mockResolvedValue(undefined),
batchMoveEmails: vi.fn().mockResolvedValue(undefined),
batchArchiveEmails: vi.fn().mockResolvedValue(undefined),
getEmails: vi.fn().mockResolvedValue({ emails: [], hasMore: false, total: 0 }),
getMailboxes: vi.fn().mockResolvedValue([]),
} as unknown as IJMAPClient;
}
describe('non-unified shared-folder batch action routing', () => {
let activeClient: IJMAPClient; // account-a, the logged-in user, also reaches owner-x
beforeEach(() => {
activeClient = makeClient();
// Only the active login exists; the shared owner 'owner-x' is reached
// THROUGH it (there is no separate login client for a group mailbox).
useAuthStore.setState({
activeAccountId: 'account-a',
getClientForAccount: (id: string) => (id === 'account-a' ? activeClient : undefined) as never,
} as never);
useSettingsStore.setState({ deleteAction: 'trash', permanentlyDeleteJunk: false } as never);
// Viewing the shared inbox directly: not unified, no viewingAccountId set (the
// "Shared" section selects the namespaced folder id through handleMailboxSelect).
// `mailboxes` is the merged own+shared list the sidebar renders, so it contains
// BOTH the user's own trash and the shared folders.
useEmailStore.setState({
isUnifiedView: false,
unifiedRole: null,
viewingAccountId: null,
selectedMailbox: 'owner-x:x-inbox',
mailboxes: [
// Own folders are listed first (as getMailboxes emits them), so an
// unscoped `find(role==='archive'|'trash')` would wrongly pick these.
makeMailbox({ id: 'a-inbox', role: 'inbox' }),
makeMailbox({ id: 'a-trash', name: 'Deleted Items', role: 'trash' }),
makeMailbox({ id: 'a-archive', name: 'Archive', role: 'archive' }),
makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox', role: 'inbox', isShared: true, accountId: 'owner-x', unreadEmails: 2, totalEmails: 5 }),
makeMailbox({ id: 'owner-x:x-trash', originalId: 'x-trash', name: 'Deleted Items', role: 'trash', isShared: true, accountId: 'owner-x' }),
makeMailbox({ id: 'owner-x:x-archive', originalId: 'x-archive', name: 'Archive', role: 'archive', isShared: true, accountId: 'owner-x' }),
],
accountMailboxes: {
'owner-x': [
makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', role: 'inbox', isShared: true, accountId: 'owner-x' }),
makeMailbox({ id: 'owner-x:x-trash', originalId: 'x-trash', role: 'trash', isShared: true, accountId: 'owner-x' }),
makeMailbox({ id: 'owner-x:x-archive', originalId: 'x-archive', role: 'archive', isShared: true, accountId: 'owner-x' }),
],
},
processingReadStatus: new Set(),
selectedEmail: null,
emails: [
makeEmail({ id: 'e1', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } }),
makeEmail({ id: 'e2', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } }),
],
selectedEmailIds: new Set(['e1', 'e2']),
});
});
it('batchDelete moves shared emails to the SHARED trash on the owner account', async () => {
await useEmailStore.getState().batchDelete(activeClient, false);
// Owner account + owner's trash id, reached via the active client — NOT the
// user's own trash on their own account.
expect(activeClient.batchMoveEmails).toHaveBeenCalledWith(['e1', 'e2'], 'x-trash', 'owner-x', false);
});
it('batchMoveToMailbox moves shared emails within the owner account', async () => {
await useEmailStore.getState().batchMoveToMailbox(activeClient, 'owner-x:x-archive');
expect(activeClient.batchMoveEmails).toHaveBeenCalledWith(['e1', 'e2'], 'x-archive', 'owner-x');
});
it('batchMarkAsRead marks shared emails read on the owner account', async () => {
await useEmailStore.getState().batchMarkAsRead(activeClient, true);
expect(activeClient.batchMarkAsRead).toHaveBeenCalledWith(['e1', 'e2'], true, 'owner-x');
});
it('batchArchive archives shared emails into the SHARED archive on the owner account', async () => {
await useEmailStore.getState().batchArchive(activeClient);
// Owner archive id + owner accountId — not the user's own archive/account.
expect(activeClient.batchArchiveEmails).toHaveBeenCalledWith(
[{ id: 'e1', receivedAt: expect.any(String) }, { id: 'e2', receivedAt: expect.any(String) }],
'x-archive',
'single',
expect.anything(),
'owner-x',
);
});
it('leaves own-account batch delete untouched (no owner accountId)', async () => {
// Selecting the user's own inbox: undecorated emails must still route to the
// own account with no JMAP accountId (undefined), moving to the own trash.
useEmailStore.setState({
selectedMailbox: 'a-inbox',
emails: [
makeEmail({ id: 'o1', keywords: {}, mailboxIds: { 'a-inbox': true } }),
makeEmail({ id: 'o2', keywords: {}, mailboxIds: { 'a-inbox': true } }),
],
selectedEmailIds: new Set(['o1', 'o2']),
});
await useEmailStore.getState().batchDelete(activeClient, false);
expect(activeClient.batchMoveEmails).toHaveBeenCalledWith(['o1', 'o2'], 'a-trash', undefined, false);
});
});