mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge pull request #792 from dev-hive-kazniisa/fix/shared-mailbox-folder-management
fix(email-store): route shared-folder management to the owner account
This commit is contained in:
@@ -30,7 +30,7 @@ interface Position {
|
||||
|
||||
export type MailboxContextTarget =
|
||||
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean }
|
||||
| { kind: "folders-section" };
|
||||
| { kind: "folders-section"; accountId?: string };
|
||||
|
||||
const PATH_SEPARATOR = " › ";
|
||||
const MAX_PATH_LENGTH = 40;
|
||||
@@ -82,7 +82,7 @@ interface MailboxContextMenuProps {
|
||||
onMarkAllFoldersRead?: () => void;
|
||||
onEmptyFolder?: (mailboxId: string) => void;
|
||||
onCreateSubfolder?: (parentId: string) => void;
|
||||
onCreateFolder?: () => void;
|
||||
onCreateFolder?: (accountId?: string) => void;
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
@@ -119,18 +119,23 @@ export function MailboxContextMenu({
|
||||
if (target.kind === "folders-section") {
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuItem
|
||||
icon={CheckCheck}
|
||||
label={t("mark_all_folders_read")}
|
||||
onClick={() => handleAction(onMarkAllFoldersRead!)}
|
||||
disabled={!onMarkAllFoldersRead}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
{!target.accountId && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={CheckCheck}
|
||||
label={t("mark_all_folders_read")}
|
||||
onClick={() => handleAction(onMarkAllFoldersRead!)}
|
||||
disabled={!onMarkAllFoldersRead}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
icon={FolderPlus}
|
||||
label={t("new_folder")}
|
||||
onClick={() => handleAction(onCreateFolder!)}
|
||||
onClick={() => handleAction(() => onCreateFolder?.(target.accountId))}
|
||||
disabled={!onCreateFolder}
|
||||
testId="mailbox-new-folder"
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={RefreshCw}
|
||||
@@ -184,12 +189,14 @@ export function MailboxContextMenu({
|
||||
label={t("new_subfolder")}
|
||||
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
|
||||
disabled={!onCreateSubfolder || !canCreateChild}
|
||||
testId="mailbox-new-subfolder"
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("rename")}
|
||||
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
||||
disabled={!onRenameFolder || !canRename}
|
||||
testId="mailbox-rename"
|
||||
/>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
@@ -216,6 +223,7 @@ export function MailboxContextMenu({
|
||||
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
|
||||
disabled={!onDeleteFolder || !canDelete}
|
||||
destructive
|
||||
testId="mailbox-delete"
|
||||
/>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@@ -84,7 +84,7 @@ interface SidebarProps {
|
||||
onMarkAllFoldersRead?: () => void;
|
||||
onEmptyFolder?: (mailboxId: string) => void;
|
||||
onCreateSubfolder?: (parentId: string) => void;
|
||||
onCreateFolder?: () => void;
|
||||
onCreateFolder?: (accountId?: string) => void;
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
@@ -338,6 +338,7 @@ function SidebarRow({
|
||||
e.stopPropagation();
|
||||
onExpandToggle();
|
||||
}}
|
||||
data-testid="folder-expand-toggle"
|
||||
className="flex items-center justify-center rounded hover:bg-muted active:bg-accent transition-colors"
|
||||
style={{ width: CHEVRON_SLOT, height: CHEVRON_SLOT }}
|
||||
title={isExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
|
||||
@@ -394,6 +395,7 @@ function SidebarSectionHeader({
|
||||
icon,
|
||||
sub,
|
||||
testId,
|
||||
onContextMenu,
|
||||
}: {
|
||||
label: string;
|
||||
expanded: boolean;
|
||||
@@ -405,6 +407,7 @@ function SidebarSectionHeader({
|
||||
icon?: ReactNode;
|
||||
sub?: boolean;
|
||||
testId?: string;
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
}) {
|
||||
if (isCollapsed) {
|
||||
return first ? null : <div className="h-px bg-border/50 mx-2 my-2" aria-hidden />;
|
||||
@@ -419,6 +422,7 @@ function SidebarSectionHeader({
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
onContextMenu={onContextMenu}
|
||||
data-testid={testId}
|
||||
data-section-name={label}
|
||||
data-expanded={expanded ? 'true' : 'false'}
|
||||
@@ -1118,6 +1122,17 @@ export function Sidebar({
|
||||
openMailboxContextMenu(e, { kind: "folders-section" });
|
||||
};
|
||||
|
||||
// `buildMailboxTree()` groups shared mailboxes that carry no accountId under
|
||||
// the placeholder id "unknown" (lib/utils.ts). Such a node cannot be a valid
|
||||
// JMAP mutation target, so it gets no folder-creation menu at all rather than
|
||||
// a menu that would create the folder in a non-existent account.
|
||||
const sharedAccountMenuId = (accountId?: string) =>
|
||||
accountId && accountId !== "unknown" ? accountId : undefined;
|
||||
|
||||
const handleSharedAccountContextMenu = (e: React.MouseEvent, accountId: string) => {
|
||||
openMailboxContextMenu(e, { kind: "folders-section", accountId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1332,6 +1347,7 @@ export function Sidebar({
|
||||
<>
|
||||
{sharedAccounts.map((account) => {
|
||||
const accountExpanded = expandedSharedAccounts.has(account.id);
|
||||
const menuAccountId = sharedAccountMenuId(account.accountId);
|
||||
return (
|
||||
<div key={account.id}>
|
||||
<SidebarSectionHeader
|
||||
@@ -1342,6 +1358,9 @@ export function Sidebar({
|
||||
sub
|
||||
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
testId="section-shared-account"
|
||||
onContextMenu={menuAccountId
|
||||
? (e) => handleSharedAccountContextMenu(e, menuAccountId)
|
||||
: undefined}
|
||||
/>
|
||||
{accountExpanded && !isCollapsed && account.children.map((child) => (
|
||||
<MailboxTreeItem
|
||||
|
||||
@@ -2555,7 +2555,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolderFromContextMenu = async () => {
|
||||
const handleCreateFolderFromContextMenu = async (accountId?: string) => {
|
||||
if (!client) return;
|
||||
const name = await promptDialog({
|
||||
title: tCtxMenu('mailbox_context_menu.new_folder'),
|
||||
@@ -2565,7 +2565,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
});
|
||||
if (!name) return;
|
||||
try {
|
||||
await createMailbox(client, name);
|
||||
await createMailbox(client, name, undefined, accountId);
|
||||
toast.success(tCtxMenu('mailbox_context_menu.toast_folder_created'));
|
||||
} catch {
|
||||
toast.error(tCtxMenu('mailbox_context_menu.toast_error_create'));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ACCOUNTS } from './helpers/config';
|
||||
import { ACCOUNTS, GROUP } from './helpers/config';
|
||||
import { sendMail } from './helpers/smtp';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import {
|
||||
@@ -31,13 +31,17 @@ const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
|
||||
|
||||
test.describe('Shared folder actions', () => {
|
||||
let ja: JmapClient; // owner (alice)
|
||||
let jc: JmapClient; // grantee + member of the team group account
|
||||
let sharedId: string;
|
||||
let teamAccountId: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
ja = await JmapClient.connect(alice.email, alice.password);
|
||||
const jc = await JmapClient.connect(carol.email, carol.password);
|
||||
jc = await JmapClient.connect(carol.email, carol.password);
|
||||
teamAccountId = jc.accountIdByName(GROUP.team.email);
|
||||
await ja.reset();
|
||||
await jc.reset();
|
||||
await jc.reset(teamAccountId);
|
||||
// Delegate a custom folder + Trash + Junk to carol.
|
||||
sharedId = await ja.createSharedFolder(SHARED, carol.email);
|
||||
await ja.shareMailboxByRole('trash', carol.email);
|
||||
@@ -65,6 +69,74 @@ test.describe('Shared folder actions', () => {
|
||||
await expectEmailVisible(page, s);
|
||||
});
|
||||
|
||||
test('group folder create, rename, and delete stay in the group account', async ({ page }) => {
|
||||
const rootName = `GroupRoot-${Date.now()}`;
|
||||
const childName = `${rootName}-child`;
|
||||
const renamed = `${childName}-renamed`;
|
||||
|
||||
await login(page, carol);
|
||||
await expandSharedFolders(page, GROUP.team.email);
|
||||
|
||||
const groupHeader = page.locator(
|
||||
`[data-testid="section-shared-account"][data-section-name="${GROUP.team.email}"]`,
|
||||
);
|
||||
await groupHeader.click({ button: 'right' });
|
||||
await page.getByTestId('mailbox-new-folder').click();
|
||||
await page.getByRole('dialog').getByRole('textbox').fill(rootName);
|
||||
await page.getByRole('dialog').getByRole('textbox').press('Enter');
|
||||
|
||||
const rootRow = folderRow(page, { name: rootName, shared: true }).first();
|
||||
await expect(rootRow).toBeVisible();
|
||||
await expect.poll(
|
||||
async () => await jc.mailboxByName(rootName, teamAccountId),
|
||||
{ timeout: 30000 },
|
||||
).toMatchObject({ parentId: null });
|
||||
const rootId = (await jc.mailboxByName(rootName, teamAccountId))!.id;
|
||||
|
||||
await rootRow.click({ button: 'right' });
|
||||
await page.getByTestId('mailbox-new-subfolder').click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('textbox').fill(childName);
|
||||
await dialog.getByRole('textbox').press('Enter');
|
||||
|
||||
await expect(rootRow.getByTestId('folder-expand-toggle')).toBeVisible();
|
||||
const childRow = folderRow(page, { name: childName, shared: true }).first();
|
||||
// The prompt closes before its async submit handler finishes. The mailbox
|
||||
// refetch can therefore replace the row just as the first expand click
|
||||
// lands; retry the user gesture until the freshly fetched child is visible.
|
||||
await expect(async () => {
|
||||
if (!(await childRow.isVisible())) {
|
||||
await rootRow.getByTestId('folder-expand-toggle').click();
|
||||
}
|
||||
await expect(childRow).toBeVisible({ timeout: 2000 });
|
||||
}).toPass({ timeout: 20000 });
|
||||
await expect.poll(async () => await jc.mailboxByName(childName, teamAccountId), { timeout: 30000 })
|
||||
.toMatchObject({ parentId: rootId });
|
||||
|
||||
await childRow.click({ button: 'right' });
|
||||
await page.getByTestId('mailbox-rename').click();
|
||||
await page.getByRole('dialog').getByRole('textbox').fill(renamed);
|
||||
await page.getByRole('dialog').getByRole('textbox').press('Enter');
|
||||
const renamedRow = folderRow(page, { name: renamed, shared: true }).first();
|
||||
await expect(renamedRow).toBeVisible();
|
||||
await expect.poll(async () => await jc.mailboxByName(renamed, teamAccountId), { timeout: 30000 })
|
||||
.toMatchObject({ parentId: rootId });
|
||||
|
||||
await renamedRow.click({ button: 'right' });
|
||||
await page.getByTestId('mailbox-delete').click();
|
||||
await page.getByRole('alertdialog').getByRole('button').last().click();
|
||||
await expect(renamedRow).toHaveCount(0);
|
||||
await expect.poll(async () => await jc.mailboxByName(renamed, teamAccountId), { timeout: 30000 })
|
||||
.toBeUndefined();
|
||||
|
||||
await rootRow.click({ button: 'right' });
|
||||
await page.getByTestId('mailbox-delete').click();
|
||||
await page.getByRole('alertdialog').getByRole('button').last().click();
|
||||
await expect(rootRow).toHaveCount(0);
|
||||
await expect.poll(async () => await jc.mailboxByName(rootName, teamAccountId), { timeout: 30000 })
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
test('mark read/unread in a shared folder updates its counter', async ({ page }) => {
|
||||
const s = subj('sh-read');
|
||||
await seedIntoShared(s);
|
||||
|
||||
@@ -84,6 +84,13 @@ export class JmapClient {
|
||||
.map(([, name]) => name);
|
||||
}
|
||||
|
||||
/** Resolve an account visible in this session by its JMAP display name. */
|
||||
accountIdByName(name: string): string {
|
||||
const entry = Object.entries(this.accounts).find(([, accountName]) => accountName === name);
|
||||
if (!entry) throw new Error(`No account named ${name} in JMAP session`);
|
||||
return entry[0];
|
||||
}
|
||||
|
||||
async request(methodCalls: MethodCall[], using: string[] = [CORE, MAIL, SUBMISSION]): Promise<any> {
|
||||
const res = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
@@ -169,25 +176,25 @@ export class JmapClient {
|
||||
return mb.id;
|
||||
}
|
||||
|
||||
async mailboxes(): Promise<JmapMailbox[]> {
|
||||
const r = await this.request([['Mailbox/get', { accountId: this.accountId }, '0']]);
|
||||
async mailboxes(accountId: string = this.accountId): Promise<JmapMailbox[]> {
|
||||
const r = await this.request([['Mailbox/get', { accountId }, '0']]);
|
||||
return r.methodResponses[0][1].list as JmapMailbox[];
|
||||
}
|
||||
|
||||
async mailboxByRole(role: string): Promise<JmapMailbox | undefined> {
|
||||
return (await this.mailboxes()).find((m) => m.role === role);
|
||||
async mailboxByRole(role: string, accountId: string = this.accountId): Promise<JmapMailbox | undefined> {
|
||||
return (await this.mailboxes(accountId)).find((m) => m.role === role);
|
||||
}
|
||||
|
||||
async mailboxByName(name: string): Promise<JmapMailbox | undefined> {
|
||||
return (await this.mailboxes()).find((m) => m.name === name);
|
||||
async mailboxByName(name: string, accountId: string = this.accountId): Promise<JmapMailbox | undefined> {
|
||||
return (await this.mailboxes(accountId)).find((m) => m.name === name);
|
||||
}
|
||||
|
||||
/** Create a folder (top-level) and return its id. Idempotent by name. */
|
||||
async createMailbox(name: string, parentId: string | null = null): Promise<string> {
|
||||
const existing = await this.mailboxByName(name);
|
||||
async createMailbox(name: string, parentId: string | null = null, accountId: string = this.accountId): Promise<string> {
|
||||
const existing = await this.mailboxByName(name, accountId);
|
||||
if (existing) return existing.id;
|
||||
const r = await this.request([
|
||||
['Mailbox/set', { accountId: this.accountId, create: { new: { name, parentId } } }, '0'],
|
||||
['Mailbox/set', { accountId, create: { new: { name, parentId } } }, '0'],
|
||||
]);
|
||||
const created = r.methodResponses[0][1].created?.new;
|
||||
if (!created) throw new Error(`Mailbox/set create failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
@@ -202,8 +209,8 @@ export class JmapClient {
|
||||
]);
|
||||
}
|
||||
|
||||
private async allEmailIds(): Promise<string[]> {
|
||||
const r = await this.request([['Email/query', { accountId: this.accountId, limit: 5000 }, '0']]);
|
||||
private async allEmailIds(accountId: string = this.accountId): Promise<string[]> {
|
||||
const r = await this.request([['Email/query', { accountId, limit: 5000 }, '0']]);
|
||||
return r.methodResponses[0][1].ids as string[];
|
||||
}
|
||||
|
||||
@@ -211,16 +218,30 @@ export class JmapClient {
|
||||
* Reset a mailbox to a clean slate: destroy every message and delete any
|
||||
* non-system (custom) folder. System folders (Inbox/Sent/Trash/…) are kept.
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
const ids = await this.allEmailIds();
|
||||
async reset(accountId: string = this.accountId): Promise<void> {
|
||||
const ids = await this.allEmailIds(accountId);
|
||||
if (ids.length) {
|
||||
await this.request([['Email/set', { accountId: this.accountId, destroy: ids }, '0']]);
|
||||
await this.request([['Email/set', { accountId, destroy: ids }, '0']]);
|
||||
}
|
||||
const custom = (await this.mailboxes()).filter((m) => !m.role);
|
||||
if (custom.length) {
|
||||
await this.request([
|
||||
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: custom.map((m) => m.id) }, '0'],
|
||||
const custom = (await this.mailboxes(accountId)).filter((m) => !m.role);
|
||||
const byId = new Map(custom.map((mailbox) => [mailbox.id, mailbox]));
|
||||
const depth = (mailbox: JmapMailbox): number => {
|
||||
let result = 0;
|
||||
let parentId = mailbox.parentId;
|
||||
while (parentId && byId.has(parentId)) {
|
||||
result++;
|
||||
parentId = byId.get(parentId)!.parentId;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
// Stalwart refuses to destroy a mailbox that still has children. Remove
|
||||
// custom trees leaf-first so tests that create nested folders cleanly reset.
|
||||
for (const mailbox of custom.sort((a, b) => depth(b) - depth(a))) {
|
||||
const r = await this.request([
|
||||
['Mailbox/set', { accountId, onDestroyRemoveEmails: true, destroy: [mailbox.id] }, '0'],
|
||||
]);
|
||||
const failure = r.methodResponses[0][1].notDestroyed?.[mailbox.id];
|
||||
if (failure) throw new Error(`Mailbox reset failed for ${mailbox.name}: ${JSON.stringify(failure)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
|
||||
type JMAPMethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
function createClient(): JMAPClient {
|
||||
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
||||
Object.assign(client, {
|
||||
apiUrl: 'https://jmap.example.com/api',
|
||||
accountId: 'primary-account',
|
||||
username: 'user@example.com',
|
||||
accounts: {
|
||||
'primary-account': { name: 'user@example.com' },
|
||||
'shared-account': { name: 'team@example.com' },
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
function mockMailboxSet(): JMAPMethodCall[] {
|
||||
const captured: JMAPMethodCall[] = [];
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
const body = JSON.parse((init as { body: string }).body) as { methodCalls: JMAPMethodCall[] };
|
||||
const call = body.methodCalls[0];
|
||||
const args = call[1];
|
||||
captured.push(call);
|
||||
|
||||
let result: Record<string, unknown>;
|
||||
if (args.create) {
|
||||
const createId = Object.keys(args.create as Record<string, unknown>)[0];
|
||||
result = { created: { [createId]: { id: 'created-mailbox' } } };
|
||||
} else if (args.update) {
|
||||
result = { updated: Object.fromEntries(Object.keys(args.update as Record<string, unknown>).map((id) => [id, null])) };
|
||||
} else {
|
||||
result = { destroyed: args.destroy };
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ methodResponses: [['Mailbox/set', result, '0']] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe('JMAP mailbox mutations use the requested account', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('creates a mailbox in a shared account and returns correctly scoped metadata', async () => {
|
||||
const client = createClient();
|
||||
const captured = mockMailboxSet();
|
||||
|
||||
const mailbox = await client.createMailbox('Child', 'parent-id', 'shared-account');
|
||||
|
||||
expect(captured[0][0]).toBe('Mailbox/set');
|
||||
expect(captured[0][1].accountId).toBe('shared-account');
|
||||
expect(Object.values(captured[0][1].create as Record<string, unknown>))
|
||||
.toEqual([{ name: 'Child', parentId: 'parent-id' }]);
|
||||
expect(mailbox).toMatchObject({
|
||||
id: 'created-mailbox',
|
||||
accountId: 'shared-account',
|
||||
accountName: 'team@example.com',
|
||||
isShared: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates a mailbox in the explicit shared account', async () => {
|
||||
const client = createClient();
|
||||
const captured = mockMailboxSet();
|
||||
|
||||
await client.updateMailbox('mailbox-id', { name: 'Renamed' }, 'shared-account');
|
||||
|
||||
expect(captured[0][1]).toMatchObject({
|
||||
accountId: 'shared-account',
|
||||
update: { 'mailbox-id': { name: 'Renamed' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a mailbox from the explicit shared account', async () => {
|
||||
const client = createClient();
|
||||
const captured = mockMailboxSet();
|
||||
|
||||
await client.deleteMailbox('mailbox-id', 'shared-account');
|
||||
|
||||
expect(captured[0][1]).toMatchObject({
|
||||
accountId: 'shared-account',
|
||||
destroy: ['mailbox-id'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps update and delete on the primary account when no account is supplied', async () => {
|
||||
const client = createClient();
|
||||
const captured = mockMailboxSet();
|
||||
|
||||
await client.updateMailbox('mailbox-id', { sortOrder: 3 });
|
||||
await client.deleteMailbox('mailbox-id');
|
||||
|
||||
expect(captured).toHaveLength(2);
|
||||
expect(captured[0][1].accountId).toBe('primary-account');
|
||||
expect(captured[1][1].accountId).toBe('primary-account');
|
||||
});
|
||||
});
|
||||
@@ -139,12 +139,12 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return mb;
|
||||
}
|
||||
|
||||
async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void> {
|
||||
async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, _accountId?: string): Promise<void> {
|
||||
const mb = this.data.mailboxes.find(m => m.id === mailboxId);
|
||||
if (mb) Object.assign(mb, changes);
|
||||
}
|
||||
|
||||
async deleteMailbox(mailboxId: string): Promise<void> {
|
||||
async deleteMailbox(mailboxId: string, _accountId?: string): Promise<void> {
|
||||
this.data.mailboxes = this.data.mailboxes.filter(m => m.id !== mailboxId);
|
||||
// Also remove emails in this mailbox
|
||||
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
|
||||
|
||||
@@ -93,8 +93,8 @@ export interface IJMAPClient {
|
||||
getMailboxes(accountId?: string): Promise<Mailbox[]>;
|
||||
getAllMailboxes(): Promise<Mailbox[]>;
|
||||
createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox>;
|
||||
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
|
||||
deleteMailbox(mailboxId: string): Promise<void>;
|
||||
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountId?: string): Promise<void>;
|
||||
deleteMailbox(mailboxId: string, accountId?: string): Promise<void>;
|
||||
|
||||
// ── Emails ────────────────────────────────────────────────────
|
||||
// `pinnedFirst` sorts emails carrying the $pinned keyword to the top
|
||||
|
||||
+11
-8
@@ -2218,6 +2218,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
async createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const createId = `new-${Date.now()}`;
|
||||
const createData: Record<string, unknown> = { name };
|
||||
if (parentId) {
|
||||
@@ -2226,7 +2227,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const response = await this.request([
|
||||
["Mailbox/set", {
|
||||
accountId: accountId || this.accountId,
|
||||
accountId: targetAccountId,
|
||||
create: { [createId]: createData },
|
||||
}, "0"],
|
||||
]);
|
||||
@@ -2258,16 +2259,17 @@ export class JMAPClient implements IJMAPClient {
|
||||
unreadThreads: 0,
|
||||
myRights: DEFAULT_MAILBOX_RIGHTS,
|
||||
isSubscribed: true,
|
||||
accountId: this.accountId,
|
||||
accountName: this.accounts[this.accountId]?.name || this.username,
|
||||
isShared: false,
|
||||
accountId: targetAccountId,
|
||||
accountName: this.accounts[targetAccountId]?.name || (targetAccountId === this.accountId ? this.username : targetAccountId),
|
||||
isShared: targetAccountId !== this.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void> {
|
||||
async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountId?: string): Promise<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const response = await this.request([
|
||||
["Mailbox/set", {
|
||||
accountId: this.accountId,
|
||||
accountId: targetAccountId,
|
||||
update: { [mailboxId]: changes },
|
||||
}, "0"],
|
||||
]);
|
||||
@@ -2278,10 +2280,11 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMailbox(mailboxId: string): Promise<void> {
|
||||
async deleteMailbox(mailboxId: string, accountId?: string): Promise<void> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const response = await this.request([
|
||||
["Mailbox/set", {
|
||||
accountId: this.accountId,
|
||||
accountId: targetAccountId,
|
||||
destroy: [mailboxId],
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
|
||||
import { emailHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
@@ -32,13 +34,14 @@ function makeMailbox(overrides: Partial<Mailbox> = {}): Mailbox {
|
||||
|
||||
function makeMockClient(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getAccountId: vi.fn().mockReturnValue('account-a'),
|
||||
getServerUrl: vi.fn().mockReturnValue('https://mail.example.test'),
|
||||
createMailbox: vi.fn().mockResolvedValue(makeMailbox({ id: 'mb-new' })),
|
||||
updateMailbox: vi.fn().mockResolvedValue(undefined),
|
||||
deleteMailbox: vi.fn().mockResolvedValue(undefined),
|
||||
getAllMailboxes: vi.fn().mockResolvedValue([]),
|
||||
...overrides,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
} as unknown as IJMAPClient;
|
||||
}
|
||||
|
||||
describe('email-store folder management', () => {
|
||||
@@ -48,8 +51,15 @@ describe('email-store folder management', () => {
|
||||
const custom = makeMailbox({ id: 'custom-1', name: 'My Folder' });
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'account-a',
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
} as never);
|
||||
useEmailStore.setState({
|
||||
mailboxes: [inbox, sent, trash, custom],
|
||||
accountMailboxes: {},
|
||||
viewingAccountId: null,
|
||||
selectedMailbox: 'inbox-1',
|
||||
error: null,
|
||||
});
|
||||
@@ -67,7 +77,7 @@ describe('email-store folder management', () => {
|
||||
|
||||
await useEmailStore.getState().createMailbox(client, 'New Folder');
|
||||
|
||||
expect(client.createMailbox).toHaveBeenCalledWith('New Folder', undefined);
|
||||
expect(client.createMailbox).toHaveBeenCalledWith('New Folder', undefined, undefined);
|
||||
expect(client.getAllMailboxes).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -78,7 +88,7 @@ describe('email-store folder management', () => {
|
||||
|
||||
await useEmailStore.getState().createMailbox(client, 'Sub Folder', 'inbox-1');
|
||||
|
||||
expect(client.createMailbox).toHaveBeenCalledWith('Sub Folder', 'inbox-1');
|
||||
expect(client.createMailbox).toHaveBeenCalledWith('Sub Folder', 'inbox-1', undefined);
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
@@ -100,7 +110,7 @@ describe('email-store folder management', () => {
|
||||
|
||||
await useEmailStore.getState().renameMailbox(client, 'custom-1', 'Renamed');
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { name: 'Renamed' });
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { name: 'Renamed' }, undefined);
|
||||
const mb = useEmailStore.getState().mailboxes.find(m => m.id === 'custom-1');
|
||||
expect(mb?.name).toBe('Renamed');
|
||||
});
|
||||
@@ -133,7 +143,7 @@ describe('email-store folder management', () => {
|
||||
|
||||
await useEmailStore.getState().deleteMailbox(client, 'custom-1');
|
||||
|
||||
expect(client.deleteMailbox).toHaveBeenCalledWith('custom-1');
|
||||
expect(client.deleteMailbox).toHaveBeenCalledWith('custom-1', undefined);
|
||||
const mb = useEmailStore.getState().mailboxes.find(m => m.id === 'custom-1');
|
||||
expect(mb).toBeUndefined();
|
||||
expect(useEmailStore.getState().mailboxes).toHaveLength(3);
|
||||
@@ -182,7 +192,7 @@ describe('email-store folder management', () => {
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(client, 'custom-1', 'archive');
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { role: 'archive' });
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { role: 'archive' }, undefined);
|
||||
});
|
||||
|
||||
it('should clear existing role from another mailbox when reassigning', async () => {
|
||||
@@ -198,9 +208,9 @@ describe('email-store folder management', () => {
|
||||
await useEmailStore.getState().setMailboxRole(client, 'custom-1', 'trash');
|
||||
|
||||
// Should first clear trash role from trash-1
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: null });
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: null }, undefined);
|
||||
// Then set trash role on custom-1
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { role: 'trash' });
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { role: 'trash' }, undefined);
|
||||
});
|
||||
|
||||
it('should clear role from a mailbox when role is null', async () => {
|
||||
@@ -213,7 +223,7 @@ describe('email-store folder management', () => {
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(client, 'trash-1', null);
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: null });
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: null }, undefined);
|
||||
});
|
||||
|
||||
it('should not clear role from same mailbox when re-assigning same role', async () => {
|
||||
@@ -225,7 +235,7 @@ describe('email-store folder management', () => {
|
||||
|
||||
// Should only call once (to set the role), not twice (no need to clear from same mailbox)
|
||||
expect(client.updateMailbox).toHaveBeenCalledTimes(1);
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: 'trash' });
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: 'trash' }, undefined);
|
||||
});
|
||||
|
||||
it('should clear role from ALL mailboxes with that role when reassigning', async () => {
|
||||
@@ -249,10 +259,10 @@ describe('email-store folder management', () => {
|
||||
await useEmailStore.getState().setMailboxRole(client, 'custom-1', 'trash');
|
||||
|
||||
// Should clear trash role from BOTH trash-1 and trash-2
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: null });
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-2', { role: null });
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: null }, undefined);
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-2', { role: null }, undefined);
|
||||
// Then set trash role on custom-1
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { role: 'trash' });
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { role: 'trash' }, undefined);
|
||||
expect(client.updateMailbox).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
@@ -269,6 +279,167 @@ describe('email-store folder management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared/group mailbox routing', () => {
|
||||
const sharedFolder = makeMailbox({
|
||||
id: 'owner-x:shared-1',
|
||||
originalId: 'shared-1',
|
||||
name: 'Shared Folder',
|
||||
accountId: 'owner-x',
|
||||
isShared: true,
|
||||
});
|
||||
const sharedArchive = makeMailbox({
|
||||
id: 'owner-x:archive-x',
|
||||
originalId: 'archive-x',
|
||||
name: 'Shared Archive',
|
||||
role: 'archive',
|
||||
accountId: 'owner-x',
|
||||
isShared: true,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useEmailStore.setState({
|
||||
mailboxes: [inbox, sent, trash, custom, sharedFolder, sharedArchive],
|
||||
selectedMailbox: sharedFolder.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a subfolder with the owner accountId and bare parent id', async () => {
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().createMailbox(client, 'Shared Child', sharedFolder.id);
|
||||
|
||||
expect(client.createMailbox).toHaveBeenCalledWith('Shared Child', 'shared-1', 'owner-x');
|
||||
});
|
||||
|
||||
it('keeps root creation in the personal Folders account while a shared folder is selected', async () => {
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().createMailbox(client, 'Personal Root');
|
||||
|
||||
expect(client.createMailbox).toHaveBeenCalledWith('Personal Root', undefined, undefined);
|
||||
});
|
||||
|
||||
it('creates a shared root folder only when the caller explicitly targets its account', async () => {
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().createMailbox(client, 'Shared Root', undefined, 'owner-x');
|
||||
|
||||
expect(client.createMailbox).toHaveBeenCalledWith('Shared Root', undefined, 'owner-x');
|
||||
});
|
||||
|
||||
it('renames a shared folder through its owner account and preserves the namespaced store id', async () => {
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().renameMailbox(client, sharedFolder.id, 'Renamed Shared');
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('shared-1', { name: 'Renamed Shared' }, 'owner-x');
|
||||
expect(useEmailStore.getState().mailboxes.find((mb) => mb.id === sharedFolder.id)?.name)
|
||||
.toBe('Renamed Shared');
|
||||
});
|
||||
|
||||
it('deletes a shared folder through its owner account and removes the namespaced store entry', async () => {
|
||||
const client = makeMockClient();
|
||||
useEmailStore.setState({ selectedMailbox: inbox.id });
|
||||
|
||||
await useEmailStore.getState().deleteMailbox(client, sharedFolder.id);
|
||||
|
||||
expect(client.deleteMailbox).toHaveBeenCalledWith('shared-1', 'owner-x');
|
||||
expect(useEmailStore.getState().mailboxes.some((mb) => mb.id === sharedFolder.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('reassigns a shared role only within the shared owner account', async () => {
|
||||
const ownArchive = makeMailbox({ id: 'archive-a', name: 'Personal Archive', role: 'archive' });
|
||||
useEmailStore.setState({
|
||||
mailboxes: [inbox, sent, trash, custom, ownArchive, sharedFolder, sharedArchive],
|
||||
});
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(client, sharedFolder.id, 'archive');
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('archive-x', { role: null }, 'owner-x');
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('shared-1', { role: 'archive' }, 'owner-x');
|
||||
expect(client.updateMailbox).not.toHaveBeenCalledWith('archive-a', { role: null }, undefined);
|
||||
});
|
||||
|
||||
it('keeps role reassignment scoped when the owner has a directly connected client', async () => {
|
||||
const ownArchive = makeMailbox({ id: 'archive-a', name: 'Personal Archive', role: 'archive' });
|
||||
useEmailStore.setState({
|
||||
mailboxes: [inbox, sent, trash, custom, ownArchive, sharedFolder, sharedArchive],
|
||||
});
|
||||
const activeClient = makeMockClient();
|
||||
const ownerClient = makeMockClient({
|
||||
getAccountId: vi.fn().mockReturnValue('owner-x'),
|
||||
});
|
||||
useAuthStore.setState({
|
||||
getAllConnectedClients: () => new Map([['owner-login', ownerClient]]),
|
||||
} as never);
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(activeClient, sharedFolder.id, 'archive');
|
||||
|
||||
expect(ownerClient.updateMailbox).toHaveBeenCalledWith('archive-x', { role: null }, undefined);
|
||||
expect(ownerClient.updateMailbox).toHaveBeenCalledWith('shared-1', { role: 'archive' }, undefined);
|
||||
expect(activeClient.updateMailbox).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a same-accountId client from a different server', async () => {
|
||||
const activeClient = makeMockClient();
|
||||
// Same opaque JMAP account id, different server: a collision, not the owner.
|
||||
const foreignClient = makeMockClient({
|
||||
getAccountId: vi.fn().mockReturnValue('owner-x'),
|
||||
getServerUrl: vi.fn().mockReturnValue('https://other.example.test'),
|
||||
});
|
||||
useAuthStore.setState({
|
||||
getAllConnectedClients: () => new Map([['other-login', foreignClient]]),
|
||||
} as never);
|
||||
|
||||
await useEmailStore.getState().renameMailbox(activeClient, sharedFolder.id, 'Renamed Shared');
|
||||
|
||||
expect(foreignClient.updateMailbox).not.toHaveBeenCalled();
|
||||
expect(activeClient.updateMailbox).toHaveBeenCalledWith('shared-1', { name: 'Renamed Shared' }, 'owner-x');
|
||||
});
|
||||
|
||||
it('recovers the owner and bare id from a namespaced id that is no longer in the store', async () => {
|
||||
// A concurrent refresh dropped the shared account while the rename prompt
|
||||
// was open: the store id is all we have left.
|
||||
useEmailStore.setState({ mailboxes: [inbox, sent, trash, custom] });
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().renameMailbox(client, 'owner-x:shared-1', 'Renamed Shared');
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('shared-1', { name: 'Renamed Shared' }, 'owner-x');
|
||||
});
|
||||
|
||||
it('does not clear a personal role when the shared mailbox object is missing', async () => {
|
||||
const ownArchive = makeMailbox({ id: 'archive-a', name: 'Personal Archive', role: 'archive' });
|
||||
useEmailStore.setState({ mailboxes: [inbox, sent, trash, custom, ownArchive] });
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(client, 'owner-x:shared-1', 'archive');
|
||||
|
||||
expect(client.updateMailbox).not.toHaveBeenCalledWith('archive-a', { role: null }, undefined);
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('shared-1', { role: 'archive' }, 'owner-x');
|
||||
});
|
||||
|
||||
it('reorders shared folders with bare ids inside the owner account', async () => {
|
||||
const sharedSibling = makeMailbox({
|
||||
id: 'owner-x:shared-2',
|
||||
originalId: 'shared-2',
|
||||
name: 'Shared Sibling',
|
||||
accountId: 'owner-x',
|
||||
isShared: true,
|
||||
});
|
||||
useEmailStore.setState({
|
||||
mailboxes: [inbox, sent, trash, custom, sharedFolder, sharedSibling],
|
||||
});
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().reorderMailboxes(client, [sharedSibling.id, sharedFolder.id]);
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenNthCalledWith(1, 'shared-2', { sortOrder: 1 }, 'owner-x');
|
||||
expect(client.updateMailbox).toHaveBeenNthCalledWith(2, 'shared-1', { sortOrder: 2 }, 'owner-x');
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: a background fetchMailboxes (e.g. push-driven after deleting
|
||||
// drafts in "All Drafts") must not reset a virtual unified/cross-view selection
|
||||
// to the inbox, which would jump the user out of the view they're in.
|
||||
|
||||
+114
-12
@@ -257,7 +257,7 @@ interface EmailStore {
|
||||
markThreadAsRead: (client: IJMAPClient, threadId: string) => Promise<void>;
|
||||
|
||||
// Mailbox management
|
||||
createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise<void>;
|
||||
createMailbox: (client: IJMAPClient, name: string, parentId?: string, accountId?: string) => Promise<void>;
|
||||
renameMailbox: (client: IJMAPClient, mailboxId: string, name: string) => Promise<void>;
|
||||
deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
||||
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
|
||||
@@ -391,6 +391,93 @@ function resolveViewAccountId(): string | undefined {
|
||||
return mb?.isShared ? mb.accountId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a store-side mailbox id to the JMAP mutation target.
|
||||
*
|
||||
* Shared/group mailboxes use namespaced ids in the store (`accountId:id`) so
|
||||
* ids from different accounts cannot collide. JMAP `Mailbox/set`, however,
|
||||
* needs the owner's bare mailbox id plus the owner's accountId. The shared
|
||||
* account is still reached through the active/viewing login client; it does
|
||||
* not have a separately connected client of its own.
|
||||
*
|
||||
* If the owner also has a directly connected login on the same server, use that
|
||||
* client's primary account. Otherwise (the usual delegated/group case), keep
|
||||
* the active/viewing client and pass the owner's accountId explicitly.
|
||||
*/
|
||||
function resolveMailboxMutationContext(
|
||||
passedClient: IJMAPClient,
|
||||
storeMailboxId: string,
|
||||
): {
|
||||
client: IJMAPClient;
|
||||
mailboxId: string;
|
||||
accountId: string | undefined;
|
||||
ownerAccountId: string | undefined;
|
||||
} {
|
||||
const state = useEmailStore.getState();
|
||||
const actionMailboxes = resolveActionMailboxes();
|
||||
const mailbox = actionMailboxes.find((mb) => mb.id === storeMailboxId)
|
||||
?? state.mailboxes.find((mb) => mb.id === storeMailboxId)
|
||||
?? Object.values(state.accountMailboxes).flat().find((mb) => mb.id === storeMailboxId);
|
||||
|
||||
// No live mailbox object (a concurrent refresh dropped the shared account
|
||||
// while a rename/create prompt was open, or a caller passed a stale id):
|
||||
// recover the owner and the bare id from the namespaced store id itself.
|
||||
// Without this the raw `accountId:id` would be sent as a mailbox id AND the
|
||||
// owner would be lost, so the mutation - including the role-clearing sweep
|
||||
// in setMailboxRole - would be applied to the active personal account.
|
||||
const parsed = mailbox ? undefined : parseNamespacedMailboxId(storeMailboxId);
|
||||
|
||||
const ownerAccountId = mailbox?.accountId ?? parsed?.accountId;
|
||||
const accountTarget = resolveMailboxAccountTarget(passedClient, ownerAccountId);
|
||||
|
||||
return {
|
||||
...accountTarget,
|
||||
mailboxId: mailbox?.originalId || parsed?.mailboxId || storeMailboxId,
|
||||
ownerAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a namespaced shared-mailbox store id (`${accountId}:${mailboxId}`)
|
||||
* back into its parts. Bare (personal) ids carry no separator and yield
|
||||
* `undefined`, which keeps them on the personal path.
|
||||
*/
|
||||
function parseNamespacedMailboxId(
|
||||
storeMailboxId: string,
|
||||
): { accountId: string; mailboxId: string } | undefined {
|
||||
const separator = storeMailboxId.indexOf(':');
|
||||
if (separator <= 0 || separator === storeMailboxId.length - 1) return undefined;
|
||||
return {
|
||||
accountId: storeMailboxId.slice(0, separator),
|
||||
mailboxId: storeMailboxId.slice(separator + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMailboxAccountTarget(
|
||||
passedClient: IJMAPClient,
|
||||
ownerAccountId?: string,
|
||||
): { client: IJMAPClient; accountId: string | undefined } {
|
||||
const client = resolveActionClient(passedClient);
|
||||
if (!ownerAccountId || client.getAccountId() === ownerAccountId) {
|
||||
return { client, accountId: undefined };
|
||||
}
|
||||
|
||||
// A directly connected owner login can act on its own primary account. Match
|
||||
// it on server URL as well as account id: JMAP account ids are opaque
|
||||
// per-server values, so two logins on different servers can share one id and
|
||||
// matching on the id alone would route the mutation to a foreign server -
|
||||
// where that bare mailbox id may well exist and belong to someone else.
|
||||
const serverUrl = client.getServerUrl();
|
||||
for (const candidate of useAuthStore.getState().getAllConnectedClients().values()) {
|
||||
if (candidate.getAccountId() === ownerAccountId && candidate.getServerUrl() === serverUrl) {
|
||||
return { client: candidate, accountId: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
// The usual delegated/group case: reach the owner through the active client.
|
||||
return { client, accountId: ownerAccountId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the store-side mailbox ids that make up a personal account's
|
||||
* contribution to the unified cross views (All mail / Unread / Starred), honoring
|
||||
@@ -3572,9 +3659,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
|
||||
// Mailbox management
|
||||
createMailbox: async (client, name, parentId) => {
|
||||
createMailbox: async (client, name, parentId, accountId) => {
|
||||
try {
|
||||
await resolveActionClient(client).createMailbox(name, parentId);
|
||||
// Root creation is personal unless its UI caller explicitly supplies a
|
||||
// shared/group account (the account header's context menu). Never infer
|
||||
// a root target merely from whichever mailbox happens to be selected.
|
||||
const target = parentId
|
||||
? resolveMailboxMutationContext(client, parentId)
|
||||
: { ...resolveMailboxAccountTarget(client, accountId), mailboxId: undefined };
|
||||
await target.client.createMailbox(name, target.mailboxId, target.accountId);
|
||||
if (get().viewingAccountId) {
|
||||
await refreshMailboxesForViewingAccount(client);
|
||||
} else {
|
||||
@@ -3588,7 +3681,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
renameMailbox: async (client, mailboxId, name) => {
|
||||
try {
|
||||
await resolveActionClient(client).updateMailbox(mailboxId, { name });
|
||||
const target = resolveMailboxMutationContext(client, mailboxId);
|
||||
await target.client.updateMailbox(target.mailboxId, { name }, target.accountId);
|
||||
const viewingId = get().viewingAccountId;
|
||||
if (viewingId) {
|
||||
set((state) => ({
|
||||
@@ -3614,7 +3708,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
deleteMailbox: async (client, mailboxId) => {
|
||||
try {
|
||||
await resolveActionClient(client).deleteMailbox(mailboxId);
|
||||
const target = resolveMailboxMutationContext(client, mailboxId);
|
||||
await target.client.deleteMailbox(target.mailboxId, target.accountId);
|
||||
const { selectedMailbox, viewingAccountId: viewingId } = get();
|
||||
if (viewingId) {
|
||||
const updatedList = (get().accountMailboxes[viewingId] ?? []).filter(mb => mb.id !== mailboxId);
|
||||
@@ -3643,15 +3738,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
setMailboxRole: async (client, mailboxId, role) => {
|
||||
try {
|
||||
const effectiveClient = resolveActionClient(client);
|
||||
// If assigning a role, first clear that role from ALL other mailboxes that have it
|
||||
const target = resolveMailboxMutationContext(client, mailboxId);
|
||||
// If assigning a role, first clear that role from every other mailbox in
|
||||
// the SAME owning account. Never clear the active user's role while
|
||||
// changing a shared/group mailbox (or vice versa).
|
||||
if (role) {
|
||||
const existingMailboxes = resolveActionMailboxes().filter(mb => mb.role === role && !mb.isShared && mb.id !== mailboxId);
|
||||
const existingMailboxes = resolveActionMailboxes().filter((mb) =>
|
||||
mb.role === role
|
||||
&& mb.id !== mailboxId
|
||||
&& (target.ownerAccountId ? mb.accountId === target.ownerAccountId : !mb.isShared)
|
||||
);
|
||||
for (const existing of existingMailboxes) {
|
||||
await effectiveClient.updateMailbox(existing.id, { role: null });
|
||||
const existingTarget = resolveMailboxMutationContext(client, existing.id);
|
||||
await existingTarget.client.updateMailbox(existingTarget.mailboxId, { role: null }, existingTarget.accountId);
|
||||
}
|
||||
}
|
||||
await effectiveClient.updateMailbox(mailboxId, { role });
|
||||
await target.client.updateMailbox(target.mailboxId, { role }, target.accountId);
|
||||
if (get().viewingAccountId) {
|
||||
await refreshMailboxesForViewingAccount(client);
|
||||
} else {
|
||||
@@ -3686,9 +3788,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
set({ mailboxes: applyLocal(get().mailboxes) });
|
||||
}
|
||||
try {
|
||||
const effectiveClient = resolveActionClient(client);
|
||||
for (const u of updates) {
|
||||
await effectiveClient.updateMailbox(u.id, { sortOrder: u.sortOrder });
|
||||
const target = resolveMailboxMutationContext(client, u.id);
|
||||
await target.client.updateMailbox(target.mailboxId, { sortOrder: u.sortOrder }, target.accountId);
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to reorder folders' });
|
||||
|
||||
Reference in New Issue
Block a user