diff --git a/.env.example b/.env.example index 99f857fa..1b952ce6 100644 --- a/.env.example +++ b/.env.example @@ -27,11 +27,6 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Set to "false" to disable if using a non-Stalwart JMAP server. # STALWART_FEATURES=true -# If your reverse proxy doesn't forward Stalwart management API paths -# (/api/account/*, /api/principal/*), set this to the URL where Stalwart's -# HTTP listener is directly reachable. Defaults to JMAP_SERVER_URL if not set. -# STALWART_API_URL=https://admin.example.com - # ============================================================================= # OAuth / OpenID Connect (optional) # ============================================================================= diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aebdf4e..9dcb8df1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## 1.5.0 (2026-04-22) + +### Breaking Changes + +- **Self-service portal now needs Stalwart 0.16+**: Stalwart dropped its self-service HTTP API in 0.16.0 and replaced it with JMAP. Bulwark Webmail only talks to the new JMAP endpoint, so the self-service portal (account settings, app passwords, API keys) requires Stalwart 0.16 or newer. `STALWART_API_URL` is deprecated, these actions go through the normal JMAP session. + +### Features + +- **Stalwart**: Migrate Stalwart management API to JMAP `x:` methods for Stalwart 0.16 +- **Admin**: Add API Keys management and IP allowlist for App Passwords +- **Contacts**: Revamp contact detail view with filters, photo, print, and duplicate actions +- **Contacts**: Add contact activity component showing recent emails and upcoming events +- **Contacts**: Add right-click context menu +- **Contacts**: Group contacts by first letter with sticky section headers, toggleable in settings +- **Calendar**: Support resizing events from the top edge +- **Calendar**: Add timezone-aware formatting for event start times and update `utcEnd` on duration change +- **Calendar**: Optimize layout of overlapping events +- **Calendar**: Add collapsible details to calendar invitation banner +- **Email**: Implement batch archiving and bulk moving of emails +- **Email**: Show full folder path in move/drop toast +- **Settings**: Reorganize settings into 6 groups with clearer tabs +- **Navigation**: Add account-addition button to the navigation rail +- **Mobile**: Streamline email viewer header layout +- **Mobile**: Pass `isMobile` through calendar views and time-grid interactions + +### Fixes + +- **Mailbox**: Retry mailbox fetch on first login to handle lazy provisioning (#217) +- **Mailbox**: Use fresh state in archive handling to avoid stale mailbox data +- **Mailbox**: Improve error message on mailbox creation failure +- **Auth**: Skip `checkAuth` on route change when already authenticated +- **Auth**: Clean up unused imports and improve TOTP QR code rendering +- **UI**: Align hover styles and selection-toggle target with focused item +- **UI**: Read `matchMedia` synchronously on client to prevent layout flicker + +### Refactor + +- **Settings**: Remove Stalwart API URL configuration (now derived via JMAP) + +### Chore + +- **i18n**: Add missing translation keys +- **Deps**: Bump dependencies to latest compatible versions + ## 1.4.14 (2026-04-16) Thank you for your donations: diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 00000000..79a69120 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,124 @@ +# Features + +## Mail + +- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding) +- Gmail-style threading with inline expansion and an optional conversation toggle +- Unified mailbox view across all connected accounts +- Draft auto-save with identity preservation +- Attachment upload, download, and inline preview; forgotten-attachment warning +- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries +- Batch operations – multi-select, archive, delete, move, tag +- Archive modes – direct, by year, or by month +- Multi-tag support with color labels, reordering, and drag-and-drop assignment +- Star/unstar with configurable mark-as-read delay +- Virtual scrolling for large mailboxes +- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers +- Plain-text composer mode and Reply-To support +- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping +- Folder management with icon picker, subfolders, and sidebar counts +- Print directly from the viewer +- Browser history sync for back/forward navigation + +## Calendar + +- Month, week, day, and agenda views with a mini-calendar sidebar and task list +- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap +- Recurring events with scoped edit/delete (this / this and following / all) +- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment +- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import +- iCalendar import with preview, bulk create, and UID deduplication +- iCal / webcal subscriptions with editing and batch import +- Auto-generated birthday calendar from contacts +- Virtual locations (video conference URLs) as first-class event fields +- Task management with due dates, priority, and completion status +- Shared calendars with CalDAV discovery and multi-account home resolution +- Week numbers, event hover preview, notifications with sound picker +- Real-time sync via JMAP push + +## Contacts + +- JMAP sync (RFC 9553 / 9610) with local fallback +- Multiple address books with drag-and-drop between books +- Contact groups with member management +- vCard import/export (RFC 6350) with duplicate detection +- Trusted senders stored in a dedicated JMAP address book +- Autocomplete in the composer (To / Cc / Bcc) + +## Filters & Templates + +- Server-side filters via JMAP Sieve Scripts (RFC 9661) +- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) +- Preserves rules authored in other clients +- Raw Sieve editor with syntax validation +- Vacation responder with date range scheduling +- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …) + +## Files + +- JMAP FileNode browser (Stalwart native cloud storage) +- Streamed WebDAV PUT upload and folder upload with progress tracking +- Dynamic upload limits based on server configuration +- Grid and list views with sorting by name, size, or date +- Previews for images, text, audio, and video +- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files + +## Security & Privacy + +- External content blocked by default, with a trusted senders list +- HTML sanitization via DOMPurify +- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation +- SPF / DKIM / DMARC status indicators +- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments +- TOTP two-factor authentication +- Account security panel for password and 2FA management via the Stalwart admin API +- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie +- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention +- Plugin hardening with dangerous-pattern detection and admin approval +- Newsletter unsubscribe (RFC 2369) + +## Interface + +- Three-pane layout with resizable columns +- Dark and light themes with intelligent email color transformation +- Responsive desktop, tablet, and mobile layouts +- Full keyboard navigation +- Drag-and-drop email organization and tag assignment +- Interactive guided tour for new users +- Right-click context menus, toast notifications with undo +- Customizable toolbar position, favicon, and login branding +- Pinnable sidebar apps with drag-and-drop reordering +- Encrypted settings sync across devices +- Storage quota display +- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions + +## Internationalization + +14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська + +Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. + +## Identity & Multi-Account + +- Up to 5 simultaneous accounts with instant switching and per-account session persistence +- Account switcher with connection status and default account selection +- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list +- Sub-addressing (`user+tag@domain.com`) with contextual tag suggestions +- Shared folders across accounts +- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) + +## Admin & Extensibility + +- Stalwart admin dashboard with dedicated policy sections +- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event slots, and managed policy enforcement +- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles +- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`) +- Bundled plugins including Jitsi Meet calendar integration + +## Operations + +- Progressive Web App with service worker, install prompt, and dynamic manifest +- Automatic update check with server-side logging of new releases +- Structured logging (`text` or `json`) with category-based levels +- Release (`main`) and development (`dev`) Docker images on GHCR +- Demo mode with fixture data – no mail server required diff --git a/README.md b/README.md index 0feff814..c1352b3b 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,11 @@ # Bulwark Webmail -A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/).
-Built with Next.js and the JMAP protocol. +A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.4.14-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.5.0-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) @@ -22,228 +21,56 @@ Built with Next.js and the JMAP protocol. ## Screenshots + + + Mail view + + - - + + - - + + + + + + + + + + + + + + + + + +
- -Inbox - three-pane layout with sidebar, email list, and viewer (dark mode) - -**Mail** - Three-pane layout with sidebar, email list, and viewer - - - -Calendar - -**Calendar** - Month, week, day, and agenda views with event management - -CalendarContacts
- -Contacts - -**Contacts** - Contact management with groups and vCard support - - - -File browser - -**Files** - Cloud file browser with upload, preview, and folder navigation - -Calendar – month, week, day, and agenda views with drag-to-reschedule, iMIP invitations, and CalDAV subscriptions.Contacts – multiple address books, groups, vCard import/export, and autocomplete in the composer.
ThemesPlugins
Themes – bundled color themes or upload your own as ZIP bundles; admins can enforce presets.Plugins – extend the client with bundled or third-party plugins installed from a .zip file.
Light modeSettings
Light mode – full theme support with intelligent color transformation for HTML emails.Settings – appearance, identities, filters, templates, security, and more.
-
-More screenshots - - - - - - - - - -
+## Overview -Inbox - light mode +Bulwark is a full webmail suite – not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login: -**Light mode** - Full theme support with intelligent color transformation +- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates +- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions +- **Contacts** – multiple address books, groups, vCard import/export +- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload - +Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a Stalwart admin dashboard. -Settings - -**Settings** - Appearance, identities, filters, templates, and more - -
- -Login page - -**Login** - Configurable branding with OAuth2/OIDC and 2FA support - - -
-
- -## Features - -### Mail - -- **Read, compose, reply, reply-all, forward** with rich HTML rendering and a Tiptap-based rich text editor (inline image upload, drag-and-drop embedding) -- **Threading** - Gmail-style inline expansion with thread navigation; optional conversation threading toggle -- **Unified mailbox** - view emails across all accounts in a single list -- **Draft auto-save** with discard confirmation; full draft editing with identity preservation -- **Attachments** - upload, download, and inline preview; attachment-keyword warning if a file is forgotten -- **Search** - full-text with JMAP filter panel, search chips, cross-mailbox queries, wildcard support, and OR conditions -- **Batch operations** - multi-select with checkboxes, archive, delete, move, tag -- **Archive modes** - archive directly or organize archived mail by year or month -- **Print** emails directly from the viewer -- **Answered/forwarded status icons** in email list and thread views -- **Multi-tag per email** with color labels, reorderable tags, and drag-and-drop tag assignment -- **Star/unstar** with configurable mark-as-read delay -- **Virtual scrolling** for large mailboxes -- **Quick reply** from the viewer -- **Hover actions** - configurable quick-action buttons on email rows with customizable placement -- **Sender avatars** - favicon-based with negative caching for performance -- **Recipient popover** for quick contact interaction -- **Plain text composer mode** and auto-select reply identity -- **Reply-to addresses** support in the composer -- **TNEF support** - extract Outlook `winmail.dat` message bodies and attachments automatically -- **message/rfc822 unwrapping** for embedded messages -- **Folder management** - create, rename, delete folders with icon picker and subfolder support -- **Tag counts** - unread and total counts displayed in sidebar -- **Browser history sync** - back/forward navigation mirrors mail view state - -### Calendar - -- **Month, week, day, and agenda views** with mini-calendar sidebar and a dedicated task list view -- **Event hover preview** popover with configurable details -- **Drag-and-drop rescheduling**, click-drag or double-click creation, edge-resize (15-min snap) -- **Recurring events** with edit/delete scope (this / this and following / all) and client-side recurrence expansion -- **Participant scheduling** - iMIP invitations sent on create and update (RFC 5545/6047 compliant), organizer/attendee UI, RSVP with trust assessment -- **Inline calendar invitations** in email viewer - auto-detect `.ics`, RSVP, import -- **iCalendar import** with preview, bulk create, and UID deduplication -- **iCal / webcal subscriptions** with editing and batch import -- **Birthday calendar** - auto-generated from contacts -- **Virtual locations** - video conference URLs as first-class event fields -- **Task management** - create, edit, and track tasks with due dates, priority, and completion status; external CalDAV client detection (Thunderbird) -- **Shared calendars** with visual grouping in the sidebar -- **CalDAV discovery** with automatic calendar home resolution for multi-account setups -- **Week numbers** in mini-calendar sidebar -- **Notifications** with configurable sound, alert persistence, and sound picker with preview playback -- **Real-time sync** via JMAP push - -### Contacts - -- **Contact management** with JMAP sync (RFC 9553/9610) and local fallback -- **Multiple address books** - create, rename, drag-and-drop between books, with editor picker in contact form -- **Collapsible sidebar** with address book grouping and bulk operations -- **Contact groups** with group expansion and member management -- **vCard import/export** (RFC 6350) with duplicate detection -- **Trusted senders** stored in a dedicated JMAP address book -- **Autocomplete** in composer (To/Cc/Bcc) -- **Bulk operations** - multi-select, delete, group add, export - -### Filters & Automation - -- **Server-side email filters** via JMAP Sieve Scripts (RFC 9661) -- **Visual rule builder** - conditions (From, To, Subject, Size, Body…) and actions (Move, Forward, Star, Discard…) with an expanded visual view -- **External rule preservation** - rules authored in other clients are displayed and preserved -- **Raw Sieve editor** with syntax validation -- **Vacation responder** with date range scheduling and sidebar indicator -- **Email templates** - reusable, categorized, with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, etc.) - -### Files - -- **File browser** with JMAP FileNode cloud storage (Stalwart native) -- **Upload and download** files with progress tracking, folder upload, and streamed WebDAV PUT (no in-memory buffering) -- **Dynamic upload limits** - respects the server-configured maximum upload size -- **Folder navigation** with breadcrumb path and tree sidebar -- **Grid and list views** with sorting by name, size, or date -- **Clipboard operations** - cut, copy, paste, duplicate files -- **File preview** for images, text, audio, video, and more -- **Favorites and recent files** for quick access -- **Bulk operations** - multi-select, delete, move, download - -### Security & Privacy - -- **External content blocked** by default - trusted senders list for auto-load -- **HTML sanitization** via DOMPurify with XSS prevention -- **S/MIME** - manage certificates, sign outgoing mail, encrypt to recipients, decrypt messages, and verify signatures; self-signed certificate detection; legacy 3DES / PBE support; per-account key isolation -- **SPF/DKIM/DMARC** status indicators -- **OAuth2/OIDC with PKCE** for SSO (Keycloak, Authentik, or built-in), with OAuth-only mode, OAuth app passwords, configurable scopes, and non-interactive SSO for embedded/iframe deployments -- **TOTP two-factor authentication** -- **Account security panel** - manage passwords and 2FA via Stalwart admin API -- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in) -- **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention -- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure) -- **Newsletter unsubscribe** (RFC 2369) - -### Interface - -- **Three-pane layout** - sidebar, email list, viewer with resizable columns -- **Dark and light themes** with intelligent email color transformation -- **Always-light email rendering** option for problematic HTML messages in dark theme -- **Responsive** - desktop sidebar + mobile bottom tab bar with tablet support -- **Keyboard shortcuts** - full navigation without a mouse -- **Drag-and-drop** email organization between mailboxes and tag assignment -- **Interactive guided tour** - onboarding walkthrough for new users -- **Right-click context menus**, toast notifications with undo, form validation with shake feedback -- **Customizable toolbar** position, custom favicon, sidebar/login logos, and login page branding -- **Sidebar apps** - pin custom tools to the navigation rail with drag-and-drop reordering, mobile visibility toggles, and inline or new-tab launch modes -- **Settings sync** - preferences synchronized with the server (encrypted) -- **Storage quota** display -- **Version badge** in settings -- **Focused mode** with proper viewport bounds -- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions - -### Internationalization - -14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська - -Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. - -### Identity Management - -- **Multiple sender identities** with per-identity signatures -- **Automatic identity synchronization** and refresh to keep the identity manager aligned with server-side changes -- **Sub-addressing** - `user+tag@domain.com` with contextual tag suggestions -- **Identity badges** in viewer and email list - -### Multi-Account - -- **Up to 5 simultaneous accounts** with instant switching and per-account session persistence -- **Account switcher** with connection status, default account selection, and per-account logout -- **Per-account settings** - encrypted settings storage with server-side sync -- **Shared folders** across accounts -- **Custom JMAP server endpoints** - optionally let users connect to any JMAP server from the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) - -### Admin & Extensibility - -- **Stalwart admin dashboard** - sidebar access with reorganized dashboard and dedicated policy sections -- **Plugin system** - schema-driven admin config UI, render and intercept hooks, `onAvatarResolve` and i18n APIs, calendar event action slots, forced enable/disable and managed policy enforcement -- **Themes** - upload, enforce, and manage admin-controlled themes with ZIP bundles -- **Extension marketplace** - browse and install plugins/themes from a configurable directory (`EXTENSION_DIRECTORY_URL`) -- **Bundled plugins** - Jitsi Meet calendar integration - -### Operations - -- **Progressive Web App (PWA)** - installable with service worker, install prompt, and dynamic manifest (app name, description, icons, theme and background colors) -- **Automatic update check** - server logs when a newer release is available -- **Logging categories** with `text` or `json` formats for log aggregation -- **Docker images** - release (`main`) and development (`dev`) channels on GHCR -- **Demo mode** - try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes - no mail server required +Full feature list: **[FEATURES.md](FEATURES.md)**. --- ## Quick Start -### Docker (recommended) +### Docker ```bash docker run -d -p 3000:3000 \ @@ -255,7 +82,7 @@ Or with Docker Compose: ```bash cp .env.example .env.local -# Edit .env.local - set JMAP_SERVER_URL +# Edit .env.local – set JMAP_SERVER_URL docker compose up -d ``` @@ -266,21 +93,21 @@ git clone https://github.com/bulwarkmail/webmail.git cd webmail npm install cp .env.example .env.local -# Edit .env.local - set JMAP_SERVER_URL +# Edit .env.local – set JMAP_SERVER_URL npm run build && npm start ``` ### Development ```bash -npm run dev # Start dev server (mock JMAP server included) -npm run typecheck # Type checking -npm run lint # Linting +npm run dev # Dev server with a mock JMAP server +npm run typecheck +npm run lint ``` ## Configuration -Edit `.env.local`: +All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`: ```env # Required @@ -290,27 +117,25 @@ JMAP_SERVER_URL=https://mail.example.com APP_NAME=My Webmail ``` -All variables are **runtime** - Docker deployments can be configured without rebuilding. -
-Server Listen Address +Server listen address ```env HOSTNAME=0.0.0.0 # Default; use "::" for IPv6 -PORT=3000 # Default listen port +PORT=3000 ```
-OAuth2/OIDC (SSO) +OAuth2 / OIDC ```env OAUTH_ENABLED=true OAUTH_CLIENT_ID=webmail OAUTH_CLIENT_SECRET= # optional, for confidential clients -OAUTH_CLIENT_SECRET_FILE= # Path to a file containing the client secret -OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik) +OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret +OAUTH_ISSUER_URL= # optional, for external IdPs ``` Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. @@ -318,29 +143,28 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
-Remember Me & Settings Sync +Session & settings sync ```env -SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32 -SESSION_SECRET_FILE=/session-secret # Path to a file containing the session secret +SESSION_SECRET= # openssl rand -base64 32 +SESSION_SECRET_FILE=/session-secret # path to a file containing the secret -SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server -SETTINGS_DATA_DIR=./data/settings # Storage location (mount a volume in Docker) +SETTINGS_SYNC_ENABLED=true +SETTINGS_DATA_DIR=./data/settings # mount as a volume in Docker ``` -Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day expiry). -Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`. +Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry). Settings sync stores per-account preferences encrypted at rest and requires `SESSION_SECRET`.
-Custom JMAP Endpoint +Custom JMAP endpoint ```env -ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login +ALLOW_CUSTOM_JMAP_ENDPOINT=true ``` -Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin. +Shows a "JMAP Server" field on the login form. External servers must CORS-allow the webmail origin.
@@ -349,13 +173,13 @@ Lets users connect to any JMAP-compatible server. External servers must CORS-all ```env APP_NAME=My Webmail -APP_SHORT_NAME=Webmail # Home-screen label on mobile -APP_DESCRIPTION=Your personal mail # Shown during PWA install +APP_SHORT_NAME=Webmail +APP_DESCRIPTION=Your personal mail FAVICON_URL=/branding/favicon.svg -PWA_ICON_URL=/branding/icon.svg # Falls back to FAVICON_URL -PWA_THEME_COLOR=#3b82f6 # Browser chrome color -PWA_BACKGROUND_COLOR=#ffffff # PWA splash background +PWA_ICON_URL=/branding/icon.svg # falls back to FAVICON_URL +PWA_THEME_COLOR=#3b82f6 +PWA_BACKGROUND_COLOR=#ffffff APP_LOGO_LIGHT_URL=/branding/logo-light.svg APP_LOGO_DARK_URL=/branding/logo-dark.svg @@ -371,7 +195,7 @@ LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
-Extension Directory +Extension directory ```env EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org @@ -382,14 +206,13 @@ Enables the admin marketplace for browsing and installing plugins and themes.
-Stalwart Integration & Logging +Stalwart integration & logging ```env -STALWART_FEATURES=true # Password change, sieve filters, etc. -STALWART_API_URL=https://admin.example.com # If reverse proxy doesn't forward /api/* +STALWART_FEATURES=true # password change, Sieve filters, etc. LOG_FORMAT=text # "text" or "json" -LOG_LEVEL=info # "error", "warn", "info", "debug" +LOG_LEVEL=info # error | warn | info | debug ```
@@ -424,19 +247,16 @@ LOG_LEVEL=info # "error", "warn", "info", "debug" ## Why Stalwart? -[Stalwart](https://github.com/stalwartlabs/mail-server) is a mail server written in Rust with **native JMAP support** - not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single binary. Self-hosted, no third-party dependencies. +[Stalwart](https://github.com/stalwartlabs/mail-server) is a Rust mail server with native JMAP support – not IMAP/SMTP with JMAP bolted on. It handles JMAP, IMAP, SMTP, and ManageSieve in a single self-hosted binary with no third-party dependencies. ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. +See [CONTRIBUTING.md](CONTRIBUTING.md). ## License -[GNU AGPL v3](LICENSE) - -This repository also preserves the original MIT attribution notice for the -fork lineage in [NOTICE](NOTICE). +[GNU AGPL v3](LICENSE). This repository preserves the original MIT attribution for the fork lineage in [NOTICE](NOTICE). ## Acknowledgments -Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for doing most of the groundwork that this project builds upon. +Thanks to [root-fr/jmap-webmail](https://github.com/root-fr/jmap-webmail/) and [@ma2t](https://github.com/ma2t) for the groundwork this project builds upon. diff --git a/VERSION b/VERSION index 323afbcd..bc80560f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.14 +1.5.0 diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 9d867766..1b67de9c 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -126,8 +126,15 @@ export default function CalendarPage() { } }, [events, detailEvent]); - // Check auth on mount + // Check auth on mount – skip when already authenticated so that navigating + // between routes doesn't retrigger checkAuth's transient `{ client: null, + // isLoading: true }` reset, which was flashing the spinner on every nav. useEffect(() => { + const state = useAuthStore.getState(); + if (state.isAuthenticated && state.client) { + setInitialCheckDone(true); + return; + } checkAuth().finally(() => { setInitialCheckDone(true); }); diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index bb078413..1190019f 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -98,13 +98,20 @@ export default function ContactsPage() { // Panel resize state - contact list const [listWidth, setListWidth] = useState(() => { - try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; } + try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; } }); const [isListResizing, setIsListResizing] = useState(false); - const listDragStartWidth = useRef(320); + const listDragStartWidth = useRef(384); - // Check auth on mount + // Check auth on mount – skip when already authenticated so that navigating + // between routes doesn't retrigger checkAuth's transient `{ client: null, + // isLoading: true }` reset, which was flashing the spinner on every nav. useEffect(() => { + const state = useAuthStore.getState(); + if (state.isAuthenticated && state.client) { + setInitialCheckDone(true); + return; + } checkAuth().finally(() => { setInitialCheckDone(true); }); @@ -172,21 +179,6 @@ export default function ContactsPage() { return getGroupMembers(activeCategory.groupId); }, [activeCategory, individuals, getGroupMembers]); - // Label for the current category - const categoryLabel = useMemo(() => { - if (activeCategory === "all") return t("tabs.all"); - if (activeCategory === "uncategorized") return t("no_category"); - if ("addressBookId" in activeCategory) { - const book = addressBooks.find(b => b.id === activeCategory.addressBookId); - return book?.name || t("tabs.all"); - } - if ("keyword" in activeCategory) { - return activeCategory.keyword; - } - const group = contacts.find(c => c.id === activeCategory.groupId); - return group ? getContactDisplayName(group) : t("tabs.all"); - }, [activeCategory, contacts, addressBooks, t]); - const handleSelectCategory = useCallback((category: ContactCategory) => { setActiveCategory(category); clearSelection(); @@ -259,9 +251,7 @@ export default function ContactsPage() { setView("edit"); }; - const handleDelete = async () => { - if (!selectedContact) return; - + const deleteContactById = useCallback(async (contactId: string) => { const confirmed = await confirmDialog({ title: t("delete_confirm_title"), message: t("delete_confirm"), @@ -272,18 +262,60 @@ export default function ContactsPage() { try { if (supportsSync && client) { - await deleteContact(client, selectedContact.id); + await deleteContact(client, contactId); } else { - deleteLocalContact(selectedContact.id); + deleteLocalContact(contactId); } toast.success(t("toast.deleted")); - setView("list"); + if (selectedContactId === contactId) setView("list"); } catch (error) { console.error('Failed to delete contact:', error); toast.error(t("toast.error_delete")); } + }, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]); + + const handleDelete = async () => { + if (!selectedContact) return; + await deleteContactById(selectedContact.id); }; + const handleEditContact = useCallback((id: string) => { + setSelectedContact(id); + setView("edit"); + }, [setSelectedContact]); + + const handleDeleteContact = useCallback((contact: ContactCard) => { + void deleteContactById(contact.id); + }, [deleteContactById]); + + const handleAddContactToGroup = useCallback((id: string) => { + clearSelection(); + toggleContactSelection(id); + if (groups.length === 0) { + setView("group-create"); + return; + } + setView("bulk-add-to-group"); + }, [clearSelection, toggleContactSelection, groups.length]); + + const handleDuplicateContact = useCallback(async (source: ContactCard) => { + const { id: _id, created: _created, updated: _updated, ...rest } = source; + void _id; void _created; void _updated; + const data: Partial = JSON.parse(JSON.stringify(rest)); + if (supportsSync && client) { + await createContact(client, data); + toast.success(t("toast.created")); + } else { + const localContact: ContactCard = { + id: `local-${generateUUID()}`, + addressBookIds: data.addressBookIds || {}, + ...data, + }; + addLocalContact(localContact); + toast.success(t("toast.created")); + } + }, [supportsSync, client, createContact, addLocalContact, t]); + const handleSaveNew = useCallback(async (data: Partial) => { if (supportsSync && client) { await createContact(client, data); @@ -585,6 +617,16 @@ export default function ContactsPage() { contact={selectedContact} onEdit={handleEdit} onDelete={handleDelete} + onAddToGroup={ + selectedContact + ? () => handleAddContactToGroup(selectedContact.id) + : undefined + } + onDuplicate={ + selectedContact + ? () => void handleDuplicateContact(selectedContact) + : undefined + } isMobile={isMobile} /> ); @@ -680,7 +722,6 @@ export default function ContactsPage() { onSearchChange={setSearchQuery} onSelectContact={handleSelectContact} onCreateNew={handleCreateNew} - categoryLabel={categoryLabel} className="flex-1" selectedContactIds={selectedContactIds} onToggleSelection={toggleContactSelection} @@ -690,6 +731,9 @@ export default function ContactsPage() { onBulkDelete={handleBulkDelete} onBulkAddToGroup={handleBulkAddToGroup} onBulkExport={handleBulkExport} + onEditContact={handleEditContact} + onDeleteContact={handleDeleteContact} + onAddContactToGroup={handleAddContactToGroup} /> @@ -701,7 +745,7 @@ export default function ContactsPage() { setIsListResizing(false); localStorage.setItem("contacts-list-width", String(listWidth)); }} - onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }} + onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }} /> )} diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx index 0d4c80bc..261e73d6 100644 --- a/app/[locale]/files/page.tsx +++ b/app/[locale]/files/page.tsx @@ -105,8 +105,15 @@ export default function FilesPage() { const detailResource = detailName ? resources.find(r => r.name === detailName) || null : null; - // Check auth on mount + // Check auth on mount – skip when already authenticated so that navigating + // between routes doesn't retrigger checkAuth's transient `{ client: null, + // isLoading: true }` reset, which was flashing the spinner on every nav. useEffect(() => { + const state = useAuthStore.getState(); + if (state.isAuthenticated && state.client) { + setInitialCheckDone(true); + return; + } checkAuth().finally(() => { setInitialCheckDone(true); }); diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 3fc1c43f..197a65a2 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -8,7 +8,7 @@ import { EmailViewer } from "@/components/email/email-viewer"; import { EmailComposer } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer"; import { ThreadConversationView } from "@/components/email/thread-conversation-view"; -import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header"; +import { MobileHeader } from "@/components/layout/mobile-header"; import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types"; import { useAccountStore } from "@/stores/account-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; @@ -455,8 +455,15 @@ export default function Home() { document.title = title; }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]); - // Check auth on mount + // Check auth on mount – skip when already authenticated so that navigating + // between routes doesn't retrigger checkAuth's transient `{ client: null, + // isLoading: true }` reset, which was flashing the spinner on every nav. useEffect(() => { + const state = useAuthStore.getState(); + if (state.isAuthenticated && state.client) { + setInitialCheckDone(true); + return; + } checkAuth().finally(() => { setInitialCheckDone(true); }); @@ -493,7 +500,10 @@ export default function Home() { // Load mailboxes and emails when authenticated (only if not already loaded) useEffect(() => { if (isAuthenticated && client && mailboxes.length === 0) { - const loadData = async () => { + let retryTimer: ReturnType | null = null; + let cancelled = false; + + const loadData = async (attempt = 1) => { try { // First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes) await Promise.all([ @@ -505,6 +515,15 @@ export default function Home() { const state = useEmailStore.getState(); const selectedMailboxId = state.selectedMailbox; + // On first login the server may still be provisioning mailboxes. + // Retry a few times with back-off before giving up. + if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) { + const delay = Math.min(1000 * attempt, 5000); + debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`); + retryTimer = setTimeout(() => loadData(attempt + 1), delay); + return; + } + // Fetch emails for the selected mailbox if (selectedMailboxId) { await fetchEmails(client, selectedMailboxId); @@ -538,6 +557,12 @@ export default function Home() { } }; loadData(); + + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + client.closePushNotifications(); + }; } // Cleanup push notifications on unmount @@ -813,8 +838,11 @@ export default function Home() { const handleArchive = async (emailToArchive: Email | null = selectedEmail) => { if (!client || !emailToArchive) return; - // Find archive mailbox - const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive"); + // Read fresh mailboxes from the store – batch archive calls this in a loop, + // and each iteration needs to see folders created by prior iterations. + const currentMailboxes = useEmailStore.getState().mailboxes; + + const archiveMailbox = currentMailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive"); if (!archiveMailbox) return; const { archiveMode } = useSettingsStore.getState(); @@ -823,14 +851,12 @@ export default function Home() { if (archiveMode === 'single') { await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id); } else { - // Determine year/month from the email's received date const emailDate = new Date(emailToArchive.receivedAt); const year = emailDate.getFullYear().toString(); const month = (emailDate.getMonth() + 1).toString().padStart(2, '0'); const archiveId = archiveMailbox.originalId || archiveMailbox.id; - // Find or create year subfolder under archive - let yearMailbox = mailboxes.find( + let yearMailbox = currentMailboxes.find( m => m.name === year && m.parentId === archiveId ); if (!yearMailbox) { @@ -841,9 +867,9 @@ export default function Home() { if (archiveMode === 'year') { await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id); } else { - // archiveMode === 'month' - find or create month subfolder under year const yearId = yearMailbox.originalId || yearMailbox.id; - let monthMailbox = mailboxes.find( + const afterYear = useEmailStore.getState().mailboxes; + let monthMailbox = afterYear.find( m => m.name === month && m.parentId === yearId ); if (!monthMailbox) { @@ -1492,9 +1518,12 @@ export default function Home() { } else { selectAllEmails(); } - } else { - // Enter selection mode by selecting the first email - if (emails.length > 0) toggleEmailSelection(emails[0].id); + } else if (emails.length > 0) { + const currentId = selectedEmail?.id; + const target = currentId && emails.some((e) => e.id === currentId) + ? currentId + : emails[0].id; + toggleEmailSelection(target); } }} className={cn( @@ -1898,14 +1927,6 @@ export default function Home() { /> ) : ( <> - {/* Mobile Header for Viewer */} - {isMobile && activeView === "viewer" && ( - - )} - = { - appearance: Palette, - email: Mail, - notifications: Bell, account: User, - security: Shield, + language: Languages, + notifications: Bell, + appearance: Palette, + layout: LayoutGrid, + reading: BookOpen, + composing: PenLine, identities: UserPen, - encryption: KeyRound, vacation: PalmtreeIcon, - calendar: Calendar, - contacts: BookUser, filters: Filter, templates: FileText, folders: FolderOpen, keywords: Tags, + security: Shield, + encryption: KeyRound, + content_senders: EyeOff, + calendar: Calendar, + contacts: BookUser, files: HardDrive, sidebar_apps: PanelLeftClose, + about_data: Info, themes: Palette, plugins: Puzzle, - advanced: Wrench, + debug: Bug, }; -const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system']; +const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced']; + +// Map legacy tab IDs to current ones; runs once on read of localStorage. +const LEGACY_TAB_MAP: Record = { + email: 'reading', + advanced: 'about_data', +}; + +function readPersistedTab(): Tab { + try { + const saved = localStorage.getItem('settings-active-tab'); + if (!saved) return 'appearance'; + if (saved in LEGACY_TAB_MAP) { + const migrated = LEGACY_TAB_MAP[saved]; + try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ } + return migrated; + } + return saved as Tab; + } catch { + return 'appearance'; + } +} export default function SettingsPage() { const router = useRouter(); @@ -107,13 +168,7 @@ export default function SettingsPage() { const { quota, isPushConnected } = useEmailStore(); const { stalwartFeaturesEnabled } = useConfig(); const { isFeatureEnabled } = usePolicyStore(); - const [activeTab, setActiveTab] = useState(() => { - try { - const saved = localStorage.getItem('settings-active-tab'); - if (saved) return saved as Tab; - } catch { /* ignore */ } - return 'appearance'; - }); + const [activeTab, setActiveTab] = useState(readPersistedTab); const [mobileShowContent, setMobileShowContent] = useState(false); const isDesktop = useIsDesktop(); @@ -124,21 +179,28 @@ export default function SettingsPage() { const [isResizing, setIsResizing] = useState(false); const dragStartWidth = useRef(256); - // Check auth on mount + // Check auth on mount – skip when already authenticated so that navigating + // between routes doesn't retrigger checkAuth's transient `{ client: null, + // isLoading: true }` reset, which was flashing the spinner on every nav. useEffect(() => { + const state = useAuthStore.getState(); + if (state.isAuthenticated && state.client) { + setInitialCheckDone(true); + return; + } checkAuth().finally(() => { setInitialCheckDone(true); }); }, [checkAuth]); - // Listen for tab change events from child components + // Listen for tab change events from child components (with legacy migration) useEffect(() => { const handler = (e: Event) => { - const tab = (e as CustomEvent).detail as Tab; - if (tab) { - setActiveTab(tab); - try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ } - } + const raw = (e as CustomEvent).detail as string; + if (!raw) return; + const tab = (LEGACY_TAB_MAP[raw] ?? raw) as Tab; + setActiveTab(tab); + try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ } }; window.addEventListener('settings-tab-change', handler); return () => window.removeEventListener('settings-tab-change', handler); @@ -161,25 +223,41 @@ export default function SettingsPage() { const supportsFiles = client?.supportsFiles() ?? false; const tabs: TabDef[] = [ - { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' }, - { id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' }, + // General + { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' }, + { id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' }, { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' }, - { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' }, - ...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []), - { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' }, - ...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' as TabGroup }] : []), - ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []), - ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []), - ...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' as TabGroup }] : []), - { id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' }, - ...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' as TabGroup }] : []), + + // Appearance + { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' }, + { id: 'layout', label: t('tabs.layout'), icon: tabIcons.layout, group: 'appearance' }, + + // Mail + { id: 'reading', label: t('tabs.reading'), icon: tabIcons.reading, group: 'mail' }, + { id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' }, + { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' }, + ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []), + ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []), + ...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []), + { id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' }, + ...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []), + + // Privacy & Security + ...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []), + ...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []), + { id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' }, + + // Apps ...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []), { id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' }, ...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []), ...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []), - ...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'system' as TabGroup, experimental: true }] : []), - ...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'system' as TabGroup, experimental: true }] : []), - { id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' }, + + // Advanced + { id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' }, + ...(isFeatureEnabled('themesEnabled') ? [{ id: 'themes' as Tab, label: 'Themes', icon: tabIcons.themes, group: 'advanced' as TabGroup, experimental: true }] : []), + ...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup, experimental: true }] : []), + ...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []), ]; // Group tabs by category @@ -191,6 +269,10 @@ export default function SettingsPage() { })) .filter((g) => g.items.length > 0); + // If active tab is not in the visible list (e.g., feature disabled), fall back. + const isActiveVisible = tabs.some((tab) => tab.id === activeTab); + const effectiveActiveTab: Tab = isActiveVisible ? activeTab : 'appearance'; + const handleTabSelect = (tabId: Tab) => { setActiveTab(tabId); try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ } @@ -199,39 +281,42 @@ export default function SettingsPage() { } }; - const activeTabLabel = tabs.find((tab) => tab.id === activeTab)?.label ?? ''; + const activeTabLabel = tabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? ''; const renderTabContent = () => ( <> - {activeTab === 'appearance' && } - {activeTab === 'email' && } - {activeTab === 'notifications' && } - {activeTab === 'account' && } - {activeTab === 'security' && } - {activeTab === 'identities' && } - {activeTab === 'encryption' && } - {activeTab === 'vacation' && } - {activeTab === 'calendar' && <>
} - {activeTab === 'contacts' && <>
} - {activeTab === 'filters' && } - {activeTab === 'templates' && } - {activeTab === 'folders' && } - {activeTab === 'keywords' && } - {activeTab === 'files' && } - {activeTab === 'sidebar_apps' && } - {activeTab === 'themes' && } - {activeTab === 'plugins' && } - {activeTab === 'advanced' && } + {effectiveActiveTab === 'account' && } + {effectiveActiveTab === 'language' && } + {effectiveActiveTab === 'notifications' && } + {effectiveActiveTab === 'appearance' && } + {effectiveActiveTab === 'layout' && } + {effectiveActiveTab === 'reading' && } + {effectiveActiveTab === 'composing' && } + {effectiveActiveTab === 'identities' && } + {effectiveActiveTab === 'vacation' && } + {effectiveActiveTab === 'filters' && } + {effectiveActiveTab === 'templates' && } + {effectiveActiveTab === 'folders' && } + {effectiveActiveTab === 'keywords' && } + {effectiveActiveTab === 'security' && } + {effectiveActiveTab === 'encryption' && } + {effectiveActiveTab === 'content_senders' && } + {effectiveActiveTab === 'calendar' && <>
} + {effectiveActiveTab === 'contacts' && <>
} + {effectiveActiveTab === 'files' && } + {effectiveActiveTab === 'sidebar_apps' && } + {effectiveActiveTab === 'about_data' && } + {effectiveActiveTab === 'themes' && } + {effectiveActiveTab === 'plugins' && } + {effectiveActiveTab === 'debug' && } ); // Mobile layout if (!isDesktop) { - // Mobile: show content view if (mobileShowContent) { return (
- {/* Mobile content header */}
- {/* Content */}
{renderTabContent()}
- {/* Bottom Navigation */} - {/* Mobile header */}
- {/* Tab list */}
{groupedTabs.map((group, groupIndex) => ( @@ -319,7 +399,6 @@ export default function SettingsPage() { ))}
- {/* Logout */}
- {/* Bottom Navigation */} - {/* Navigation Rail */}
- {/* Settings Sidebar */}
- {/* Header */}
- {/* Tabs */}
{groupedTabs.map((group, groupIndex) => ( @@ -406,14 +480,14 @@ export default function SettingsPage() { onClick={() => setActiveTab(tab.id)} className={cn( 'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5', - activeTab === tab.id + effectiveActiveTab === tab.id ? 'bg-accent text-accent-foreground font-medium' : 'hover:bg-muted text-foreground' )} > {tab.label} {tab.experimental && ( @@ -430,7 +504,6 @@ export default function SettingsPage() {
- {/* Sidebar resize handle */} { dragStartWidth.current = settingsSidebarWidth; setIsResizing(true); }} onResize={(delta) => setSettingsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} @@ -441,10 +514,8 @@ export default function SettingsPage() { onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }} /> - {/* Settings Content */}
- {/* Page Header */}
@@ -452,7 +523,6 @@ export default function SettingsPage() {
- {/* Active Tab Content */}
{renderTabContent()}
diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index f6aff034..1939c9c1 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -122,7 +122,6 @@ export default function AdminSettingsPage() {
)} - diff --git a/app/api/account/stalwart/auth/route.ts b/app/api/account/stalwart/auth/route.ts deleted file mode 100644 index 4fa0f4d0..00000000 --- a/app/api/account/stalwart/auth/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; - -/** - * Parse Stalwart error response to extract meaningful error message - */ -function parseStalwartError(responseText: string): string { - try { - const error = JSON.parse(responseText); - if (error.detail) return error.detail; - if (error.error) return error.error; - return `HTTP ${error.status || 'Error'}`; - } catch { - return responseText; - } -} - -/** - * GET /api/account/stalwart/auth - * Proxy to Stalwart GET /api/account/auth - */ -export async function GET(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const response = await fetch(`${creds.apiUrl}/api/account/auth`, { - method: 'GET', - headers: { 'Authorization': creds.authHeader }, - }); - - if (!response.ok) { - const text = await response.text(); - const detail = parseStalwartError(text); - logger.warn('Stalwart auth info failed', { status: response.status, detail }); - return NextResponse.json( - { error: detail || 'Failed to fetch auth info' }, - { status: response.status } - ); - } - - const data = await response.json(); - return NextResponse.json(data); - } catch (error) { - logger.error('Stalwart auth proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -/** - * POST /api/account/stalwart/auth - * Proxy to Stalwart POST /api/account/auth - */ -export async function POST(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const body = await request.json(); - - const response = await fetch(`${creds.apiUrl}/api/account/auth`, { - method: 'POST', - headers: { - 'Authorization': creds.authHeader, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - const data = await response.json(); - - if (!response.ok) { - logger.warn('Stalwart auth update failed', { status: response.status }); - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data); - } catch (error) { - logger.error('Stalwart auth update proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/app/api/account/stalwart/crypto/route.ts b/app/api/account/stalwart/crypto/route.ts deleted file mode 100644 index 07681115..00000000 --- a/app/api/account/stalwart/crypto/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; - -/** - * Parse Stalwart error response to extract meaningful error message - */ -function parseStalwartError(responseText: string): string { - try { - const error = JSON.parse(responseText); - if (error.detail) return error.detail; - if (error.error) return error.error; - return `HTTP ${error.status || 'Error'}`; - } catch { - return responseText; - } -} - -/** - * GET /api/account/stalwart/crypto - * Proxy to Stalwart GET /api/account/crypto - */ -export async function GET(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const response = await fetch(`${creds.apiUrl}/api/account/crypto`, { - method: 'GET', - headers: { 'Authorization': creds.authHeader }, - }); - - if (!response.ok) { - const text = await response.text(); - const detail = parseStalwartError(text); - logger.warn('Stalwart crypto info failed', { status: response.status, detail }); - return NextResponse.json( - { error: detail || 'Failed to fetch crypto info' }, - { status: response.status } - ); - } - - const data = await response.json(); - return NextResponse.json(data); - } catch (error) { - logger.error('Stalwart crypto proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -/** - * POST /api/account/stalwart/crypto - * Proxy to Stalwart POST /api/account/crypto - */ -export async function POST(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const body = await request.json(); - - const response = await fetch(`${creds.apiUrl}/api/account/crypto`, { - method: 'POST', - headers: { - 'Authorization': creds.authHeader, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - const data = await response.json(); - - if (!response.ok) { - logger.warn('Stalwart crypto update failed', { status: response.status }); - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data); - } catch (error) { - logger.error('Stalwart crypto update proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/app/api/account/stalwart/jmap/route.ts b/app/api/account/stalwart/jmap/route.ts new file mode 100644 index 00000000..7f292c17 --- /dev/null +++ b/app/api/account/stalwart/jmap/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; + +/** + * POST /api/account/stalwart/jmap + * + * Passthrough to Stalwart's JMAP endpoint using the stored basic-auth + * context so the browser does not need access to the user's credentials. + * + * Body: standard JMAP request `{ using: string[], methodCalls: [...] }` + * + * In Stalwart 0.16 all management operations (password change, app + * passwords, API keys, account settings, etc.) are exposed as JMAP + * methods under the `x:` namespace on the same endpoint. + */ +export async function POST(request: NextRequest) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.text(); + + const response = await fetch(`${creds.serverUrl}/jmap/`, { + method: 'POST', + headers: { + 'Authorization': creds.authHeader, + 'Content-Type': 'application/json', + }, + body, + }); + + const responseText = await response.text(); + return new NextResponse(responseText, { + status: response.status, + headers: { 'Content-Type': response.headers.get('Content-Type') || 'application/json' }, + }); + } catch (error) { + logger.error('Stalwart JMAP passthrough error', { + error: error instanceof Error ? error.message : 'Unknown', + }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/account/stalwart/password/route.ts b/app/api/account/stalwart/password/route.ts deleted file mode 100644 index e229e63f..00000000 --- a/app/api/account/stalwart/password/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; -import { logger } from '@/lib/logger'; -import { encryptSession } from '@/lib/auth/crypto'; -import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; -import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context'; - -const COOKIE_OPTIONS = { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' as const, - path: '/', - maxAge: SESSION_COOKIE_MAX_AGE, -}; - -/** - * POST /api/account/stalwart/password - * Change user password via Stalwart PATCH /api/principal/{name} - * - * Body: { currentPassword: string, newPassword: string } - */ -export async function POST(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const { currentPassword, newPassword } = await request.json(); - - if (!currentPassword || !newPassword) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); - } - - if (newPassword.length < 8) { - return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 }); - } - - // Verify current password by attempting to authenticate - const verifyAuth = `Basic ${Buffer.from(`${creds.username}:${currentPassword}`).toString('base64')}`; - const verifyResponse = await fetch(`${creds.serverUrl}/.well-known/jmap`, { - method: 'GET', - headers: { 'Authorization': verifyAuth }, - }); - - if (!verifyResponse.ok) { - return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 }); - } - - // Change password via Stalwart principal API - const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, { - method: 'PATCH', - headers: { - 'Authorization': creds.authHeader, - 'Content-Type': 'application/json', - }, - body: JSON.stringify([ - { action: 'set', field: 'secrets', value: newPassword }, - ]), - }); - - if (!response.ok) { - const text = await response.text(); - logger.warn('Stalwart password change failed', { status: response.status }); - return NextResponse.json( - { error: 'Failed to change password', details: text }, - { status: response.status } - ); - } - - // If session cookie exists, update it with the new password - const cookieStore = await cookies(); - - if (creds.hasSessionCookie) { - const newToken = encryptSession(creds.serverUrl, creds.username, newPassword); - cookieStore.set(sessionCookieName(creds.slot), newToken, COOKIE_OPTIONS); - } - - if (creds.authHeader.startsWith('Basic ')) { - setStalwartAuthContextInStore(cookieStore, creds.slot, { - serverUrl: creds.serverUrl, - username: creds.username, - authHeader: `Basic ${Buffer.from(`${creds.username}:${newPassword}`).toString('base64')}`, - }); - } - - return NextResponse.json({ ok: true }); - } catch (error) { - logger.error('Stalwart password change proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/app/api/account/stalwart/principal/route.ts b/app/api/account/stalwart/principal/route.ts deleted file mode 100644 index 8801c2b6..00000000 --- a/app/api/account/stalwart/principal/route.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; - -/** - * Parse Stalwart error response to extract meaningful error message - */ -function parseStalwartError(responseText: string): string { - try { - const error = JSON.parse(responseText); - if (error.detail) return error.detail; - if (error.error) return error.error; - return `HTTP ${error.status || 'Error'}`; - } catch { - return responseText; - } -} - -/** - * GET /api/account/stalwart/principal - * Proxy to Stalwart GET /api/principal/{username} - */ -export async function GET(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, { - method: 'GET', - headers: { 'Authorization': creds.authHeader }, - }); - - if (!response.ok) { - const text = await response.text(); - const detail = parseStalwartError(text); - logger.warn('Stalwart principal fetch failed', { status: response.status, detail }); - return NextResponse.json( - { error: detail || 'Failed to fetch principal' }, - { status: response.status } - ); - } - - const data = await response.json(); - return NextResponse.json(data); - } catch (error) { - logger.error('Stalwart principal proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -/** - * PATCH /api/account/stalwart/principal - * Proxy to Stalwart PATCH /api/principal/{username} - * Body: PrincipalUpdateAction[] (array of {action, field, value}) - */ -export async function PATCH(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); - } - - const body = await request.json(); - - // Prevent secrets field from being changed through this endpoint (use /password instead) - if (Array.isArray(body)) { - const hasSecrets = body.some((action: { field?: string }) => action.field === 'secrets'); - if (hasSecrets) { - return NextResponse.json({ error: 'Use /api/account/stalwart/password to change passwords' }, { status: 400 }); - } - } - - const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, { - method: 'PATCH', - headers: { - 'Authorization': creds.authHeader, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - const data = await response.json(); - - if (!response.ok) { - logger.warn('Stalwart principal update failed', { status: response.status }); - return NextResponse.json(data, { status: response.status }); - } - - return NextResponse.json(data); - } catch (error) { - logger.error('Stalwart principal update proxy error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} diff --git a/app/api/account/stalwart/probe/route.ts b/app/api/account/stalwart/probe/route.ts deleted file mode 100644 index e7dbe525..00000000 --- a/app/api/account/stalwart/probe/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; - -/** - * GET /api/account/stalwart/probe - * Detect whether the JMAP server is Stalwart by probing /api/account/auth - */ -export async function GET(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ isStalwart: false }); - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - - try { - const response = await fetch(`${creds.apiUrl}/api/account/auth`, { - method: 'GET', - headers: { 'Authorization': creds.authHeader }, - signal: controller.signal, - }); - - clearTimeout(timeout); - - if (!response.ok) { - return NextResponse.json({ isStalwart: false }); - } - - const data = await response.json(); - const isStalwart = data.data !== undefined && typeof data.data.otpEnabled === 'boolean'; - - return NextResponse.json({ isStalwart }); - } catch { - clearTimeout(timeout); - return NextResponse.json({ isStalwart: false }); - } - } catch (error) { - logger.error('Stalwart probe error', { error: error instanceof Error ? error.message : 'Unknown' }); - return NextResponse.json({ isStalwart: false }); - } -} diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 803edd08..3ba03af6 100644 --- a/app/api/admin/auth/route.ts +++ b/app/api/admin/auth/route.ts @@ -7,20 +7,38 @@ import { logger } from '@/lib/logger'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; /** - * Check if the current user is a Stalwart admin by probing an admin-only endpoint. + * Permissions that indicate Stalwart admin privileges. + * If the authenticated user has at least one of these, they can manage + * system-level resources and are considered an admin. + */ +const ADMIN_PERMISSIONS = [ + 'sysAccountQuery', + 'sysTenantQuery', + 'sysSystemSettingsGet', +]; + +/** + * Check if the current user is a Stalwart admin by inspecting the + * permissions list returned by Stalwart's /api/account endpoint. */ async function checkStalwartAdmin(request: NextRequest): Promise { try { const creds = await getStalwartCredentials(request); if (!creds) return false; - // Probe admin-only endpoint: listing principals requires admin privileges - const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, { + const response = await fetch(`${creds.serverUrl}/api/account`, { method: 'GET', headers: { 'Authorization': creds.authHeader }, }); - const isAdmin = response.ok; + if (!response.ok) { + logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin: false }); + return false; + } + + const data = await response.json() as { permissions?: string[] }; + const permissions = Array.isArray(data.permissions) ? data.permissions : []; + const isAdmin = ADMIN_PERMISSIONS.some(p => permissions.includes(p)); logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin }); return isAdmin; } catch (error) { diff --git a/app/api/admin/stalwart-check/route.ts b/app/api/admin/stalwart-check/route.ts deleted file mode 100644 index 4c2c3d6a..00000000 --- a/app/api/admin/stalwart-check/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { logger } from '@/lib/logger'; -import { getStalwartCredentials } from '@/lib/stalwart/credentials'; - -/** - * GET /api/admin/stalwart-check - * Check if the currently logged-in user is a Stalwart admin. - * Probes the admin-only principal-list endpoint - if the user can access it, they're an admin. - */ -export async function GET(request: NextRequest) { - try { - const creds = await getStalwartCredentials(request); - if (!creds) { - return NextResponse.json({ isStalwartAdmin: false }, { - headers: { 'Cache-Control': 'no-store' }, - }); - } - - // Probe an admin-only endpoint: listing principals requires admin privileges. - // Use limit=1 to minimize payload. - const url = `${creds.apiUrl}/api/principal?limit=1`; - const response = await fetch(url, { - method: 'GET', - headers: { 'Authorization': creds.authHeader }, - }); - - const isStalwartAdmin = response.ok; - logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin }); - - return NextResponse.json({ isStalwartAdmin }, { - headers: { 'Cache-Control': 'no-store' }, - }); - } catch (error) { - logger.error('Stalwart admin check error', { - error: error instanceof Error ? error.message : 'Unknown', - }); - return NextResponse.json({ isStalwartAdmin: false }, { - headers: { 'Cache-Control': 'no-store' }, - }); - } -} diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 4c2dc54e..fde1f2af 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -112,10 +112,9 @@ const emails: MockEmail[] = [ preview: 'Hallo! This is a sample email to help you get started with the Bulwark Webmail development environment.', hasAttachment: false, textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }], - htmlBody: [{ partId: 'p2', blobId: 'blob-002', size: 420, type: 'text/html' }], + htmlBody: [], bodyValues: { p1: { value: 'Hallo!\n\nThis is a sample email to help you get started with the Bulwark Webmail development environment.\n\nFeel free to explore the UI - all data here is mock data.\n\nBeste Grüße,\nSophie' }, - p2: { value: '

Hallo!

This is a sample email to help you get started with the Bulwark Webmail development environment.

Feel free to explore the UI - all data here is mock data.

Beste Grüße,
Sophie

' }, }, }, { diff --git a/app/api/webdav/route.ts b/app/api/webdav/route.ts index 85cbbac7..acc19542 100644 --- a/app/api/webdav/route.ts +++ b/app/api/webdav/route.ts @@ -55,7 +55,7 @@ export async function POST(request: NextRequest) { } const davPath = request.headers.get('X-WebDAV-Path') || '/'; - const baseUrl = creds.apiUrl.replace(/\/$/, ''); + const baseUrl = creds.serverUrl.replace(/\/$/, ''); const targetUrl = buildDavTargetUrl(baseUrl, creds.username, davPath); // Build headers for the upstream request diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 62e0d554..cec29b8d 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -119,6 +119,7 @@ export function CalendarDayView({ created: t("notifications.event_created"), error: t("notifications.event_error"), }, + isMobile, }); const formatHour = (h: number): string => { @@ -246,9 +247,11 @@ export function CalendarDayView({ {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { const durMin = Math.max(15, endMinutes - startMinutes); - const top = (startMinutes / 60) * HOUR_HEIGHT; + const baseTop = (startMinutes / 60) * HOUR_HEIGHT; const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT); - const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; + const isResizing = resizeVisual?.eventId === ev.id; + const top = isResizing ? resizeVisual!.topPx : baseTop; + const height = isResizing ? resizeVisual!.heightPx : baseHeight; const calId = getPrimaryCalendarId(ev); const leftPct = (column / totalColumns) * 100; const widthPct = (1 / totalColumns) * 100; @@ -270,11 +273,21 @@ export function CalendarDayView({ onContextMenu={onContextMenuEvent} draggable /> +
handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)} + onPointerMove={handleResizePointerMove} + onPointerUp={handleResizePointerUp} + > +
+
handleResizePointerDown(ev.id, durMin, e)} + onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)} onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerUp} > diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 19aa3d8e..6dc8ac61 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -183,6 +183,7 @@ export function CalendarWeekView({ created: t("notifications.event_created"), error: t("notifications.event_error"), }, + isMobile, }); const formatHour = (h: number): string => { @@ -384,9 +385,11 @@ export function CalendarWeekView({ {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { const durMin = Math.max(15, endMinutes - startMinutes); - const top = (startMinutes / 60) * HOUR_HEIGHT; + const baseTop = (startMinutes / 60) * HOUR_HEIGHT; const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT); - const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; + const isResizing = resizeVisual?.eventId === ev.id; + const top = isResizing ? resizeVisual!.topPx : baseTop; + const height = isResizing ? resizeVisual!.heightPx : baseHeight; const calId = getPrimaryCalendarId(ev); const leftPct = (column / totalColumns) * 100; const widthPct = (1 / totalColumns) * 100; @@ -408,11 +411,21 @@ export function CalendarWeekView({ onContextMenu={onContextMenuEvent} draggable /> +
handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)} + onPointerMove={handleResizePointerMove} + onPointerUp={handleResizePointerUp} + > +
+
handleResizePointerDown(ev.id, durMin, e)} + onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)} onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerUp} > diff --git a/components/calendar/participant-input.tsx b/components/calendar/participant-input.tsx index 082b3898..7b004b92 100644 --- a/components/calendar/participant-input.tsx +++ b/components/calendar/participant-input.tsx @@ -90,13 +90,11 @@ export const ParticipantInput = forwardRef { - setTimeout(() => { - setShowSuggestions(false); - const trimmed = query.trim(); - if (trimmed && EMAIL_REGEX.test(trimmed)) { - addParticipant({ name: "", email: trimmed }); - } - }, 200); + const trimmed = query.trim(); + if (trimmed && EMAIL_REGEX.test(trimmed)) { + addParticipant({ name: "", email: trimmed }); + } + setTimeout(() => setShowSuggestions(false), 200); }, [query, addParticipant]); useImperativeHandle(ref, () => ({ @@ -167,7 +165,22 @@ export const ParticipantInput = forwardRef - {p.name || p.email} + {!disabled ? ( + + ) : ( + {p.name || p.email} + )} {!disabled && ( + ); + })} +
+ )} + + +
+ {eventsLoading ? ( + + ) : eventsError ? ( +

{t("load_failed")}

+ ) : !events || events.length === 0 ? ( +

{t("no_events")}

+ ) : ( +
+ {eventGroups.map(([dateKey, group]) => ( +
+
+ {formatEventDateHeader(dateKey)} +
+
+ {group.map((event) => ( + + ))} +
+
+ ))} +
+ )} +
+
+ ); +} + +function LoadingRow() { + return ( +
+ +
+ ); +} diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx new file mode 100644 index 00000000..19b032c9 --- /dev/null +++ b/components/contacts/contact-context-menu.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { + ContextMenu, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuHeader, +} from "@/components/ui/context-menu"; +import { + Eye, + Pencil, + Mail, + Phone, + ClipboardCopy, + Download, + Users, + Trash2, + Copy, + Printer, +} from "lucide-react"; +import type { ContactCard } from "@/lib/jmap/types"; +import { getContactPrimaryEmail } from "@/stores/contact-store"; +import { exportContact } from "./contact-export"; +import { printContact } from "./contact-print"; +import { toast } from "@/stores/toast-store"; + +function getContactPrimaryPhone(contact: ContactCard): string { + if (!contact.phones) return ""; + return Object.values(contact.phones)[0]?.number || ""; +} + +interface Position { + x: number; + y: number; +} + +interface ContactContextMenuProps { + contact: ContactCard; + position: Position; + isOpen: boolean; + onClose: () => void; + menuRef: React.RefObject; + isMultiSelect?: boolean; + selectedCount?: number; + onOpen: () => void; + onEdit: () => void; + onDelete: () => void; + onAddToGroup: () => void; + onDuplicate?: () => void; + onBatchExport?: () => void; + onBatchAddToGroup?: () => void; + onBatchDelete?: () => void; +} + +export function ContactContextMenu({ + contact, + position, + isOpen, + onClose, + menuRef, + isMultiSelect = false, + selectedCount = 1, + onOpen, + onEdit, + onDelete, + onAddToGroup, + onDuplicate, + onBatchExport, + onBatchAddToGroup, + onBatchDelete, +}: ContactContextMenuProps) { + const t = useTranslations("contacts"); + const email = getContactPrimaryEmail(contact); + const phone = getContactPrimaryPhone(contact); + const showBatchActions = isMultiSelect && selectedCount > 1; + + const handle = (fn: () => void) => () => { + fn(); + onClose(); + }; + + const handleSendEmail = () => { + if (!email) return; + window.location.href = `mailto:${email}`; + }; + + const handleCall = () => { + if (!phone) return; + window.location.href = `tel:${phone}`; + }; + + const handleCopy = async (value: string) => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + toast.success(t("detail.copied")); + } catch { + toast.error(t("detail.copy_failed")); + } + }; + + const handleExport = () => { + exportContact(contact); + toast.success(t("export.success", { count: 1 })); + }; + + const handlePrint = () => { + printContact(contact); + }; + + if (showBatchActions) { + return ( + + + {t("bulk.selected", { count: selectedCount })} + + onBatchAddToGroup?.())} + disabled={!onBatchAddToGroup} + /> + onBatchExport?.())} + disabled={!onBatchExport} + /> + + onBatchDelete?.())} + disabled={!onBatchDelete} + destructive + /> + + ); + } + + return ( + + + + {(email || phone) && } + {email && ( + + )} + {phone && ( + + )} + {email && ( + handleCopy(email))} + /> + )} + {phone && ( + handleCopy(phone))} + /> + )} + + + {onDuplicate && ( + + )} + + + + + + ); +} diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index ffdd8e28..f1f3382b 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -1,22 +1,37 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; -import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react"; +import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react"; import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; -import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; +import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store"; +import { ContactActivity } from "./contact-activity"; import { useSmimeStore } from "@/stores/smime-store"; import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils"; import type { CertificateInfo } from "@/lib/smime/types"; import { toast } from "@/stores/toast-store"; +import { exportContact } from "./contact-export"; +import { printContact } from "./contact-print"; + +type MoreItem = + | { + icon: React.ComponentType<{ className?: string }>; + label: string; + onClick: () => void; + destructive?: boolean; + separator?: false; + } + | { separator: true }; interface ContactDetailProps { contact: ContactCard | null; onEdit: () => void; onDelete: () => void; + onAddToGroup?: () => void; + onDuplicate?: () => void; isMobile?: boolean; className?: string; } @@ -26,9 +41,45 @@ function formatPhoneFeatures(features?: Record): string { return Object.keys(features).filter(k => features[k]).join(", "); } +function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } { + if (typeof dateInput === "object" && dateInput !== null) { + if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") { + const d = new Date(dateInput.utc); + if (!isNaN(d.getTime())) { + return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }; + } + return {}; + } + const pd = dateInput as PartialDate; + return { year: pd.year, month: pd.month, day: pd.day }; + } + const s = String(dateInput); + if (s.startsWith("--")) { + const parts = s.substring(2).split("-"); + return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined }; + } + const d = new Date(s); + if (!isNaN(d.getTime())) { + return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() }; + } + return {}; +} + +function getCompletedYears(dateInput: AnniversaryDate): number | null { + const { year, month, day } = getDateParts(dateInput); + if (!year) return null; + const now = new Date(); + let years = now.getFullYear() - year; + const m = month ?? 1; + const d = day ?? 1; + const nowM = now.getMonth() + 1; + const nowD = now.getDate(); + if (nowM < m || (nowM === m && nowD < d)) years -= 1; + if (years < 0) return null; + return years; +} + function formatDate(dateInput: AnniversaryDate): string { - // Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? } - // Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." } if (typeof dateInput === 'object' && dateInput !== null) { if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') { try { @@ -40,20 +91,15 @@ function formatDate(dateInput: AnniversaryDate): string { return String(dateInput.utc); } const pd = dateInput as PartialDate; - const year = pd.year; - const month = pd.month; - const day = pd.day; const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const parts: string[] = []; - if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]); - if (day) parts.push(String(day)); - if (year) parts.push(String(year)); + if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]); + if (pd.day) parts.push(String(pd.day)); + if (pd.year) parts.push(String(pd.year)); return parts.join(' ') || String(dateInput); } const dateStr = String(dateInput); - // Handle both ISO dates and partial dates like 1990-01-15 or --01-15 if (dateStr.startsWith("--")) { - // Partial date without year const parts = dateStr.substring(2).split("-"); const month = parseInt(parts[0], 10); const day = parts[1] ? parseInt(parts[1], 10) : undefined; @@ -69,7 +115,7 @@ function formatDate(dateInput: AnniversaryDate): string { return dateStr; } -export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) { +export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) { const t = useTranslations("contacts"); const smimeStore = useSmimeStore(); const [parsedCerts, setParsedCerts] = useState>(new Map()); @@ -87,7 +133,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } try { let derBytes: ArrayBuffer | string | null = null; if (key.uri.startsWith('data:')) { - // data URI - extract base64 content const commaIdx = key.uri.indexOf(','); if (commaIdx === -1) continue; const b64 = key.uri.substring(commaIdx + 1); @@ -96,7 +141,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j); derBytes = bytes.buffer; } else if (key.uri.startsWith('-----BEGIN')) { - // PEM-encoded certificate inline derBytes = key.uri; } if (!derBytes) continue; @@ -127,6 +171,29 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } const name = getContactDisplayName(contact); const email = getContactPrimaryEmail(contact); + const photoUri = getContactPhotoUri(contact); + const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined; + + const handleExport = () => { + exportContact(contact); + toast.success(t("export.success", { count: 1 })); + }; + + const handlePrint = () => { + printContact(contact, name); + }; + + const moreItems: MoreItem[] = []; + if (onAddToGroup) { + moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup }); + } + if (onDuplicate) { + moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate }); + } + moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport }); + moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint }); + moreItems.push({ separator: true }); + moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true }); const emails = contact.emails ? Object.values(contact.emails) : []; const phones = contact.phones ? Object.values(contact.phones) : []; const orgs = contact.organizations ? Object.values(contact.organizations) : []; @@ -170,255 +237,259 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } const hasNickname = nicknames.length > 0; const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined; + const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[]; + const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0; + const hasWork = titles.length > 0 || orgs.length > 0; + const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns)); + const hasPersonal = anniversaries.length > 0 || personalInfo.length > 0 || hasGender || preferredLanguages.length > 0; return (
-
- +
+

{name || "-"}

{hasNickname && (

“{nicknames.map(n => n.name).join(", ")}”

)} - {titleLine && ( -

{titleLine}

- )} - {orgs.length > 0 && orgs[0].name && ( -

{orgs[0].name}

+ {subtitleParts.length > 0 && ( +

{subtitleParts.join(" · ")}

)}
-
+
+ {email && ( + + + {t("detail.compose_email")} + + )} + {phone && ( + + + {t("context_menu.call")} + + )} - +
-
-
- {/* Contact info */} - {emails.length > 0 && ( -
+
+ {hasContactDetails && ( +
+
{emails.map((e, i) => ( -
- - {e.address} - - {e.contexts && } - {e.label && ({e.label})} - + ))} -
- )} - {phones.length > 0 && ( -
{phones.map((p, i) => { - const featureStr = formatPhoneFeatures(p.features); + const features = formatPhoneFeatures(p.features); + const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[]; return ( -
- - {p.number} - - {p.contexts && } - {featureStr && ( - {featureStr} - )} - -
+ + + ); })} -
- )} - {(roles.length > 0 || jobTitles.length > 1) && ( -
- {titles.map((tl, i) => ( -
- {tl.name} - {tl.kind && ( - {tl.kind} - )} -
- ))} -
- )} - - {orgs.length > 0 && ( -
- {orgs.map((o, i) => ( -
- {o.name} - {o.units && o.units.length > 0 && ( - - {o.units.map(u => u.name).join(", ")} - )} -
- ))} -
- )} - - {/* Addresses span full width */} - {addresses.length > 0 && ( -
-
-
- {addresses.map((a, i) => ( -
-
- {a.full || a.fullAddress - ? (a.full || a.fullAddress) - : a.components && a.components.length > 0 - ? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ") - : [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")} - {a.contexts && } -
+ {addresses.map((a, i) => { + const lines: string[] = []; + if (a.full || a.fullAddress) { + lines.push((a.full || a.fullAddress) as string); + } else if (a.components && a.components.length > 0) { + const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", "); + if (joined) lines.push(joined); + } else { + const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country] + .map(s => (typeof s === "string" ? s.trim() : "")) + .filter(Boolean) as string[]; + lines.push(...parts); + } + return ( + +
+ {lines.map((line, idx) => ( +
{line}
+ ))} {a.timeZone && (
{t("detail.timezone")}: {a.timeZone}
)}
- ))} -
-
-
- )} + + ); + })} - {onlineServices.length > 0 && ( -
{onlineServices.map((svc, i) => ( -
- {typeof svc.uri === 'string' && svc.uri.startsWith("http") ? ( - - {svc.user || svc.uri} - - ) : ( - {svc.user || String(svc.uri ?? '')} - )} - {svc.service && ( - {svc.service} - )} - {svc.contexts && } - -
+ +
+ {typeof svc.uri === 'string' && svc.uri.startsWith("http") ? ( + + {svc.user || svc.uri} + + ) : ( + {svc.user || String(svc.uri ?? '')} + )} + + + +
+
))} -
- )} +
+
+ )} - {anniversaries.length > 0 && ( -
- {anniversaries.map((ann, i) => ( -
- {formatDate(ann.date)} - - {t(`detail.anniversary_${ann.kind}`)} - -
+ {hasWork && ( +
+
+ {orgs.map((o, i) => ( + +
+ {o.name} + {o.units && o.units.length > 0 && ( + · {o.units.map(u => u.name).join(", ")} + )} +
+
))} -
- )} - - {personalInfo.length > 0 && ( -
- {personalInfo.map((pi, i) => ( -
- {pi.value} - {t(`detail.personal_${pi.kind}`)} - {pi.level && ( - ({pi.level}) - )} -
+ {titles.map((tl, i) => ( + +
{tl.name}
+
))} -
- )} +
+ + )} - {contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && ( -
-
- {contact.speakToAs.grammaticalGender && {t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}} - {contact.speakToAs.pronouns && (() => { - const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns; - return firstPronoun ? ( - {contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun} - ) : null; - })()} -
-
- )} - - {preferredLanguages.length > 0 && ( -
+ {hasPersonal && ( +
+
+ {anniversaries.map((ann, i) => { + const years = getCompletedYears(ann.date); + const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since"; + return ( + +
+ {formatDate(ann.date)} + {years !== null && ( + · {t(suffixKey, { count: years })} + )} +
+
+ ); + })} + {hasGender && ( + +
+ {contact.speakToAs?.grammaticalGender && ( + {t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })} + )} + {contact.speakToAs?.pronouns && (() => { + const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns; + return firstPronoun ? ( + {contact.speakToAs!.grammaticalGender ? " · " : ""}{firstPronoun} + ) : null; + })()} +
+
+ )} {preferredLanguages.map((lang, i) => ( -
- {lang.language} - {lang.contexts && } -
+ +
{lang.language}
+
))} -
- )} + {personalInfo.map((pi, i) => ( + +
{pi.value}
+
+ ))} +
+ + )} - {keywords.length > 0 && ( -
-
- {keywords.map((kw, i) => ( - - {kw} - - ))} -
-
- )} + {keywords.length > 0 && ( +
+
+ {keywords.map((kw, i) => ( + + {kw} + + ))} +
+
+ )} - {relatedTo.length > 0 && ( -
+ {relatedTo.length > 0 && ( +
+
{relatedTo.map(([uri, rel], i) => { const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined; return ( -
- {uri} - {relType && ( - {relType} - )} -
+ +
{uri}
+
); })} -
- )} +
+ + )} - {cryptoKeys.length > 0 && ( -
+ {cryptoKeys.length > 0 && ( +
+
{cryptoKeys.map((key, i) => { const certInfo = parsedCerts.get(i); const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false; @@ -427,7 +498,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } : false; return ( -
+
{certInfo ? ( <>
@@ -448,12 +519,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } {certInfo.algorithm &&

{t("detail.cert_algorithm")}: {certInfo.algorithm}

}
{!alreadyImported && ( - @@ -463,7 +529,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } )} ) : ( -
- )} +
+ + )} - {(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && ( -
+ {(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && ( +
+
{contact.calendarUri && ( - + + + {contact.calendarUri} + + )} {contact.schedulingUri && ( -
- {t("detail.scheduling_uri")}: - {contact.schedulingUri} -
+ + + {contact.schedulingUri} + + )} {contact.freeBusyUri && ( -
- {t("detail.freebusy_uri")}: - {contact.freeBusyUri} -
+ + + {contact.freeBusyUri} + + )} -
- )} +
+ + )} - {/* Notes span full width */} - {notes.length > 0 && ( -
-
+ {notes.length > 0 && ( +
+
+ +
{notes.map((n, i) => ( -

{n.note}

+

{n.note}

))} -
+
- )} + + )} - {/* Timestamps span full width */} - {(contact.created || contact.updated) && ( -
- {contact.created &&
{t("detail.created")}: {formatDate(contact.created)}
} - {contact.updated &&
{t("detail.updated")}: {formatDate(contact.updated)}
} -
- )} + + + {(contact.created || contact.updated) && ( +
+ {contact.created &&
{t("detail.created")}: {formatDate(contact.created)}
} + {contact.updated &&
{t("detail.updated")}: {formatDate(contact.updated)}
} +
+ )} +
+
+ ); +} + +function formatContexts(contexts?: Record): string { + if (!contexts) return ""; + return Object.keys(contexts).filter(k => contexts[k]).join(", "); +} + +export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function FieldRow({ icon: Icon, label, children }: { icon: React.ComponentType<{ className?: string }>; label?: string; children: React.ReactNode }) { + return ( +
+ +
+ {label &&
{label}
} + {children} +
+
+ ); +} + +function RowActions({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDocMouseDown = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDocMouseDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocMouseDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + if (items.length === 0) return null; + + return ( +
+ + {open && ( +
+ {items.map((item, i) => { + if (item.separator) { + return
; + } + return ( + + ); + })}
-
+ )}
); } -type SectionCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes"; - -const categoryStyles: Record = { - contact: "border-l-blue-400 dark:border-l-blue-500", - work: "border-l-amber-400 dark:border-l-amber-500", - location: "border-l-emerald-400 dark:border-l-emerald-500", - personal: "border-l-violet-400 dark:border-l-violet-500", - digital: "border-l-cyan-400 dark:border-l-cyan-500", - calendar: "border-l-rose-400 dark:border-l-rose-500", - notes: "border-l-stone-400 dark:border-l-stone-500", -}; - -function Section({ icon: Icon, title, children, category = "contact" }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode; category?: SectionCategory }) { - return ( -
-
- -

{title}

-
-
{children}
-
- ); -} - -function ContextBadge({ contexts }: { contexts: Record }) { - const labels = Object.keys(contexts).filter(k => contexts[k]); - if (labels.length === 0) return null; - - return ( - - {labels.join(", ")} - - ); -} - function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) { return ( - {open && ( -
+ {(open || !collapsible) && ( +
{children}
)} -
+ ); } +const MAX_PHOTO_DIM = 512; +const PHOTO_QUALITY = 0.85; + +async function processImageFile(file: File): Promise<{ uri: string; mediaType: string }> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const img = new Image(); + img.onload = () => { + const ratio = Math.min(1, MAX_PHOTO_DIM / Math.max(img.width, img.height)); + const w = Math.max(1, Math.round(img.width * ratio)); + const h = Math.max(1, Math.round(img.height * ratio)); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("canvas-unsupported")); + return; + } + ctx.drawImage(img, 0, 0, w, h); + const uri = canvas.toDataURL("image/jpeg", PHOTO_QUALITY); + resolve({ uri, mediaType: "image/jpeg" }); + }; + img.onerror = () => reject(new Error("invalid-image")); + img.src = reader.result as string; + }; + reader.onerror = () => reject(new Error("read-failed")); + reader.readAsDataURL(file); + }); +} + function Select({ value, onChange, children, className }: { value: string; onChange: (e: React.ChangeEvent) => void; @@ -313,10 +332,54 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc }, [contact]); const [selectedBookId, setSelectedBookId] = useState(currentBookId); + const initialPhotoEntry = useMemo(() => { + if (!contact?.media) return null; + for (const [key, m] of Object.entries(contact.media)) { + if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType }; + } + return null; + }, [contact]); + const [photoUri, setPhotoUri] = useState(initialPhotoEntry?.uri); + const [photoMediaType, setPhotoMediaType] = useState(initialPhotoEntry?.mediaType); + const [photoError, setPhotoError] = useState(null); + const [photoUploading, setPhotoUploading] = useState(false); + const photoInputRef = useRef(null); + const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [emailErrors, setEmailErrors] = useState>({}); + const handlePhotoSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + if (!file.type.startsWith("image/")) { + setPhotoError(t("photo_invalid")); + return; + } + if (file.size > 10 * 1024 * 1024) { + setPhotoError(t("photo_too_large")); + return; + } + setPhotoError(null); + setPhotoUploading(true); + try { + const { uri, mediaType } = await processImageFile(file); + setPhotoUri(uri); + setPhotoMediaType(mediaType); + } catch { + setPhotoError(t("photo_invalid")); + } finally { + setPhotoUploading(false); + } + }; + + const handlePhotoRemove = () => { + setPhotoUri(undefined); + setPhotoMediaType(undefined); + setPhotoError(null); + }; + const validateEmail = (address: string): boolean => { if (!address.trim()) return true; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim()); @@ -427,6 +490,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc }); } + const mediaMap: Record = {}; + if (contact?.media) { + for (const [key, m] of Object.entries(contact.media)) { + if (m.kind !== "photo") mediaMap[key] = m; + } + } + if (photoUri) { + const photoKey = initialPhotoEntry?.key || "photo"; + mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType }; + } + const data: Partial = { name: { components: nameComponents, isOrdered: true }, nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined, @@ -453,6 +527,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc calendarUri: calendarUri.trim() || undefined, schedulingUri: schedulingUri.trim() || undefined, freeBusyUri: freeBusyUri.trim() || undefined, + media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined, ...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}), }; @@ -466,6 +541,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc } }; + const previewName = [givenName, surname].filter(Boolean).join(" ").trim(); + const previewEmail = emails.find(e => e.address.trim())?.address.trim() || ""; + return (
@@ -478,38 +556,82 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
-
+
{error && (
{error}
)} -
- - {/* Address Book Selector */} - {addressBooks && addressBooks.length > 1 && ( -
- - + +
+

{t("photo_hint")}

+ {photoError && ( +

{photoError}

+ )} + {photoUri && ( + + )}
+
+ +
+ + {addressBooks && addressBooks.length > 1 && ( + + + )} - {/* Name & Identity - full width */} -
- +
@@ -543,10 +665,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
-
{/* Email */} - +
{emails.map((entry, i) => (
@@ -598,7 +719,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc {/* Phone */} - +
{phones.map((entry, i) => (
@@ -656,7 +777,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc {/* Work & Organization */} - +
@@ -677,9 +798,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
- {/* Addresses - full width */} -
- + {/* Addresses */} + 0}>
{addresses.map((addr, i) => (
@@ -711,10 +831,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
-
{/* Online Services */} - + 0}>
{onlineServices.map((svc, i) => (
@@ -743,7 +862,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc {/* Anniversaries */} - + 0}>
{anniversaries.map((ann, i) => (
@@ -775,7 +894,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc {/* Personal Info */} - + 0}>
{personalInfoEntries.map((pi, i) => (
@@ -816,7 +935,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc {/* Categories */} - + {/* Gender */} - +
@@ -849,7 +968,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc {/* Calendar */} - +
@@ -866,9 +985,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
- {/* Notes - full width */} -
- + {/* Notes */} +