feat: folder management settings with CRUD and standard role assignment
This commit is contained in:
@@ -14,10 +14,11 @@ import { CalendarSettings } from '@/components/settings/calendar-settings';
|
||||
import { FilterSettings } from '@/components/settings/filter-settings';
|
||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
import { FolderSettings } from '@/components/settings/folder-settings';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'advanced';
|
||||
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'advanced';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
@@ -38,6 +39,7 @@ export default function SettingsPage() {
|
||||
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar') }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters') }] : []),
|
||||
{ id: 'templates', label: t('tabs.templates') },
|
||||
{ id: 'folders', label: t('tabs.folders') },
|
||||
{ id: 'advanced', label: t('tabs.advanced') },
|
||||
];
|
||||
|
||||
@@ -100,6 +102,7 @@ export default function SettingsPage() {
|
||||
{activeTab === 'calendar' && <CalendarSettings />}
|
||||
{activeTab === 'filters' && <FilterSettings />}
|
||||
{activeTab === 'templates' && <TemplateSettings />}
|
||||
{activeTab === 'folders' && <FolderSettings />}
|
||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { SettingsSection, SettingItem, Select } from './settings-section';
|
||||
import { Plus, Pencil, Trash2, Check, X, FolderPlus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const;
|
||||
|
||||
export function FolderSettings() {
|
||||
const t = useTranslations('settings.folders');
|
||||
const { client } = useAuthStore();
|
||||
const { mailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Only show own (non-shared) mailboxes
|
||||
const ownMailboxes = mailboxes.filter(mb => !mb.isShared);
|
||||
|
||||
const getRoleMailboxId = (role: string): string => {
|
||||
const mb = ownMailboxes.find(m => m.role === role);
|
||||
return mb?.id ?? '';
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!client || !newFolderName.trim()) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await createMailbox(client, newFolderName.trim());
|
||||
setNewFolderName('');
|
||||
setIsCreating(false);
|
||||
} catch {
|
||||
// error is set in the store
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (mailboxId: string) => {
|
||||
if (!client || !editingName.trim()) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await renameMailbox(client, mailboxId, editingName.trim());
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
} catch {
|
||||
// error is set in the store
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (mailboxId: string) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await deleteMailbox(client, mailboxId);
|
||||
setDeletingId(null);
|
||||
} catch {
|
||||
// error is set in the store
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (role: string, mailboxId: string) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (mailboxId === '') {
|
||||
// Clear the role from whatever mailbox currently has it
|
||||
const current = ownMailboxes.find(m => m.role === role);
|
||||
if (current) {
|
||||
await setMailboxRole(client, current.id, null);
|
||||
}
|
||||
} else {
|
||||
await setMailboxRole(client, mailboxId, role);
|
||||
}
|
||||
} catch {
|
||||
// error is set in the store
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (mb: { id: string; name: string }) => {
|
||||
setEditingId(mb.id);
|
||||
setEditingName(mb.name);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Standard Folder Roles */}
|
||||
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
|
||||
{STANDARD_ROLES.map((role) => (
|
||||
<SettingItem key={role} label={t(`role_${role}`)}>
|
||||
<Select
|
||||
value={getRoleMailboxId(role)}
|
||||
onChange={(value) => handleRoleChange(role, value)}
|
||||
options={[
|
||||
{ value: '', label: t('role_none') },
|
||||
...ownMailboxes.map(mb => ({
|
||||
value: mb.id,
|
||||
label: mb.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
{/* Folder List */}
|
||||
<SettingsSection title={t('folder_list')}>
|
||||
<div className="space-y-1">
|
||||
{ownMailboxes.map((mb) => (
|
||||
<div
|
||||
key={mb.id}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50 group"
|
||||
>
|
||||
{editingId === mb.id ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename(mb.id);
|
||||
if (e.key === 'Escape') cancelEdit();
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRename(mb.id)}
|
||||
disabled={isLoading || !editingName.trim()}
|
||||
className="p-1 text-primary hover:bg-accent rounded disabled:opacity-50"
|
||||
title={t('rename')}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1 text-muted-foreground hover:bg-accent rounded"
|
||||
title={t('cancel')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : deletingId === mb.id ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<p className="text-sm text-destructive flex-1">
|
||||
{t('confirm_delete', { name: mb.name })}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleDelete(mb.id)}
|
||||
disabled={isLoading}
|
||||
className="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded hover:bg-destructive/90 disabled:opacity-50"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingId(null)}
|
||||
className="px-2 py-1 text-xs bg-muted text-foreground rounded hover:bg-accent"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground">{mb.name}</span>
|
||||
{mb.role && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{mb.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
)}>
|
||||
{mb.myRights?.mayRename && (
|
||||
<button
|
||||
onClick={() => startEdit(mb)}
|
||||
className="p-1 text-muted-foreground hover:text-foreground hover:bg-accent rounded"
|
||||
title={t('rename')}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{mb.myRights?.mayDelete && !mb.role && (
|
||||
<button
|
||||
onClick={() => setDeletingId(mb.id)}
|
||||
className="p-1 text-muted-foreground hover:text-destructive hover:bg-accent rounded"
|
||||
title={t('delete')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create folder */}
|
||||
{isCreating ? (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<FolderPlus className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (e.key === 'Escape') {
|
||||
setIsCreating(false);
|
||||
setNewFolderName('');
|
||||
}
|
||||
}}
|
||||
placeholder={t('new_folder_name')}
|
||||
className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !newFolderName.trim()}
|
||||
className="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{t('create')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsCreating(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
className="px-3 py-1 text-xs bg-muted text-foreground rounded hover:bg-accent"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="flex items-center gap-2 mt-3 px-3 py-2 text-sm text-primary hover:bg-accent rounded-md transition-colors w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('create_folder')}
|
||||
</button>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -746,6 +746,75 @@ export class JMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
|
||||
const createId = `new-${Date.now()}`;
|
||||
const createData: Record<string, unknown> = { name };
|
||||
if (parentId) {
|
||||
createData.parentId = parentId;
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["Mailbox/set", {
|
||||
accountId: this.accountId,
|
||||
create: { [createId]: createData },
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notCreated?.[createId]) {
|
||||
throw new Error(`Failed to create mailbox: ${result.notCreated[createId].type || 'unknown error'}`);
|
||||
}
|
||||
|
||||
const created = result?.created?.[createId];
|
||||
if (!created?.id) {
|
||||
throw new Error('Failed to create mailbox: no ID returned');
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
name,
|
||||
parentId,
|
||||
sortOrder: 0,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
myRights: DEFAULT_MAILBOX_RIGHTS,
|
||||
isSubscribed: true,
|
||||
accountId: this.accountId,
|
||||
accountName: this.accounts[this.accountId]?.name || this.username,
|
||||
isShared: false,
|
||||
};
|
||||
}
|
||||
|
||||
async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void> {
|
||||
const response = await this.request([
|
||||
["Mailbox/set", {
|
||||
accountId: this.accountId,
|
||||
update: { [mailboxId]: changes },
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notUpdated?.[mailboxId]) {
|
||||
throw new Error(`Failed to update mailbox: ${result.notUpdated[mailboxId].type || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMailbox(mailboxId: string): Promise<void> {
|
||||
const response = await this.request([
|
||||
["Mailbox/set", {
|
||||
accountId: this.accountId,
|
||||
destroy: [mailboxId],
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notDestroyed?.[mailboxId]) {
|
||||
throw new Error(`Failed to delete mailbox: ${result.notDestroyed[mailboxId].type || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||
try {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
+33
-1
@@ -454,7 +454,8 @@
|
||||
"advanced": "Advanced",
|
||||
"calendar": "Calendar",
|
||||
"filters": "Filters",
|
||||
"templates": "Templates"
|
||||
"templates": "Templates",
|
||||
"folders": "Folders"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
@@ -699,6 +700,37 @@
|
||||
"empty_body": "Message body is empty — recipients will receive a blank reply"
|
||||
}
|
||||
},
|
||||
"folders": {
|
||||
"title": "Folders",
|
||||
"description": "Manage your email folders and assign standard roles",
|
||||
"folder_list": "Your Folders",
|
||||
"standard_roles": "Standard Folder Roles",
|
||||
"standard_roles_description": "Assign which folders are used for standard mailbox roles like Inbox, Sent, Trash, etc.",
|
||||
"role_inbox": "Inbox",
|
||||
"role_drafts": "Drafts",
|
||||
"role_sent": "Sent",
|
||||
"role_trash": "Trash",
|
||||
"role_junk": "Spam / Junk",
|
||||
"role_archive": "Archive",
|
||||
"role_none": "None",
|
||||
"create_folder": "Create Folder",
|
||||
"new_folder_name": "Folder name",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"confirm_delete": "Are you sure you want to delete \"{name}\"? Emails in this folder will be moved to Trash.",
|
||||
"create": "Create",
|
||||
"cancel": "Cancel",
|
||||
"no_folders": "No custom folders",
|
||||
"cannot_delete_role": "Cannot delete a folder with a standard role. Remove the role first.",
|
||||
"folder_created": "Folder created",
|
||||
"folder_renamed": "Folder renamed",
|
||||
"folder_deleted": "Folder deleted",
|
||||
"role_updated": "Folder role updated",
|
||||
"error_create": "Failed to create folder",
|
||||
"error_rename": "Failed to rename folder",
|
||||
"error_delete": "Failed to delete folder",
|
||||
"error_role": "Failed to update folder role"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Advanced options and developer settings",
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
function makeMailbox(overrides: Partial<Mailbox> = {}): Mailbox {
|
||||
return {
|
||||
id: overrides.id ?? 'mb-1',
|
||||
name: overrides.name ?? 'Test Folder',
|
||||
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 makeMockClient(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
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;
|
||||
}
|
||||
|
||||
describe('email-store folder management', () => {
|
||||
const inbox = makeMailbox({ id: 'inbox-1', name: 'Inbox', role: 'inbox' });
|
||||
const sent = makeMailbox({ id: 'sent-1', name: 'Sent', role: 'sent' });
|
||||
const trash = makeMailbox({ id: 'trash-1', name: 'Trash', role: 'trash' });
|
||||
const custom = makeMailbox({ id: 'custom-1', name: 'My Folder' });
|
||||
|
||||
beforeEach(() => {
|
||||
useEmailStore.setState({
|
||||
mailboxes: [inbox, sent, trash, custom],
|
||||
selectedMailbox: 'inbox-1',
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMailbox', () => {
|
||||
it('should call client.createMailbox and refresh mailboxes', async () => {
|
||||
const newMailboxes = [
|
||||
...useEmailStore.getState().mailboxes,
|
||||
makeMailbox({ id: 'mb-new', name: 'New Folder' }),
|
||||
];
|
||||
const client = makeMockClient({
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(newMailboxes),
|
||||
});
|
||||
|
||||
await useEmailStore.getState().createMailbox(client, 'New Folder');
|
||||
|
||||
expect(client.createMailbox).toHaveBeenCalledWith('New Folder', undefined);
|
||||
expect(client.getAllMailboxes).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call client.createMailbox with parentId', async () => {
|
||||
const client = makeMockClient({
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(useEmailStore.getState().mailboxes),
|
||||
});
|
||||
|
||||
await useEmailStore.getState().createMailbox(client, 'Sub Folder', 'inbox-1');
|
||||
|
||||
expect(client.createMailbox).toHaveBeenCalledWith('Sub Folder', 'inbox-1');
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
const client = makeMockClient({
|
||||
createMailbox: vi.fn().mockRejectedValue(new Error('Server error')),
|
||||
});
|
||||
|
||||
await expect(
|
||||
useEmailStore.getState().createMailbox(client, 'Fail')
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(useEmailStore.getState().error).toBe('Server error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameMailbox', () => {
|
||||
it('should update mailbox name locally', async () => {
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().renameMailbox(client, 'custom-1', 'Renamed');
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { name: 'Renamed' });
|
||||
const mb = useEmailStore.getState().mailboxes.find(m => m.id === 'custom-1');
|
||||
expect(mb?.name).toBe('Renamed');
|
||||
});
|
||||
|
||||
it('should not change other mailboxes', async () => {
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().renameMailbox(client, 'custom-1', 'Renamed');
|
||||
|
||||
const inboxMb = useEmailStore.getState().mailboxes.find(m => m.id === 'inbox-1');
|
||||
expect(inboxMb?.name).toBe('Inbox');
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
const client = makeMockClient({
|
||||
updateMailbox: vi.fn().mockRejectedValue(new Error('Rename failed')),
|
||||
});
|
||||
|
||||
await expect(
|
||||
useEmailStore.getState().renameMailbox(client, 'custom-1', 'Fail')
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(useEmailStore.getState().error).toBe('Rename failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMailbox', () => {
|
||||
it('should remove mailbox from state', async () => {
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().deleteMailbox(client, 'custom-1');
|
||||
|
||||
expect(client.deleteMailbox).toHaveBeenCalledWith('custom-1');
|
||||
const mb = useEmailStore.getState().mailboxes.find(m => m.id === 'custom-1');
|
||||
expect(mb).toBeUndefined();
|
||||
expect(useEmailStore.getState().mailboxes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should switch to inbox when deleting selected mailbox', async () => {
|
||||
useEmailStore.setState({ selectedMailbox: 'custom-1' });
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().deleteMailbox(client, 'custom-1');
|
||||
|
||||
expect(useEmailStore.getState().selectedMailbox).toBe('inbox-1');
|
||||
});
|
||||
|
||||
it('should keep current selection when deleting non-selected mailbox', async () => {
|
||||
const client = makeMockClient();
|
||||
|
||||
await useEmailStore.getState().deleteMailbox(client, 'custom-1');
|
||||
|
||||
expect(useEmailStore.getState().selectedMailbox).toBe('inbox-1');
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
const client = makeMockClient({
|
||||
deleteMailbox: vi.fn().mockRejectedValue(new Error('Delete failed')),
|
||||
});
|
||||
|
||||
await expect(
|
||||
useEmailStore.getState().deleteMailbox(client, 'custom-1')
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(useEmailStore.getState().error).toBe('Delete failed');
|
||||
// Mailbox should still exist
|
||||
expect(useEmailStore.getState().mailboxes).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMailboxRole', () => {
|
||||
it('should assign a role to a mailbox', async () => {
|
||||
const newMailboxes = useEmailStore.getState().mailboxes.map(mb =>
|
||||
mb.id === 'custom-1' ? { ...mb, role: 'archive' } : mb
|
||||
);
|
||||
const client = makeMockClient({
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(newMailboxes),
|
||||
});
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(client, 'custom-1', 'archive');
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { role: 'archive' });
|
||||
});
|
||||
|
||||
it('should clear existing role from another mailbox when reassigning', async () => {
|
||||
const newMailboxes = useEmailStore.getState().mailboxes.map(mb => {
|
||||
if (mb.id === 'custom-1') return { ...mb, role: 'trash' };
|
||||
if (mb.id === 'trash-1') return { ...mb, role: undefined };
|
||||
return mb;
|
||||
});
|
||||
const client = makeMockClient({
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(newMailboxes),
|
||||
});
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(client, 'custom-1', 'trash');
|
||||
|
||||
// Should first clear trash role from trash-1
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: null });
|
||||
// Then set trash role on custom-1
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('custom-1', { role: 'trash' });
|
||||
});
|
||||
|
||||
it('should clear role from a mailbox when role is null', async () => {
|
||||
const newMailboxes = useEmailStore.getState().mailboxes.map(mb =>
|
||||
mb.id === 'trash-1' ? { ...mb, role: undefined } : mb
|
||||
);
|
||||
const client = makeMockClient({
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(newMailboxes),
|
||||
});
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(client, 'trash-1', null);
|
||||
|
||||
expect(client.updateMailbox).toHaveBeenCalledWith('trash-1', { role: null });
|
||||
});
|
||||
|
||||
it('should not clear role from same mailbox when re-assigning same role', async () => {
|
||||
const client = makeMockClient({
|
||||
getAllMailboxes: vi.fn().mockResolvedValue(useEmailStore.getState().mailboxes),
|
||||
});
|
||||
|
||||
await useEmailStore.getState().setMailboxRole(client, 'trash-1', 'trash');
|
||||
|
||||
// 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' });
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
const client = makeMockClient({
|
||||
updateMailbox: vi.fn().mockRejectedValue(new Error('Role update failed')),
|
||||
});
|
||||
|
||||
await expect(
|
||||
useEmailStore.getState().setMailboxRole(client, 'custom-1', 'archive')
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(useEmailStore.getState().error).toBe('Role update failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,12 @@ interface EmailStore {
|
||||
collapseAllThreads: () => void;
|
||||
updateThreadCache: (threadId: string, emails: Email[]) => void;
|
||||
|
||||
// Mailbox management
|
||||
createMailbox: (client: JMAPClient, name: string, parentId?: string) => Promise<void>;
|
||||
renameMailbox: (client: JMAPClient, mailboxId: string, name: string) => Promise<void>;
|
||||
deleteMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
|
||||
setMailboxRole: (client: JMAPClient, mailboxId: string, role: string | null) => Promise<void>;
|
||||
|
||||
// Mock data for demo
|
||||
loadMockData: () => void;
|
||||
}
|
||||
@@ -1176,6 +1182,68 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
set({ threadEmailsCache: newCache });
|
||||
},
|
||||
|
||||
// Mailbox management
|
||||
createMailbox: async (client, name, parentId) => {
|
||||
try {
|
||||
await client.createMailbox(name, parentId);
|
||||
await get().fetchMailboxes(client);
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to create folder' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
renameMailbox: async (client, mailboxId, name) => {
|
||||
try {
|
||||
await client.updateMailbox(mailboxId, { name });
|
||||
set({
|
||||
mailboxes: get().mailboxes.map(mb =>
|
||||
mb.id === mailboxId ? { ...mb, name } : mb
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to rename folder' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteMailbox: async (client, mailboxId) => {
|
||||
try {
|
||||
await client.deleteMailbox(mailboxId);
|
||||
const { mailboxes, selectedMailbox } = get();
|
||||
const newMailboxes = mailboxes.filter(mb => mb.id !== mailboxId);
|
||||
const updates: Partial<EmailStore> = { mailboxes: newMailboxes };
|
||||
// If the deleted mailbox was selected, switch to inbox
|
||||
if (selectedMailbox === mailboxId) {
|
||||
const inbox = newMailboxes.find(mb => mb.role === 'inbox' && !mb.isShared);
|
||||
if (inbox) {
|
||||
updates.selectedMailbox = inbox.id;
|
||||
}
|
||||
}
|
||||
set(updates as EmailStore);
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to delete folder' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setMailboxRole: async (client, mailboxId, role) => {
|
||||
try {
|
||||
// If assigning a role, first clear that role from any other mailbox
|
||||
if (role) {
|
||||
const existingMailbox = get().mailboxes.find(mb => mb.role === role && !mb.isShared);
|
||||
if (existingMailbox && existingMailbox.id !== mailboxId) {
|
||||
await client.updateMailbox(existingMailbox.id, { role: null });
|
||||
}
|
||||
}
|
||||
await client.updateMailbox(mailboxId, { role });
|
||||
await get().fetchMailboxes(client);
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to update folder role' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadMockData: () => {
|
||||
const mockEmails: Email[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user