mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
# Conflicts: # components/contacts/contacts-app.tsx # components/email/__tests__/composer-discard.test.tsx # components/email/calendar-invitation-banner.tsx # components/email/email-composer.tsx # components/mail/mail-app.tsx # components/pro/pro-compose-tab-body.tsx # components/pro/pro-email-tab-body.tsx # components/ui/__tests__/avatar.test.tsx # components/ui/avatar.tsx # hooks/use-mailbox-drop.ts # lib/__tests__/calendar-alerts.test.ts # lib/__tests__/jmap-calendar-tasks.test.ts # lib/__tests__/jmap-send-threading.test.ts # lib/demo/demo-client.ts # lib/demo/fixtures/calendars.ts # lib/jmap/client-interface.ts # lib/jmap/client.ts # lib/web-push.ts # stores/__tests__/contact-store.test.ts # stores/__tests__/email-store-thread-key.test.ts # stores/calendar-store.ts # stores/contact-store.ts # stores/email-store.ts
235 lines
11 KiB
TypeScript
235 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { useEmailStore, findArchiveMailbox } 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),
|
|
getThread: vi.fn().mockResolvedValue({ id: 'thread-1', emailIds: ['e1'] }),
|
|
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
|
|
batchDeleteEmails: vi.fn().mockResolvedValue(undefined),
|
|
batchMoveEmails: vi.fn().mockResolvedValue(undefined),
|
|
batchArchiveEmails: vi.fn().mockResolvedValue(undefined),
|
|
markAsSpam: vi.fn().mockResolvedValue(undefined),
|
|
undoSpam: 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 } }),
|
|
],
|
|
selectedEmailKeys: 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('markThreadAsRead marks a shared conversation read on the owner account', async () => {
|
|
// Opening a conversation in the shared inbox. Its ids belong to owner-x;
|
|
// the login's own account may hold unrelated messages with the same ids.
|
|
useEmailStore.setState({
|
|
emails: [
|
|
makeEmail({ id: 'e1', threadId: 'thread-1', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } }),
|
|
makeEmail({ id: 'e2', threadId: 'thread-1', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } }),
|
|
],
|
|
});
|
|
|
|
await useEmailStore.getState().markThreadAsRead(activeClient, 'thread-1');
|
|
|
|
expect(activeClient.batchMarkAsRead).toHaveBeenCalledTimes(1);
|
|
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('batchMarkAsSpam moves shared emails to junk on the owner account', async () => {
|
|
await useEmailStore.getState().batchMarkAsSpam(activeClient, ['e1', 'e2']);
|
|
|
|
expect((activeClient.markAsSpam as ReturnType<typeof vi.fn>).mock.calls).toEqual([['e1', 'owner-x', false], ['e2', 'owner-x', false]]);
|
|
expect(useEmailStore.getState().emails).toEqual([]);
|
|
});
|
|
|
|
it('batchMarkAsSpam rejects, instead of letting the caller report a move, when the viewed folder is unknown', async () => {
|
|
useEmailStore.setState({ selectedMailbox: 'owner-x:gone' });
|
|
|
|
await expect(useEmailStore.getState().batchMarkAsSpam(activeClient, ['e1'])).rejects.toThrow('Mailbox not found');
|
|
expect(activeClient.markAsSpam).not.toHaveBeenCalled();
|
|
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1', 'e2']);
|
|
});
|
|
|
|
it('batchUndoSpam puts shared emails back in the owner account Inbox', async () => {
|
|
await useEmailStore.getState().batchUndoSpam(activeClient, ['e1', 'e2']);
|
|
|
|
expect((activeClient.undoSpam as ReturnType<typeof vi.fn>).mock.calls).toEqual([['e1', 'x-inbox', 'owner-x'], ['e2', 'x-inbox', 'owner-x']]);
|
|
});
|
|
|
|
it('single-email archive resolves the SHARED archive when a shared inbox is selected (#889)', async () => {
|
|
// mail-app's handleArchive resolves its target with the same helper as
|
|
// batchArchive; with the shared inbox selected it must pick the owner's
|
|
// archive, not the user's own one listed first in the merged list.
|
|
const { mailboxes, selectedMailbox } = useEmailStore.getState();
|
|
const archive = findArchiveMailbox(mailboxes, selectedMailbox);
|
|
expect(archive?.id).toBe('owner-x:x-archive');
|
|
expect(archive?.accountId).toBe('owner-x');
|
|
|
|
// Moving into that folder targets the owner account with the owner's bare id.
|
|
await useEmailStore.getState().moveThreadToMailbox(activeClient, 'e1', archive!.id);
|
|
expect(activeClient.moveEmail).toHaveBeenCalledWith('e1', 'x-archive', 'owner-x');
|
|
});
|
|
|
|
it('single-email archive resolves the OWN archive when an own folder is selected', () => {
|
|
const { mailboxes } = useEmailStore.getState();
|
|
expect(findArchiveMailbox(mailboxes, 'a-inbox')?.id).toBe('a-archive');
|
|
// Unified view pins the owning account explicitly.
|
|
expect(findArchiveMailbox(mailboxes, 'unified-inbox', 'owner-x')?.id).toBe('owner-x:x-archive');
|
|
});
|
|
|
|
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 } }),
|
|
],
|
|
selectedEmailKeys: new Set(['o1', 'o2']),
|
|
});
|
|
|
|
await useEmailStore.getState().batchDelete(activeClient, false);
|
|
|
|
expect(activeClient.batchMoveEmails).toHaveBeenCalledWith(['o1', 'o2'], 'a-trash', undefined, false);
|
|
});
|
|
});
|