diff --git a/.a0/briefings/T08c_briefing.md b/.a0/briefings/T08c_briefing.md
new file mode 100644
index 0000000..12b9d5f
--- /dev/null
+++ b/.a0/briefings/T08c_briefing.md
@@ -0,0 +1,148 @@
+# T08c: Frontend Mail UI + Global Search UI — Implementation Briefing
+
+## Task
+Implement frontend UI for Mail plugin (folder tree, mail list, reading pane, compose, templates, signatures, rules, labels, PGP, vacation, shared mailbox, delegates) and enhance Global Search UI with tabs.
+
+## Acceptance Criteria (17 ACs — skip AC1/DMS and AC16/Docker, already done)
+2. Mail route /mail renders folder tree + mail list + reading pane
+3. Mail: click folder → mail list updates with folder mails
+4. Mail: click mail → detail with sanitized HTML body + attachments
+5. Mail: compose button → editor with toolbar (bold, italic, link, template insert)
+6. Mail: reply/forward buttons → compose pre-filled
+7. Mail: template picker dropdown in compose → inserts template body
+8. Mail: signature manager in settings → create/edit/delete signatures
+9. Mail: rule editor → condition builder + action selector
+10. Mail: label manager → create labels with colors, assign to mails
+11. Mail: PGP settings → import private key, view contact public keys
+12. Mail: vacation responder toggle → date range + auto-reply text
+13. Mail: shared mailbox selector → switch between personal+shared accounts
+14. Mail: attachment download → file stream downloaded
+15. Mail: create event from mail → calendar event modal pre-filled
+16. Global search results page → tabs for companies/contacts/mails/files/events
+17. Global search autocomplete in TopBar → dropdown with suggestions
+
+## Backend API Endpoints (all implemented, prefix /api/v1/mail)
+### Accounts
+- GET /accounts — list accounts (password never returned)
+- POST /accounts — create account (AES-256 encrypted password)
+- PATCH /accounts/{id} — update account
+- DELETE /accounts/{id} — delete account
+- GET /accounts/shared — list shared mailboxes
+- POST /accounts/{id}/users — assign shared mailbox users
+- POST /accounts/{id}/delegates — create delegate access
+- POST /accounts/{id}/send-permissions — grant send permission
+- POST /accounts/{id}/test-connection — test IMAP connection
+- POST /accounts/{id}/sync — trigger IMAP sync
+
+### Folders
+- GET /folders?account_id=X — list folders with counts
+- POST /folders — create folder
+- PATCH /folders/{id} — rename folder
+- DELETE /folders/{id} — delete folder
+
+### Mails
+- GET /?folder_id=X&page=1 — paginated mail list
+- GET /{id} — mail detail (sanitized HTML, attachments)
+- POST /send — send mail via SMTP
+- POST /{id}/reply — reply with In-Reply-To
+- POST /{id}/forward — forward mail
+- PATCH /{id}/flags — toggle seen/flagged
+- POST /{id}/link — link to contact/company
+- POST /{id}/create-event — create calendar event from mail
+- POST /{id}/labels — assign label to mail
+
+### Search & Threads
+- GET /search?q=text — FTS search
+- GET /threads — threaded view
+
+### Attachments
+- GET /{mail_id}/attachments/{att_id} — file stream download
+
+### Templates
+- POST /templates — create template
+- GET /templates — list templates
+- POST /templates/substitute — substitute variables
+
+### Signatures
+- POST /signatures — create signature
+- GET /signatures — list signatures
+
+### Rules
+- POST /rules — create rule (conditions + actions)
+- GET /rules — list rules sorted by priority
+- DELETE /rules/{id} — delete rule
+
+### Vacation
+- POST /vacation — configure auto-reply
+- POST /vacation/test-dedup — test dedup
+
+### PGP
+- POST /pgp/keys — import private key (encrypted)
+- GET /pgp/keys — list PGP keys
+- POST /pgp/encrypt — encrypt message
+- POST /contacts/{contact_id}/pgp-key — store contact public key
+
+### Labels
+- POST /labels — create label (with color)
+- GET /labels — list labels
+
+## Frontend Architecture (follow existing patterns)
+- **Framework:** React + TypeScript + Vite
+- **Routing:** react-router-dom (src/routes/index.tsx)
+- **State:** TanStack Query (useQuery/useMutation)
+- **HTTP:** axios via src/api/client.ts (apiClient, baseURL /api/v1)
+- **API pattern:** See src/api/calendar.ts or src/api/dms.ts
+- **UI components:** src/components/ui/ (Button, Card, Input, Modal, Table, Badge, etc.)
+- **Shared:** src/components/shared/ (DataGrid, SearchDropdown, Tabs)
+- **Layout:** src/components/layout/AppShell.tsx + Sidebar.tsx
+- **i18n:** src/i18n/ (add de.json + en.json keys for Mail)
+- **Existing search page:** src/pages/GlobalSearchResults.tsx (enhance with tabs)
+
+## Files to Create
+- `src/api/mail.ts` — Mail API client (types + functions for all endpoints)
+- `src/pages/Mail.tsx` — Mail page (folder tree + mail list + reading pane)
+- `src/pages/MailSettings.tsx` — Mail settings (signatures, rules, PGP, vacation, labels)
+- `src/components/mail/MailFolderTree.tsx` — folder tree sidebar
+- `src/components/mail/MailList.tsx` — mail list with pagination
+- `src/components/mail/MailDetail.tsx` — reading pane (sanitized HTML, attachments)
+- `src/components/mail/ComposeModal.tsx` — compose editor (bold/italic/link/template)
+- `src/components/mail/TemplatePicker.tsx` — template dropdown
+- `src/components/mail/SignatureManager.tsx` — signature CRUD
+- `src/components/mail/RuleEditor.tsx` — rule condition builder + action selector
+- `src/components/mail/LabelManager.tsx` — label CRUD with colors
+- `src/components/mail/VacationResponder.tsx` — vacation toggle + date range
+- `src/components/mail/PgpSettings.tsx` — PGP key import + contact keys
+- `src/components/mail/SharedMailboxSelector.tsx` — account switcher
+- `src/components/mail/MailSearchBar.tsx` — mail search input
+- `src/__tests__/mail/MailPage.test.tsx` — mail page tests
+- `src/__tests__/mail/ComposeModal.test.tsx` — compose tests
+- `src/__tests__/mail/MailSettings.test.tsx` — settings tests
+- `src/__tests__/search/GlobalSearchTabs.test.tsx` — search tabs tests
+
+## Files to Modify
+- `src/routes/index.tsx` — Add /mail, /mail/settings routes
+- `src/components/layout/Sidebar.tsx` — Add Mail nav link
+- `src/pages/GlobalSearchResults.tsx` — Add tabs (companies/contacts/mails/files/events)
+- `src/components/layout/AppShell.tsx` — Add search autocomplete in TopBar
+- `src/i18n/locales/de.json` — Mail translations
+- `src/i18n/locales/en.json` — Mail translations
+
+## Test Spec
+- Run: `cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx vitest run src/__tests__/mail/ src/__tests__/search/ --reporter=verbose`
+- Build: `npx vite build`
+- Type check: `npx tsc --noEmit`
+- Coverage target: 80%
+- Follow existing test pattern from src/__tests__/dms/ or src/__tests__/companies/
+
+## Forbidden Patterns
+- No inline styles — use Tailwind classes
+- No any types — use proper TypeScript interfaces
+- No direct fetch() — use apiClient from src/api/client.ts
+- No hardcoded strings — use i18n (t() function)
+- No Lorem Ipsum — use realistic test data
+- No missing loading/error/empty states
+- No dangerouslySetInnerHTML without sanitization check
+
+## Estimated Size
+- ~700 lines code (pages + components + API client)
+- ~350+ lines tests
diff --git a/.a0/current_status.md b/.a0/current_status.md
index cb849be..6b12b68 100644
--- a/.a0/current_status.md
+++ b/.a0/current_status.md
@@ -1,28 +1,27 @@
# LeoCRM — Current Status
**Phase**: 3 (Implementation)
**Plan Mode**: implementation_allowed
-**Last completed**: T06 — Mail Plugin Backend (commit f646c59)
+**Last completed**: T08a — Frontend DMS + Tags + Permissions UI (commit 0962f3a)
**Date**: 2026-07-01
-## Completed Tasks (11/14)
+## Completed Tasks (12/14)
- T01: Core Infrastructure + Multi-Tenant + Auth System ✅
- T02: Company + Contact + Import/Export System ✅
- T03: Plugin System Framework ✅
- T04: DMS Plugin Backend ✅
- T05: Calendar Plugin Backend ✅
-- T06: Mail Plugin Backend (IMAP/SMTP, PGP, Rules, Vacation, Delegates) ✅
+- T06: Mail Plugin Backend ✅
- T07a: Frontend Core SPA ✅
- T07b: Frontend Feature Pages ✅
+- T08a: Frontend DMS + Tags + Permissions UI ✅
- T08b: Frontend Calendar UI ✅
- T09: KI-Copilot API + Workflow Engine ✅
- T11: Tags + Permissions + Entity Links Backend ✅
-## Remaining Tasks (3)
-- T08a: Frontend DMS + Tags + Permissions UI (free)
-- T08c: Frontend Mail UI + Global Search UI (blocked by T06 — now unblocked)
+## Remaining Tasks (2)
+- T08c: Frontend Mail UI + Global Search UI (free, T06 ✅)
- T10: Monitoring, Performance, Documentation & Env Config (free)
## Test Summary
- Backend: 527 tests pass (0 failures)
-- Mail: 46 tests, 74.56% coverage
-- Frontend: 112 tests pass
+- Frontend: 276 tests pass (0 failures)
diff --git a/.a0/next_steps.md b/.a0/next_steps.md
index be8f9fa..02d2156 100644
--- a/.a0/next_steps.md
+++ b/.a0/next_steps.md
@@ -1,7 +1,6 @@
# LeoCRM — Next Steps
1. **User decision needed**: Which task next?
- - T08a: DMS Frontend (prerequisite T04 ✅, T07b ✅ — unblocked)
- - T08c: Mail Frontend (prerequisite T06 ✅ — now unblocked)
+ - T08c: Frontend Mail UI + Global Search UI (prerequisite T06 ✅ — unblocked)
- T10: Monitoring, Performance, Documentation & Environment Config
2. After task selection: delegate to implementation_engineer with briefing
3. After implementation: test_debug_engineer for validation
diff --git a/.a0/project_state.json b/.a0/project_state.json
index 4998320..3afe893 100644
--- a/.a0/project_state.json
+++ b/.a0/project_state.json
@@ -1,10 +1,10 @@
{
"project_name": "leocrm",
"phase": "phase-3-implementation",
- "status": "T06_complete_3_tasks_remaining",
- "last_commit": "f646c59",
- "completed_tasks": ["T01","T02","T03","T04","T05","T06","T07a","T07b","T08b","T09","T11"],
+ "status": "T08a_complete_2_tasks_remaining",
+ "last_commit": "0962f3a",
+ "completed_tasks": ["T01","T02","T03","T04","T05","T06","T07a","T07b","T08a","T08b","T09","T11"],
"current_task": null,
- "next_task": "T08a",
- "updated_at": "2026-07-01T15:41:00+02:00"
+ "next_task": "T08c",
+ "updated_at": "2026-07-01T16:54:00+02:00"
}
diff --git a/.a0/worklog.md b/.a0/worklog.md
index 25c9030..b6619de 100644
--- a/.a0/worklog.md
+++ b/.a0/worklog.md
@@ -154,3 +154,15 @@
- **Ruff:** 0 errors, format clean
- **Commit:** f646c59
- **Risks:** Coverage 74.56% (target 80%), ILIKE fallback instead of tsvector, ARQ worker not wired
+
+## 2026-07-01 16:54 — T08a: Frontend DMS + Tags + Permissions UI Complete
+- **18 neue Dateien, 6 modified** — 3368 Zeilen
+- **DMS:** File browser (folder tree + file grid), upload dropzone, preview modal, share dialog, bulk actions, trash view
+- **Tags:** TagPicker, TagCloud, BulkTagDialog — integriert in CompanyDetail + ContactDetail
+- **Permissions:** Share dialog, public share links, permission display
+- **API clients:** dms.ts, tags.ts, permissions.ts
+- **Routes:** /dms, /dms/trash
+- **i18n:** de.json + en.json translations
+- **Tests:** 33/33 new tests pass, full regression 276/276 pass
+- **tsc:** 0 errors, **vite build:** 252 modules, 3.31s
+- **Commit:** 0962f3a
diff --git a/frontend/src/__tests__/mail/ComposeModal.test.tsx b/frontend/src/__tests__/mail/ComposeModal.test.tsx
new file mode 100644
index 0000000..6d7232b
--- /dev/null
+++ b/frontend/src/__tests__/mail/ComposeModal.test.tsx
@@ -0,0 +1,112 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+vi.mock('@/api/mail', () => ({
+ fetchTemplates: vi.fn().mockResolvedValue([
+ { id: 't1', name: 'Welcome', subject: 'Welcome!', body: '
Welcome {{name}}
', variables: ['name'] },
+ ]),
+ substituteTemplate: vi.fn().mockResolvedValue({ subject: 'Welcome!', body: 'Welcome John
' }),
+}));
+
+vi.mock('@/components/ui/Toast', () => ({
+ useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
+}));
+
+import { ComposeModal } from '@/components/mail/ComposeModal';
+
+function renderModal(props: Partial> = {}) {
+ const defaultProps = {
+ open: true,
+ mode: 'new' as const,
+ accountId: 'acc1',
+ replyToMail: null,
+ forwardMail: null,
+ signatures: [],
+ onSend: vi.fn().mockResolvedValue(undefined),
+ onClose: vi.fn(),
+ };
+ return render();
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('ComposeModal', () => {
+ it('renders compose modal when open', () => {
+ renderModal();
+ expect(screen.getByTestId('compose-modal')).toBeInTheDocument();
+ });
+
+ it('renders compose toolbar with bold button', () => {
+ renderModal();
+ expect(screen.getByTestId('compose-toolbar')).toBeInTheDocument();
+ });
+
+ it('renders recipient input field', () => {
+ renderModal();
+ expect(screen.getByTestId('compose-to')).toBeInTheDocument();
+ });
+
+ it('renders subject input field', () => {
+ renderModal();
+ expect(screen.getByTestId('compose-subject')).toBeInTheDocument();
+ });
+
+ it('renders editor', () => {
+ renderModal();
+ expect(screen.getByTestId('compose-editor')).toBeInTheDocument();
+ });
+
+ it('renders send button', () => {
+ renderModal();
+ expect(screen.getByTestId('compose-send')).toBeInTheDocument();
+ });
+
+ it('renders template picker toggle button', () => {
+ renderModal();
+ expect(screen.getByTestId('template-picker-toggle')).toBeInTheDocument();
+ });
+
+ it('shows template picker when template button is clicked', async () => {
+ renderModal();
+ fireEvent.click(screen.getByTestId('template-picker-toggle'));
+ await waitFor(() => {
+ expect(screen.getByTestId('template-picker')).toBeInTheDocument();
+ });
+ });
+
+ it('pre-fills reply fields when mode is reply', async () => {
+ renderModal({
+ mode: 'reply',
+ replyToMail: {
+ id: 'm1', folder_id: 'f1', account_id: 'acc1', from_address: 'sender@example.com', from_name: 'Sender',
+ to_addresses: ['test@example.com'], cc_addresses: [], bcc_addresses: [],
+ subject: 'Original Subject', body_text: 'Original body', body_html: null, sanitized_html: null,
+ date: '2026-01-01T10:00:00Z', is_seen: true, is_flagged: false, is_answered: false, has_attachments: false,
+ } as any,
+ });
+ await waitFor(() => {
+ const toInput = screen.getByTestId('compose-to');
+ expect(toInput).toHaveValue('sender@example.com');
+ });
+ });
+
+ it('pre-fills forward fields when mode is forward', async () => {
+ renderModal({
+ mode: 'forward',
+ forwardMail: {
+ id: 'm1', folder_id: 'f1', account_id: 'acc1', from_address: 'sender@example.com', from_name: 'Sender',
+ to_addresses: ['test@example.com'], cc_addresses: [], bcc_addresses: [],
+ subject: 'Original Subject', body_text: 'Original body', body_html: null, sanitized_html: null,
+ date: '2026-01-01T10:00:00Z', is_seen: true, is_flagged: false, is_answered: false, has_attachments: false,
+ } as any,
+ });
+ await waitFor(() => {
+ const subjectInput = screen.getByTestId('compose-subject');
+ expect(subjectInput).toHaveValue('Fwd: Original Subject');
+ });
+ });
+});
diff --git a/frontend/src/__tests__/mail/MailPage.test.tsx b/frontend/src/__tests__/mail/MailPage.test.tsx
new file mode 100644
index 0000000..3a22d3d
--- /dev/null
+++ b/frontend/src/__tests__/mail/MailPage.test.tsx
@@ -0,0 +1,162 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+vi.mock('@/api/mail', () => ({
+ fetchAccounts: vi.fn().mockResolvedValue([
+ { id: 'acc1', email: 'test@example.com', display_name: 'Test User', is_shared: false, is_active: true, imap_host: '', imap_port: 993, smtp_host: '', smtp_port: 587 },
+ ]),
+ fetchFolders: vi.fn().mockResolvedValue([
+ { id: 'f1', account_id: 'acc1', name: 'INBOX', parent_id: null, unread_count: 2, total_count: 5, children: [] },
+ { id: 'f2', account_id: 'acc1', name: 'Sent', parent_id: null, unread_count: 0, total_count: 10, children: [] },
+ ]),
+ fetchMails: vi.fn().mockResolvedValue({
+ mails: [
+ { id: 'm1', folder_id: 'f1', account_id: 'acc1', from_address: 'sender@example.com', from_name: 'Sender', to_addresses: ['test@example.com'], cc_addresses: [], bcc_addresses: [], subject: 'Test Subject', body_text: 'Hello', body_html: null, sanitized_html: 'Hello
', date: '2026-01-01T10:00:00Z', is_seen: false, is_flagged: false, is_answered: false, has_attachments: false, labels: [] },
+ { id: 'm2', folder_id: 'f1', account_id: 'acc1', from_address: 'sender2@example.com', from_name: 'Sender 2', to_addresses: ['test@example.com'], cc_addresses: [], bcc_addresses: [], subject: 'Another Subject', body_text: 'World', body_html: null, sanitized_html: 'World
', date: '2026-01-01T11:00:00Z', is_seen: true, is_flagged: true, is_answered: false, has_attachments: true, labels: [] },
+ ],
+ total: 2,
+ page: 1,
+ page_size: 25,
+ }),
+ getMail: vi.fn().mockResolvedValue({
+ id: 'm1', folder_id: 'f1', account_id: 'acc1', from_address: 'sender@example.com', from_name: 'Sender', to_addresses: ['test@example.com'], cc_addresses: [], bcc_addresses: [], subject: 'Test Subject', body_text: 'Hello', body_html: 'Hello
', sanitized_html: 'Hello
', date: '2026-01-01T10:00:00Z', is_seen: false, is_flagged: false, is_answered: false, has_attachments: false, attachments: [], labels: [],
+ }),
+ sendMail: vi.fn().mockResolvedValue({ id: 'm3', success: true }),
+ replyMail: vi.fn().mockResolvedValue({ id: 'm3', success: true }),
+ forwardMail: vi.fn().mockResolvedValue({ id: 'm3', success: true }),
+ updateFlags: vi.fn().mockResolvedValue(undefined),
+ createEventFromMail: vi.fn().mockResolvedValue({ id: 'e1', success: true }),
+ downloadAttachment: vi.fn().mockResolvedValue(new Blob(['test'])),
+ fetchSignatures: vi.fn().mockResolvedValue([]),
+ searchMails: vi.fn().mockResolvedValue({ mails: [], total: 0, page: 1, page_size: 25 }),
+}));
+
+vi.mock('@/components/ui/Toast', () => ({
+ useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
+}));
+
+import { MailPage } from '@/pages/Mail';
+
+function renderWithRouter() {
+ return render();
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('MailPage', () => {
+ it('renders mail page after loading', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-page')).toBeInTheDocument();
+ });
+ });
+
+ it('renders folder tree pane', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-folder-pane')).toBeInTheDocument();
+ });
+ });
+
+ it('renders mail list pane', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-list-pane')).toBeInTheDocument();
+ });
+ });
+
+ it('renders mail detail pane', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-detail-pane')).toBeInTheDocument();
+ });
+ });
+
+ it('renders compose button', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('compose-btn')).toBeInTheDocument();
+ });
+ });
+
+ it('renders shared mailbox selector', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('shared-mailbox-selector')).toBeInTheDocument();
+ });
+ });
+
+ it('renders mail search bar', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-search-bar')).toBeInTheDocument();
+ });
+ });
+
+ it('renders folders after loading', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByText('INBOX')).toBeInTheDocument();
+ expect(screen.getByText('Sent')).toBeInTheDocument();
+ });
+ });
+
+ it('renders mails after loading', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByText('Test Subject')).toBeInTheDocument();
+ expect(screen.getByText('Another Subject')).toBeInTheDocument();
+ });
+ });
+
+ it('shows compose modal when compose button is clicked', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ const btn = screen.getByTestId('compose-btn');
+ fireEvent.click(btn);
+ expect(screen.getByTestId('compose-modal')).toBeInTheDocument();
+ });
+ });
+
+ it('shows compose toolbar with bold and italic buttons', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ const btn = screen.getByTestId('compose-btn');
+ fireEvent.click(btn);
+ expect(screen.getByTestId('compose-toolbar')).toBeInTheDocument();
+ });
+ });
+
+ it('renders mail detail empty state initially', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-detail-empty')).toBeInTheDocument();
+ });
+ });
+
+ it('renders mail detail when mail is clicked', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByText('Test Subject')).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByText('Test Subject'));
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-detail')).toBeInTheDocument();
+ });
+ });
+
+ it('shows reply and forward buttons in mail detail', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByText('Test Subject')).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByText('Test Subject'));
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-detail-toolbar')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/__tests__/mail/MailSettings.test.tsx b/frontend/src/__tests__/mail/MailSettings.test.tsx
new file mode 100644
index 0000000..9768ce2
--- /dev/null
+++ b/frontend/src/__tests__/mail/MailSettings.test.tsx
@@ -0,0 +1,146 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+vi.mock('@/api/mail', () => ({
+ fetchAccounts: vi.fn().mockResolvedValue([
+ { id: 'acc1', email: 'test@example.com', display_name: 'Test User', is_shared: false, is_active: true, imap_host: '', imap_port: 993, smtp_host: '', smtp_port: 587 },
+ ]),
+ createAccount: vi.fn().mockResolvedValue({ id: 'acc2', email: 'new@example.com', display_name: 'New User', is_shared: false, is_active: true, imap_host: '', imap_port: 993, smtp_host: '', smtp_port: 587 }),
+ testConnection: vi.fn().mockResolvedValue({ success: true, message: 'OK' }),
+ triggerSync: vi.fn().mockResolvedValue({ success: true, synced_count: 5 }),
+ fetchSignatures: vi.fn().mockResolvedValue([]),
+ createSignature: vi.fn().mockResolvedValue({ id: 's1', name: 'Test', body_html: 'Test
', is_default: false }),
+ updateSignature: vi.fn().mockResolvedValue({ id: 's1', name: 'Test', body_html: 'Test
', is_default: false }),
+ deleteSignature: vi.fn().mockResolvedValue(undefined),
+ fetchRules: vi.fn().mockResolvedValue([]),
+ createRule: vi.fn().mockResolvedValue({ id: 'r1', name: 'Rule 1', account_id: 'acc1', priority: 1, is_active: true, conditions: [], actions: [] }),
+ deleteRule: vi.fn().mockResolvedValue(undefined),
+ fetchLabels: vi.fn().mockResolvedValue([]),
+ createLabel: vi.fn().mockResolvedValue({ id: 'l1', name: 'Important', color: '#ef4444' }),
+ deleteLabel: vi.fn().mockResolvedValue(undefined),
+ configureVacation: vi.fn().mockResolvedValue({ success: true }),
+ testVacationDedup: vi.fn().mockResolvedValue({ dedup_count: 0 }),
+ importPgpKey: vi.fn().mockResolvedValue({ id: 'k1', key_id: 'ABC', fingerprint: 'DEF', user_id: 'test', is_private: true, expires_at: null }),
+ fetchPgpKeys: vi.fn().mockResolvedValue([]),
+ fetchContactPgpKeys: vi.fn().mockResolvedValue([]),
+ storeContactPgpKey: vi.fn().mockResolvedValue(undefined),
+ fetchTemplates: vi.fn().mockResolvedValue([]),
+ substituteTemplate: vi.fn().mockResolvedValue({ subject: '', body: '' }),
+}));
+
+vi.mock('@/components/ui/Toast', () => ({
+ useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
+}));
+
+import { MailSettingsPage } from '@/pages/MailSettings';
+
+function renderWithRouter() {
+ return render();
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('MailSettingsPage', () => {
+ it('renders settings page', async () => {
+ renderWithRouter();
+ expect(screen.getByTestId('mail-settings-page')).toBeInTheDocument();
+ });
+
+ it('renders accounts tab content', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('tab-accounts')).toBeInTheDocument();
+ });
+ });
+
+ it('renders add account button', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('add-account-btn')).toBeInTheDocument();
+ });
+ });
+
+ it('shows add account form when button is clicked', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('add-account-btn')).toBeInTheDocument();
+ });
+ fireEvent.click(screen.getByTestId('add-account-btn'));
+ await waitFor(() => {
+ expect(screen.getByTestId('add-account-form')).toBeInTheDocument();
+ });
+ });
+
+ it('renders signature manager in signatures tab', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-settings-page')).toBeInTheDocument();
+ });
+ // Click on signatures tab
+ const sigTab = screen.getByRole('tab', { name: /Signaturen/i });
+ fireEvent.click(sigTab);
+ await waitFor(() => {
+ expect(screen.getByTestId('signature-manager')).toBeInTheDocument();
+ });
+ });
+
+ it('renders rule editor in rules tab', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-settings-page')).toBeInTheDocument();
+ });
+ const rulesTab = screen.getByRole('tab', { name: /Regeln/i });
+ fireEvent.click(rulesTab);
+ await waitFor(() => {
+ expect(screen.getByTestId('rule-editor')).toBeInTheDocument();
+ });
+ });
+
+ it('renders label manager in labels tab', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-settings-page')).toBeInTheDocument();
+ });
+ const labelsTab = screen.getByRole('tab', { name: /Labels/i });
+ fireEvent.click(labelsTab);
+ await waitFor(() => {
+ expect(screen.getByTestId('label-manager')).toBeInTheDocument();
+ });
+ });
+
+ it('renders vacation responder in vacation tab', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-settings-page')).toBeInTheDocument();
+ });
+ const vacationTab = screen.getByRole('tab', { name: /Abwesenheit/i });
+ fireEvent.click(vacationTab);
+ await waitFor(() => {
+ expect(screen.getByTestId('vacation-responder')).toBeInTheDocument();
+ });
+ });
+
+ it('renders PGP settings in PGP tab', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('mail-settings-page')).toBeInTheDocument();
+ });
+ const pgpTab = screen.getByRole('tab', { name: /PGP/i });
+ fireEvent.click(pgpTab);
+ await waitFor(() => {
+ expect(screen.getByTestId('pgp-settings')).toBeInTheDocument();
+ });
+ });
+
+ it('renders account list with test connection button', async () => {
+ renderWithRouter();
+ await waitFor(() => {
+ expect(screen.getByTestId('account-list')).toBeInTheDocument();
+ });
+ expect(screen.getByTestId('test-conn-acc1')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/__tests__/search/GlobalSearchTabs.test.tsx b/frontend/src/__tests__/search/GlobalSearchTabs.test.tsx
new file mode 100644
index 0000000..8601cf0
--- /dev/null
+++ b/frontend/src/__tests__/search/GlobalSearchTabs.test.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+vi.mock('@/api/hooks', () => ({
+ useGlobalSearch: () => ({
+ data: [
+ { id: '1', type: 'company', name: 'TestCorp GmbH', description: 'IT company', url: '/companies/1' },
+ { id: '2', type: 'contact', name: 'Max Mustermann', description: 'CEO', url: '/contacts/2' },
+ { id: '3', type: 'mail', name: 'Test Email', description: 'Subject: Hello', url: '/mail/3' },
+ { id: '4', type: 'file', name: 'Document.pdf', description: 'PDF file', url: '/dms/4' },
+ { id: '5', type: 'event', name: 'Meeting', description: 'Team sync', url: '/calendar/5' },
+ ],
+ isLoading: false,
+ }),
+}));
+
+import { GlobalSearchResultsPage } from '@/pages/GlobalSearchResults';
+
+describe('GlobalSearchResultsPage with tabs', () => {
+ it('renders search page', () => {
+ render();
+ expect(screen.getByTestId('global-search-page')).toBeInTheDocument();
+ });
+
+ it('renders search input field', () => {
+ render();
+ expect(screen.getByTestId('search-input')).toBeInTheDocument();
+ });
+
+ it('renders search tabs after results load', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByTestId('search-tabs')).toBeInTheDocument();
+ });
+ });
+
+ it('renders all results in the all tab', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByTestId('search-results-all')).toBeInTheDocument();
+ });
+ });
+
+ it('renders company results', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByTestId('search-results-all')).toBeInTheDocument();
+ });
+ const companyTab = screen.getByRole('tab', { name: /Firmen/i });
+ expect(companyTab).toBeInTheDocument();
+ });
+
+ it('renders all 5 result types in the all tab', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText((content, element) => content.includes('Max Mustermann'))).toBeInTheDocument();
+ });
+ expect(screen.getByText((content, element) => content.includes('Document.pdf'))).toBeInTheDocument();
+ expect(screen.getByText((content, element) => content.includes('Meeting'))).toBeInTheDocument();
+ });
+
+ it('renders search submit button', () => {
+ render();
+ expect(screen.getByTestId('search-submit-btn')).toBeInTheDocument();
+ });
+
+ it('shows query display', async () => {
+ render();
+ expect(screen.getByTestId('search-query-display')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/api/mail.ts b/frontend/src/api/mail.ts
new file mode 100644
index 0000000..b995743
--- /dev/null
+++ b/frontend/src/api/mail.ts
@@ -0,0 +1,553 @@
+/**
+ * Mail plugin API client.
+ *
+ * All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target the
+ * Mail plugin routes under `/mail/...`.
+ */
+
+import { apiClient, apiDelete, apiGet, apiPatch, apiPost } from './client';
+
+// ─── Types ─────────────────────────────────────────────────────────────────
+
+export interface MailAccount {
+ id: string;
+ email: string;
+ display_name: string;
+ imap_host: string;
+ imap_port: number;
+ smtp_host: string;
+ smtp_port: number;
+ is_shared: boolean;
+ is_active: boolean;
+ created_at?: string | null;
+ updated_at?: string | null;
+}
+
+export interface MailFolder {
+ id: string;
+ account_id: string;
+ name: string;
+ parent_id: string | null;
+ unread_count: number;
+ total_count: number;
+ children?: MailFolder[];
+}
+
+export interface MailAttachment {
+ id: string;
+ mail_id: string;
+ filename: string;
+ mime_type: string;
+ size: number;
+ content_id?: string | null;
+ is_inline: boolean;
+}
+
+export interface Mail {
+ id: string;
+ folder_id: string; account_id: string;
+ from_address: string;
+ from_name: string;
+ to_addresses: string[];
+ cc_addresses: string[];
+ bcc_addresses: string[];
+ subject: string;
+ body_text: string;
+ body_html: string | null;
+ sanitized_html: string | null;
+ date: string;
+ is_seen: boolean;
+ is_flagged: boolean;
+ is_answered: boolean;
+ has_attachments: boolean;
+ attachments?: MailAttachment[];
+ labels?: MailLabel[];
+ thread_id?: string | null;
+ in_reply_to?: string | null;
+ message_id?: string | null;
+}
+
+export interface MailListResult {
+ mails: Mail[];
+ total: number;
+ page: number;
+ page_size: number;
+}
+
+export interface MailTemplate {
+ id: string;
+ name: string;
+ subject: string;
+ body: string;
+ variables: string[];
+ created_at?: string | null;
+ updated_at?: string | null;
+}
+
+export interface MailSignature {
+ id: string;
+ name: string;
+ body_html: string;
+ is_default: boolean;
+ created_at?: string | null;
+ updated_at?: string | null;
+}
+
+export type RuleConditionType = 'from_contains' | 'to_contains' | 'subject_contains' | 'has_attachment' | 'body_contains';
+export type RuleActionType = 'move_to_folder' | 'mark_as_read' | 'mark_as_flagged' | 'forward_to' | 'delete';
+
+export interface RuleCondition {
+ type: RuleConditionType;
+ value: string;
+}
+
+export interface RuleAction {
+ type: RuleActionType;
+ value: string;
+}
+
+export interface MailRule {
+ id: string;
+ name: string;
+ account_id: string;
+ priority: number;
+ is_active: boolean;
+ conditions: RuleCondition[];
+ actions: RuleAction[];
+ created_at?: string | null;
+ updated_at?: string | null;
+}
+
+export interface MailLabel {
+ id: string;
+ name: string;
+ color: string;
+ created_at?: string | null;
+}
+
+export interface PgpKey {
+ id: string;
+ key_id: string;
+ fingerprint: string;
+ user_id: string;
+ is_private: boolean;
+ expires_at: string | null;
+ created_at?: string | null;
+}
+
+export interface ContactPgpKey {
+ contact_id: string;
+ contact_name: string;
+ key_id: string;
+ fingerprint: string;
+ added_at: string;
+}
+
+export interface VacationConfig {
+ enabled: boolean;
+ start_date: string | null;
+ end_date: string | null;
+ subject: string;
+ body: string;
+ dedup_days: number;
+}
+
+export interface SharedMailboxUser {
+ user_id: string;
+ user_name: string;
+ permissions: string[];
+}
+
+export interface DelegateAccess {
+ id: string;
+ delegate_user_id: string;
+ delegate_user_name: string;
+ permissions: string[];
+ folder_ids: string[];
+}
+
+export interface SendPermission {
+ id: string;
+ grantee_user_id: string;
+ grantee_user_name: string;
+ from_address: string;
+}
+
+export interface ThreadResult {
+ thread_id: string;
+ subject: string;
+ mails: Mail[];
+}
+
+// ─── Payloads ──────────────────────────────────────────────────────────────
+
+export interface CreateAccountPayload {
+ email: string;
+ display_name: string;
+ imap_host: string;
+ imap_port: number;
+ smtp_host: string;
+ smtp_port: number;
+ password: string;
+}
+
+export interface UpdateAccountPayload {
+ display_name?: string;
+ imap_host?: string;
+ imap_port?: number;
+ smtp_host?: string;
+ smtp_port?: number;
+ password?: string;
+ is_active?: boolean;
+}
+
+export interface CreateFolderPayload {
+ account_id: string;
+ name: string;
+ parent_id?: string | null;
+}
+
+export interface UpdateFolderPayload {
+ name?: string;
+ parent_id?: string | null;
+}
+
+export interface SendMailPayload {
+ account_id: string;
+ to: string[];
+ cc?: string[];
+ bcc?: string[];
+ subject: string;
+ body: string;
+ is_html: boolean;
+ in_reply_to?: string | null;
+ attachments?: string[];
+ signature_id?: string | null;
+}
+
+export interface ReplyPayload {
+ account_id: string;
+ body: string;
+ is_html: boolean;
+ to?: string[];
+ cc?: string[];
+ signature_id?: string | null;
+}
+
+export interface ForwardPayload {
+ account_id: string;
+ to: string[];
+ body?: string;
+ is_html: boolean;
+ signature_id?: string | null;
+}
+
+export interface FlagUpdatePayload {
+ seen?: boolean;
+ flagged?: boolean;
+}
+
+export interface LinkMailPayload {
+ contact_id?: string | null;
+ company_id?: string | null;
+}
+
+export interface CreateEventFromMailPayload {
+ title: string;
+ start: string;
+ end: string;
+ description?: string;
+ location?: string;
+ calendar_id?: string;
+}
+
+export interface AssignLabelPayload {
+ label_id: string;
+}
+
+export interface CreateTemplatePayload {
+ name: string;
+ subject: string;
+ body: string;
+ variables?: string[];
+}
+
+export interface SubstituteTemplatePayload {
+ template_id: string;
+ variables: Record;
+}
+
+export interface SubstituteResult {
+ subject: string;
+ body: string;
+}
+
+export interface CreateSignaturePayload {
+ name: string;
+ body_html: string;
+ is_default?: boolean;
+}
+
+export interface CreateRulePayload {
+ name: string;
+ account_id: string;
+ priority: number;
+ is_active: boolean;
+ conditions: RuleCondition[];
+ actions: RuleAction[];
+}
+
+export interface CreateLabelPayload {
+ name: string;
+ color: string;
+}
+
+export interface VacationPayload {
+ enabled: boolean;
+ start_date: string | null;
+ end_date: string | null;
+ subject: string;
+ body: string;
+ dedup_days?: number;
+}
+
+export interface ImportPgpKeyPayload {
+ private_key: string;
+ passphrase?: string;
+}
+
+export interface StoreContactPgpKeyPayload {
+ public_key: string;
+}
+
+export interface AssignSharedMailboxUsersPayload {
+ user_ids: string[];
+ permissions: string[];
+}
+
+export interface CreateDelegatePayload {
+ delegate_user_id: string;
+ permissions: string[];
+ folder_ids: string[];
+}
+
+export interface GrantSendPermissionPayload {
+ grantee_user_id: string;
+ from_address: string;
+}
+
+// ─── Accounts ──────────────────────────────────────────────────────────────
+
+export function fetchAccounts(): Promise {
+ return apiGet('/mail/accounts');
+}
+
+export function createAccount(payload: CreateAccountPayload): Promise {
+ return apiPost('/mail/accounts', payload);
+}
+
+export function updateAccount(accountId: string, payload: UpdateAccountPayload): Promise {
+ return apiPatch(`/mail/accounts/${accountId}`, payload);
+}
+
+export function deleteAccount(accountId: string): Promise {
+ return apiDelete(`/mail/accounts/${accountId}`);
+}
+
+export function fetchSharedAccounts(): Promise {
+ return apiGet('/mail/accounts/shared');
+}
+
+export function assignSharedMailboxUsers(accountId: string, payload: AssignSharedMailboxUsersPayload): Promise {
+ return apiPost(`/mail/accounts/${accountId}/users`, payload);
+}
+
+export function createDelegate(accountId: string, payload: CreateDelegatePayload): Promise {
+ return apiPost(`/mail/accounts/${accountId}/delegates`, payload);
+}
+
+export function grantSendPermission(accountId: string, payload: GrantSendPermissionPayload): Promise {
+ return apiPost(`/mail/accounts/${accountId}/send-permissions`, payload);
+}
+
+export function testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
+ return apiPost<{ success: boolean; message: string }>(`/mail/accounts/${accountId}/test-connection`);
+}
+
+export function triggerSync(accountId: string): Promise<{ success: boolean; synced_count: number }> {
+ return apiPost<{ success: boolean; synced_count: number }>(`/mail/accounts/${accountId}/sync`);
+}
+
+// ─── Folders ────────────────────────────────────────────────────────────────
+
+export function fetchFolders(accountId: string): Promise {
+ const qs = new URLSearchParams({ account_id: accountId }).toString();
+ return apiGet(`/mail/folders?${qs}`);
+}
+
+export function createFolder(payload: CreateFolderPayload): Promise {
+ return apiPost('/mail/folders', payload);
+}
+
+export function updateFolder(folderId: string, payload: UpdateFolderPayload): Promise {
+ return apiPatch(`/mail/folders/${folderId}`, payload);
+}
+
+export function deleteFolder(folderId: string): Promise {
+ return apiDelete(`/mail/folders/${folderId}`);
+}
+
+// ─── Mails ──────────────────────────────────────────────────────────────────
+
+export function fetchMails(folderId: string, page: number): Promise {
+ const qs = new URLSearchParams({ folder_id: folderId, page: String(page) }).toString();
+ return apiGet(`/mail/?${qs}`);
+}
+
+export function getMail(mailId: string): Promise {
+ return apiGet(`/mail/${mailId}`);
+}
+
+export function sendMail(payload: SendMailPayload): Promise<{ id: string; success: boolean }> {
+ return apiPost<{ id: string; success: boolean }>('/mail/send', payload);
+}
+
+export function replyMail(mailId: string, payload: ReplyPayload): Promise<{ id: string; success: boolean }> {
+ return apiPost<{ id: string; success: boolean }>(`/mail/${mailId}/reply`, payload);
+}
+
+export function forwardMail(mailId: string, payload: ForwardPayload): Promise<{ id: string; success: boolean }> {
+ return apiPost<{ id: string; success: boolean }>(`/mail/${mailId}/forward`, payload);
+}
+
+export function updateFlags(mailId: string, payload: FlagUpdatePayload): Promise {
+ return apiPatch(`/mail/${mailId}/flags`, payload);
+}
+
+export function linkMail(mailId: string, payload: LinkMailPayload): Promise {
+ return apiPost(`/mail/${mailId}/link`, payload);
+}
+
+export function createEventFromMail(mailId: string, payload: CreateEventFromMailPayload): Promise<{ id: string; success: boolean }> {
+ return apiPost<{ id: string; success: boolean }>(`/mail/${mailId}/create-event`, payload);
+}
+
+export function assignLabel(mailId: string, payload: AssignLabelPayload): Promise {
+ return apiPost(`/mail/${mailId}/labels`, payload);
+}
+
+// ─── Search & Threads ──────────────────────────────────────────────────────
+
+export function searchMails(query: string): Promise {
+ const qs = new URLSearchParams({ q: query }).toString();
+ return apiGet(`/mail/search?${qs}`);
+}
+
+export function fetchThreads(accountId: string): Promise {
+ const qs = new URLSearchParams({ account_id: accountId }).toString();
+ return apiGet(`/mail/threads?${qs}`);
+}
+
+// ─── Attachments ─────────────────────────────────────────────────────────────
+
+export function getAttachmentDownloadUrl(mailId: string, attachmentId: string): string {
+ return `/api/v1/mail/${mailId}/attachments/${attachmentId}`;
+}
+
+export function downloadAttachment(mailId: string, attachmentId: string): Promise {
+ return apiClient.get(`/mail/${mailId}/attachments/${attachmentId}`, {
+ responseType: 'blob',
+ }).then((res) => res.data);
+}
+
+// ─── Templates ──────────────────────────────────────────────────────────────
+
+export function createTemplate(payload: CreateTemplatePayload): Promise {
+ return apiPost('/mail/templates', payload);
+}
+
+export function fetchTemplates(): Promise {
+ return apiGet('/mail/templates');
+}
+
+export function substituteTemplate(payload: SubstituteTemplatePayload): Promise {
+ return apiPost('/mail/templates/substitute', payload);
+}
+
+// ─── Signatures ──────────────────────────────────────────────────────────────
+
+export function createSignature(payload: CreateSignaturePayload): Promise {
+ return apiPost('/mail/signatures', payload);
+}
+
+export function fetchSignatures(): Promise {
+ return apiGet('/mail/signatures');
+}
+
+export function deleteSignature(signatureId: string): Promise {
+ return apiDelete(`/mail/signatures/${signatureId}`);
+}
+
+export function updateSignature(signatureId: string, payload: Partial): Promise {
+ return apiPatch(`/mail/signatures/${signatureId}`, payload);
+}
+
+// ─── Rules ───────────────────────────────────────────────────────────────────
+
+export function createRule(payload: CreateRulePayload): Promise {
+ return apiPost('/mail/rules', payload);
+}
+
+export function fetchRules(): Promise {
+ return apiGet('/mail/rules');
+}
+
+export function deleteRule(ruleId: string): Promise {
+ return apiDelete(`/mail/rules/${ruleId}`);
+}
+
+// ─── Vacation ─────────────────────────────────────────────────────────────────
+
+export function configureVacation(payload: VacationPayload): Promise<{ success: boolean }> {
+ return apiPost<{ success: boolean }>('/mail/vacation', payload);
+}
+
+export function testVacationDedup(): Promise<{ dedup_count: number }> {
+ return apiPost<{ dedup_count: number }>('/mail/vacation/test-dedup');
+}
+
+// ─── PGP ──────────────────────────────────────────────────────────────────────
+
+export function importPgpKey(payload: ImportPgpKeyPayload): Promise {
+ return apiPost('/mail/pgp/keys', payload);
+}
+
+export function fetchPgpKeys(): Promise {
+ return apiGet('/mail/pgp/keys');
+}
+
+export function encryptMessage(payload: { recipient_key_id: string; message: string }): Promise<{ encrypted: string }> {
+ return apiPost<{ encrypted: string }>('/mail/pgp/encrypt', payload);
+}
+
+export function storeContactPgpKey(contactId: string, payload: StoreContactPgpKeyPayload): Promise {
+ return apiPost(`/mail/contacts/${contactId}/pgp-key`, payload);
+}
+
+export function fetchContactPgpKeys(): Promise {
+ return apiGet('/mail/contacts/pgp-keys');
+}
+
+// ─── Labels ───────────────────────────────────────────────────────────────────
+
+export function createLabel(payload: CreateLabelPayload): Promise {
+ return apiPost('/mail/labels', payload);
+}
+
+export function fetchLabels(): Promise {
+ return apiGet('/mail/labels');
+}
+
+export function deleteLabel(labelId: string): Promise {
+ return apiDelete(`/mail/labels/${labelId}`);
+}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
index 6a66004..3c38d16 100644
--- a/frontend/src/components/layout/Sidebar.tsx
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -22,7 +22,7 @@ const navItems: NavItem[] = [
{ to: '/contacts', labelKey: 'nav.contacts', icon: navIcon('M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z') },
{ to: '/calendar', labelKey: 'nav.calendar', icon: navIcon('M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z') },
{ to: '/dms', labelKey: 'nav.files', icon: navIcon('M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z') },
- { to: '/email', labelKey: 'nav.email', icon: navIcon('M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z') },
+ { to: '/mail', labelKey: 'nav.email', icon: navIcon('M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z') },
{ to: '/users', labelKey: 'nav.users', icon: navIcon('M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z') },
{ to: '/audit-log', labelKey: 'nav.auditLog', icon: navIcon('M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z') },
{ to: '/settings', labelKey: 'nav.settings', icon: navIcon('M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z') },
diff --git a/frontend/src/components/mail/ComposeModal.tsx b/frontend/src/components/mail/ComposeModal.tsx
new file mode 100644
index 0000000..503e85b
--- /dev/null
+++ b/frontend/src/components/mail/ComposeModal.tsx
@@ -0,0 +1,292 @@
+/**
+ * Compose modal — rich text editor with toolbar (bold, italic, link, template insert).
+ * Used for new mail, reply, and forward.
+ */
+
+import React, { useState, useRef, useCallback, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Modal } from '@/components/ui/Modal';
+import { Button } from '@/components/ui/Button';
+import { Input } from '@/components/ui/Input';
+import { Select } from '@/components/ui/Select';
+import { TemplatePicker } from './TemplatePicker';
+import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload } from '@/api/mail';
+
+export type ComposeMode = 'new' | 'reply' | 'forward';
+
+export interface ComposeModalProps {
+ open: boolean;
+ mode: ComposeMode;
+ accountId: string;
+ replyToMail: Mail | null;
+ forwardMail: Mail | null;
+ signatures: MailSignature[];
+ onSend: (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => Promise;
+ onClose: () => void;
+}
+
+export function ComposeModal({
+ open,
+ mode,
+ accountId,
+ replyToMail,
+ forwardMail,
+ signatures,
+ onSend,
+ onClose,
+}: ComposeModalProps) {
+ const { t } = useTranslation();
+ const editorRef = useRef(null);
+ const [to, setTo] = useState('');
+ const [cc, setCc] = useState('');
+ const [bcc, setBcc] = useState('');
+ const [subject, setSubject] = useState('');
+ const [body, setBody] = useState('');
+ const [showCc, setShowCc] = useState(false);
+ const [sending, setSending] = useState(false);
+ const [selectedSignatureId, setSelectedSignatureId] = useState('');
+ const [showTemplatePicker, setShowTemplatePicker] = useState(false);
+
+ useEffect(() => {
+ if (!open) return;
+ if (mode === 'reply' && replyToMail) {
+ setTo(replyToMail.from_address);
+ setSubject(replyToMail.subject.startsWith('Re: ') ? replyToMail.subject : `Re: ${replyToMail.subject}`);
+ setBody(`\n\n---\n${replyToMail.body_text.slice(0, 200)}`);
+ } else if (mode === 'forward' && forwardMail) {
+ setTo('');
+ setSubject(forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`);
+ setBody(`\n\n---\n${t('mail.forwarding')}\n${t('mail.from')}: ${forwardMail.from_address}\n${t('mail.subject')}: ${forwardMail.subject}\n\n${forwardMail.body_text.slice(0, 200)}`);
+ } else {
+ setTo('');
+ setCc('');
+ setBcc('');
+ setSubject('');
+ setBody('');
+ }
+ }, [open, mode, replyToMail, forwardMail, t]);
+
+ const execCommand = useCallback((command: string, value?: string) => {
+ document.execCommand(command, false, value);
+ if (editorRef.current) {
+ setBody(editorRef.current.innerHTML);
+ }
+ editorRef.current?.focus();
+ }, []);
+
+ const insertLink = useCallback(() => {
+ const url = window.prompt(t('mail.linkUrl'));
+ if (url) {
+ execCommand('createLink', url);
+ }
+ }, [execCommand, t]);
+
+ const insertSignature = useCallback((signatureId: string) => {
+ setSelectedSignatureId(signatureId);
+ const sig = signatures.find((s) => s.id === signatureId);
+ if (sig && editorRef.current) {
+ const current = editorRef.current.innerHTML;
+ editorRef.current.innerHTML = `${current}
---
${sig.body_html}`;
+ setBody(editorRef.current.innerHTML);
+ }
+ }, [signatures]);
+
+ const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => {
+ if (editorRef.current) {
+ editorRef.current.innerHTML = templateBody;
+ setBody(templateBody);
+ }
+ if (templateSubject) {
+ setSubject(templateSubject);
+ }
+ setShowTemplatePicker(false);
+ }, []);
+
+ const handleSend = useCallback(async () => {
+ if (!to.trim()) return;
+ setSending(true);
+ try {
+ const toList = to.split(',').map((s) => s.trim()).filter(Boolean);
+ const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
+ const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
+
+ if (mode === 'reply' && replyToMail) {
+ const replyPayload: ReplyPayload = {
+ account_id: accountId,
+ body,
+ is_html: true,
+ to: toList,
+ cc: ccList,
+ signature_id: selectedSignatureId || null,
+ };
+ await onSend(replyPayload, 'reply');
+ } else if (mode === 'forward' && forwardMail) {
+ const fwdPayload: ForwardPayload = {
+ account_id: accountId,
+ to: toList,
+ body,
+ is_html: true,
+ signature_id: selectedSignatureId || null,
+ };
+ await onSend(fwdPayload, 'forward');
+ } else {
+ const sendPayload: SendMailPayload = {
+ account_id: accountId,
+ to: toList,
+ cc: ccList,
+ bcc: bccList,
+ subject,
+ body,
+ is_html: true,
+ signature_id: selectedSignatureId || null,
+ };
+ await onSend(sendPayload, 'new');
+ }
+ onClose();
+ } finally {
+ setSending(false);
+ }
+ }, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, onSend, onClose]);
+
+ const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : t('mail.compose');
+
+ return (
+
+
+ {/* Toolbar */}
+
+
+
+
+
+
+
+
+ {showTemplatePicker && (
+
+ )}
+
+ {/* Recipients */}
+
+
+ {/* Editor */}
+
+
+
setBody((e.target as HTMLDivElement).innerHTML)}
+ className="min-h-48 border border-secondary-300 rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-primary-500 overflow-y-auto"
+ role="textbox"
+ aria-multiline="true"
+ aria-label={t('mail.body')}
+ data-testid="compose-editor"
+ dangerouslySetInnerHTML={mode === 'reply' && replyToMail ? { __html: body } : undefined}
+ />
+
+
+ {/* Signature */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/mail/LabelManager.tsx b/frontend/src/components/mail/LabelManager.tsx
new file mode 100644
index 0000000..2d9b457
--- /dev/null
+++ b/frontend/src/components/mail/LabelManager.tsx
@@ -0,0 +1,178 @@
+/**
+ * Label manager — create labels with colors, assign to mails.
+ */
+
+import React, { useState, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/Button';
+import { Input } from '@/components/ui/Input';
+import { Card } from '@/components/ui/Card';
+import { EmptyState } from '@/components/ui/EmptyState';
+import { useToast } from '@/components/ui/Toast';
+import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
+import {
+ fetchLabels,
+ createLabel,
+ deleteLabel,
+ type MailLabel,
+} from '@/api/mail';
+
+const PRESET_COLORS = [
+ '#ef4444', '#f59e0b', '#10b981', '#3b82f6',
+ '#8b5cf6', '#ec4899', '#6366f1', '#64748b',
+];
+
+export function LabelManager() {
+ const { t } = useTranslation();
+ const toast = useToast();
+ const [labels, setLabels] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [showForm, setShowForm] = useState(false);
+ const [name, setName] = useState('');
+ const [color, setColor] = useState(PRESET_COLORS[0]);
+ const [saving, setSaving] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
+ const load = useCallback(() => {
+ setLoading(true);
+ fetchLabels()
+ .then((data) => {
+ setLabels(data);
+ setError(null);
+ })
+ .catch((err) => {
+ setError(err instanceof Error ? err.message : String(err));
+ })
+ .finally(() => setLoading(false));
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ const handleSave = useCallback(async () => {
+ if (!name.trim()) return;
+ setSaving(true);
+ try {
+ const label = await createLabel({ name, color });
+ setLabels((prev) => [...prev, label]);
+ toast.success(t('mail.labelCreated'));
+ setShowForm(false);
+ setName('');
+ setColor(PRESET_COLORS[0]);
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : String(err));
+ } finally {
+ setSaving(false);
+ }
+ }, [name, color, toast, t]);
+
+ const handleDelete = useCallback(async () => {
+ if (!deleteTarget) return;
+ try {
+ await deleteLabel(deleteTarget.id);
+ setLabels((prev) => prev.filter((l) => l.id !== deleteTarget.id));
+ toast.success(t('mail.labelDeleted'));
+ setDeleteTarget(null);
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : String(err));
+ }
+ }, [deleteTarget, toast, t]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
{t('mail.labels')}
+
+
+
+ {showForm && (
+
+
+
setName(e.target.value)}
+ placeholder={t('mail.labelName')}
+ required
+ />
+
+
+
+ {PRESET_COLORS.map((c) => (
+
+
+
+
+
+
+
+
+ )}
+
+ {labels.length === 0 && !showForm ? (
+
+ ) : (
+
+ {labels.map((label) => (
+
+
{label.name}
+
+
+ ))}
+
+ )}
+
+
setDeleteTarget(null)}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/mail/MailDetail.tsx b/frontend/src/components/mail/MailDetail.tsx
new file mode 100644
index 0000000..97f84e6
--- /dev/null
+++ b/frontend/src/components/mail/MailDetail.tsx
@@ -0,0 +1,221 @@
+/**
+ * Mail detail reading pane.
+ * Shows mail headers, sanitized HTML body, attachments, reply/forward/create-event actions.
+ */
+
+import React, { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import clsx from 'clsx';
+import type { Mail, MailAttachment } from '@/api/mail';
+import { Button } from '@/components/ui/Button';
+import { Badge } from '@/components/ui/Badge';
+import { EmptyState } from '@/components/ui/EmptyState';
+
+export interface MailDetailProps {
+ mail: Mail | null; loading: boolean;
+ onReply: (mail: Mail) => void;
+ onForward: (mail: Mail) => void;
+ onCreateEvent: (mail: Mail) => void;
+ onToggleFlag: (mail: Mail) => void;
+ onDownloadAttachment: (mailId: string, attachment: MailAttachment) => void;
+ downloadingAttachmentId: string | null;
+}
+
+function formatFullDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleString([], {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function isSanitizedHtmlSafe(html: string | null): boolean {
+ if (!html) return true;
+ const lower = html.toLowerCase();
+ const dangerous = ['