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)
[](https://discord.gg/tYCujymGrT)
-[](CHANGELOG.md)
+[](CHANGELOG.md)
[](https://ghcr.io/bulwarkmail/webmail)
@@ -22,228 +21,56 @@ Built with Next.js and the JMAP protocol.
## Screenshots
+
+
+
+
+
-
-
-
-
-**Mail** - Three-pane layout with sidebar, email list, and viewer
-
-
-
-
-
-
-**Calendar** - Month, week, day, and agenda views with event management
-
-
+
+
-
-
-
-
-**Contacts** - Contact management with groups and vCard support
-
-
-
-
-
-
-**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.
+
+
+
+
+
+
+
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 mode – full theme support with intelligent color transformation for HTML emails.
+
Settings – appearance, identities, filters, templates, security, and more.
-
-More screenshots
-
-
-
+## Overview
-
+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** - Appearance, identities, filters, templates, and more
-
-
-
-
-
-
-
-
-**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' && <>