diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index b4ed551e..0cf5424b 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -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' && } {activeTab === 'filters' && } {activeTab === 'templates' && } + {activeTab === 'folders' && } {activeTab === 'advanced' && } diff --git a/components/settings/folder-settings.tsx b/components/settings/folder-settings.tsx new file mode 100644 index 00000000..eec61e26 --- /dev/null +++ b/components/settings/folder-settings.tsx @@ -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(null); + const [editingName, setEditingName] = useState(''); + const [deletingId, setDeletingId] = useState(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 ( +
+ {/* Standard Folder Roles */} + + {STANDARD_ROLES.map((role) => ( + + 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} + /> + + +
+ ) : deletingId === mb.id ? ( +
+

+ {t('confirm_delete', { name: mb.name })} +

+ + +
+ ) : ( + <> +
+ {mb.name} + {mb.role && ( + + {mb.role} + + )} +
+
+ {mb.myRights?.mayRename && ( + + )} + {mb.myRights?.mayDelete && !mb.role && ( + + )} +
+ + )} + + ))} + + + {/* Create folder */} + {isCreating ? ( +
+ + 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} + /> + + +
+ ) : ( + + )} + + + ); +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index f96e9761..ee589d72 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -746,6 +746,75 @@ export class JMAPClient { ]); } + async createMailbox(name: string, parentId?: string): Promise { + const createId = `new-${Date.now()}`; + const createData: Record = { 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 { + 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 { + 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; diff --git a/locales/en/common.json b/locales/en/common.json index 3a9f5926..77eff90b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/stores/__tests__/folder-management.test.ts b/stores/__tests__/folder-management.test.ts new file mode 100644 index 00000000..4c030f40 --- /dev/null +++ b/stores/__tests__/folder-management.test.ts @@ -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 { + 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 = {}) { + 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'); + }); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index bc0fc03c..0e9cd0cb 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -91,6 +91,12 @@ interface EmailStore { collapseAllThreads: () => void; updateThreadCache: (threadId: string, emails: Email[]) => void; + // Mailbox management + createMailbox: (client: JMAPClient, name: string, parentId?: string) => Promise; + renameMailbox: (client: JMAPClient, mailboxId: string, name: string) => Promise; + deleteMailbox: (client: JMAPClient, mailboxId: string) => Promise; + setMailboxRole: (client: JMAPClient, mailboxId: string, role: string | null) => Promise; + // Mock data for demo loadMockData: () => void; } @@ -1176,6 +1182,68 @@ export const useEmailStore = create((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 = { 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[] = [ {