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 */} +
+ setTo(e.target.value)} + placeholder="recipient@example.com" + required + data-testid="compose-to" + /> +
+ {!showCc ? ( + + ) : ( +
+ setCc(e.target.value)} + placeholder="cc@example.com" + /> + setBcc(e.target.value)} + placeholder="bcc@example.com" + /> +
+ )} +
+ setSubject(e.target.value)} + placeholder={t('mail.subjectPlaceholder')} + data-testid="compose-subject" + /> +
+ + {/* 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 */} + 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 = [' lower.includes(pattern)); + +} + +export function MailDetail({ + mail, + loading, + onReply, + onForward, + onCreateEvent, + onToggleFlag, + onDownloadAttachment, + downloadingAttachmentId, +}: MailDetailProps) { + const { t } = useTranslation(); + + const safeHtml = useMemo(() => { + if (!mail) return null; + return mail.sanitized_html || mail.body_html; + }, [mail]); + + const isSafe = useMemo(() => isSanitizedHtmlSafe(safeHtml), [safeHtml]); + + if (loading) { + return ( +
+ + {t('common.loading')} +
+ ); + } + + if (!mail) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+ + + +
+ +
+ + {/* Headers */} +
+
+

{mail.subject || t('mail.noSubject')}

+ {mail.labels && mail.labels.length > 0 && ( +
+ {mail.labels.map((label) => ( + + {label.name} + + ))} +
+ )} +
+
+
+ {t('mail.from')}: + {mail.from_name ? `${mail.from_name} <${mail.from_address}>` : mail.from_address} +
+
+ {t('mail.to')}: + {mail.to_addresses.join(', ')} +
+ {mail.cc_addresses.length > 0 && ( +
+ {t('mail.cc')}: + {mail.cc_addresses.join(', ')} +
+ )} +
+ {t('mail.date')}: + {formatFullDate(mail.date)} +
+
+
+ + {/* Body */} +
+ {safeHtml && isSafe ? ( +
+ ) : safeHtml && !isSafe ? ( +
+

{t('mail.htmlUnsafe')}

+
{mail.body_text}
+
+ ) : ( +
{mail.body_text}
+ )} +
+ + {/* Attachments */} + {mail.attachments && mail.attachments.length > 0 && ( +
+

{t('mail.attachments')}

+
    + {mail.attachments.map((att) => ( +
  • + +
    +

    {att.filename}

    +

    {formatBytes(att.size)}

    +
    + +
  • + ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/mail/MailFolderTree.tsx b/frontend/src/components/mail/MailFolderTree.tsx new file mode 100644 index 0000000..c01a9a0 --- /dev/null +++ b/frontend/src/components/mail/MailFolderTree.tsx @@ -0,0 +1,114 @@ +/** + * Mail folder tree sidebar component. + * Shows hierarchical folder list with unread badges. + */ + +import React from 'react'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import type { MailFolder } from '@/api/mail'; +import { Badge } from '@/components/ui/Badge'; +import { EmptyState } from '@/components/ui/EmptyState'; + +export interface MailFolderTreeProps { + folders: MailFolder[]; + selectedFolderId: string | null; + onSelect: (folderId: string) => void; + loading: boolean; +} + +function FolderNode({ + folder, + selectedFolderId, + onSelect, + depth, +}: { + folder: MailFolder; + selectedFolderId: string | null; + onSelect: (folderId: string) => void; + depth: number; +}) { + const { t } = useTranslation(); + const isSelected = folder.id === selectedFolderId; + const hasChildren = folder.children && folder.children.length > 0; + + return ( +
+ + {hasChildren && ( +
+ {folder.children!.map((child) => ( + + ))} +
+ )} +
+ ); +} + +export function MailFolderTree({ folders, selectedFolderId, onSelect, loading }: MailFolderTreeProps) { + const { t } = useTranslation(); + + if (loading) { + return ( +
+ + {t('common.loading')} +
+ ); + } + + if (folders.length === 0) { + return ( + + ); + } + + return ( +
+ {folders.map((folder) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/mail/MailList.tsx b/frontend/src/components/mail/MailList.tsx new file mode 100644 index 0000000..a463e06 --- /dev/null +++ b/frontend/src/components/mail/MailList.tsx @@ -0,0 +1,134 @@ +/** + * Mail list component with pagination. + * Shows list of mails in selected folder with seen/flagged indicators. + */ + +import React from 'react'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import type { Mail } from '@/api/mail'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { Pagination } from '@/components/ui/Pagination'; + +export interface MailListProps { + mails: Mail[]; + selectedMailId: string | null; + onSelectMail: (mail: Mail) => void; + loading: boolean; + currentPage: number; + total: number; + pageSize: number; + onPageChange: (page: number) => void; +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +export function MailList({ + mails, + selectedMailId, + onSelectMail, + loading, + currentPage, + total, + pageSize, + onPageChange, +}: MailListProps) { + const { t } = useTranslation(); + const totalPages = Math.ceil(total / pageSize); + + if (loading) { + return ( +
+ + {t('common.loading')} +
+ ); + } + + if (mails.length === 0) { + return ( + + ); + } + + return ( +
+
    + {mails.map((mail) => ( +
  • + +
  • + ))} +
+ {totalPages > 1 && ( + + )} +
+ ); +} diff --git a/frontend/src/components/mail/MailSearchBar.tsx b/frontend/src/components/mail/MailSearchBar.tsx new file mode 100644 index 0000000..ce17d94 --- /dev/null +++ b/frontend/src/components/mail/MailSearchBar.tsx @@ -0,0 +1,46 @@ +/** + * Mail search bar — input for full-text mail search. + */ + +import React, { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Input } from '@/components/ui/Input'; + +export interface MailSearchBarProps { + onSearch: (query: string) => void; +} + +export function MailSearchBar({ onSearch }: MailSearchBarProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(''); + + const handleChange = useCallback((e: React.ChangeEvent) => { + setQuery(e.target.value); + }, []); + + const handleSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + onSearch(query.trim()); + }, [query, onSearch]); + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/mail/PgpSettings.tsx b/frontend/src/components/mail/PgpSettings.tsx new file mode 100644 index 0000000..aed16d6 --- /dev/null +++ b/frontend/src/components/mail/PgpSettings.tsx @@ -0,0 +1,188 @@ +/** + * PGP settings — import private key, view contact public keys. + */ + +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 { + importPgpKey, + fetchPgpKeys, + fetchContactPgpKeys, + storeContactPgpKey, + type PgpKey, + type ContactPgpKey, +} from '@/api/mail'; + +export function PgpSettings() { + const { t } = useTranslation(); + const toast = useToast(); + const [keys, setKeys] = useState([]); + const [contactKeys, setContactKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [privateKey, setPrivateKey] = useState(''); + const [passphrase, setPassphrase] = useState(''); + const [importing, setImporting] = useState(false); + const [contactId, setContactId] = useState(''); + const [contactPublicKey, setContactPublicKey] = useState(''); + const [storingContact, setStoringContact] = useState(false); + + const load = useCallback(() => { + setLoading(true); + Promise.all([fetchPgpKeys(), fetchContactPgpKeys()]) + .then(([k, ck]) => { + setKeys(k); + setContactKeys(ck); + setError(null); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + const handleImport = useCallback(async () => { + if (!privateKey.trim()) return; + setImporting(true); + try { + const result = await importPgpKey({ private_key: privateKey, passphrase: passphrase || undefined }); + setKeys((prev) => [...prev, result]); + toast.success(t('mail.pgpKeyImported')); + setPrivateKey(''); + setPassphrase(''); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setImporting(false); + } + }, [privateKey, passphrase, toast, t]); + + const handleStoreContactKey = useCallback(async () => { + if (!contactId.trim() || !contactPublicKey.trim()) return; + setStoringContact(true); + try { + await storeContactPgpKey(contactId, { public_key: contactPublicKey }); + toast.success(t('mail.contactPgpStored')); + setContactId(''); + setContactPublicKey(''); + load(); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setStoringContact(false); + } + }, [contactId, contactPublicKey, toast, t, load]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + return ( +
+ +
+
+ +