Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92c7f74420 | ||
|
|
c2e4518cfa | ||
|
|
00f33afdf9 | ||
|
|
e566cfe687 | ||
|
|
6b7c849332 | ||
|
|
30c4afb977 | ||
|
|
1f60671886 | ||
|
|
794001fdbd | ||
|
|
9ad2facad3 | ||
|
|
89d8282846 | ||
|
|
c3960a99be | ||
|
|
e73ffa7449 | ||
|
|
37bd490072 | ||
|
|
24c53e5ce7 | ||
|
|
aa7f886795 | ||
|
|
00dec8c5a0 | ||
|
|
578e60c0bc | ||
|
|
8b21851353 | ||
|
|
15006086d2 | ||
|
|
bc3b923945 | ||
|
|
76ba9e5f85 | ||
|
|
44eb5fced2 | ||
|
|
172d8267ef | ||
|
|
f162f1e3d4 | ||
|
|
6fa0029d0b | ||
|
|
028e78a0c9 | ||
|
|
440a4e919a | ||
|
|
b8f39198e1 | ||
|
|
966bbe3957 | ||
|
|
522bf6a019 | ||
|
|
1689315c3a | ||
|
|
d4f7ae522e | ||
|
|
f05f70a9e5 | ||
|
|
850ee73048 | ||
|
|
5842f3f914 | ||
|
|
f303478850 | ||
|
|
8bdadc7ba3 | ||
|
|
6b57118add | ||
|
|
ffb645671c | ||
|
|
e63ce25f5f | ||
|
|
d31b30ba4a | ||
|
|
31eff96614 | ||
|
|
2ea8054240 | ||
|
|
5a2e141ed6 | ||
|
|
fa343e0768 | ||
|
|
8969338b2a | ||
|
|
4c720d6855 | ||
|
|
ad175d20e3 | ||
|
|
fb2f0c9158 | ||
|
|
7daa46e73e | ||
|
|
195185dc52 | ||
|
|
8a9dce1a99 | ||
|
|
c690e8eb76 | ||
|
|
9d867cbff6 | ||
|
|
f22699fe20 | ||
|
|
a7db3883aa | ||
|
|
7fcefa53c9 | ||
|
|
bdb76c3d90 | ||
|
|
168b36d419 | ||
|
|
a4f57e7a5c | ||
|
|
6678501501 | ||
|
|
fa0045e01b | ||
|
|
1b816d3185 | ||
|
|
24949e183f |
+3
-3
@@ -1,11 +1,11 @@
|
||||
# Bulwark Webmail — Development Configuration
|
||||
# Bulwark Webmail - Development Configuration
|
||||
# Copy this file to .env.local to run with the built-in mock JMAP server.
|
||||
# No external mail server required — great for UI development and testing.
|
||||
# No external mail server required - great for UI development and testing.
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.dev.example .env.local
|
||||
# npm run dev
|
||||
# Open http://localhost:3000 — log in with any username/password.
|
||||
# Open http://localhost:3000 - log in with any username/password.
|
||||
|
||||
# =============================================================================
|
||||
# Mock JMAP Server
|
||||
|
||||
+57
-22
@@ -1,4 +1,4 @@
|
||||
# Bulwark Webmail — Production Configuration
|
||||
# Bulwark Webmail - Production Configuration
|
||||
# Copy this file to .env.local and fill in your values.
|
||||
# For development with the built-in mock server, see .env.dev.example instead.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# JMAP Server (required)
|
||||
# =============================================================================
|
||||
|
||||
# App name displayed in the UI
|
||||
# App name displayed in the UI, browser tab title, and PWA manifest.
|
||||
APP_NAME=Bulwark Webmail
|
||||
|
||||
# URL of your JMAP-compatible mail server (required unless ALLOW_CUSTOM_JMAP_ENDPOINT is set)
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
@@ -76,7 +71,7 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
|
||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||
# For Docker, the working directory is /app, so the default resolves to
|
||||
# /app/data/settings — mount a persistent volume there:
|
||||
# /app/data/settings - mount a persistent volume there:
|
||||
# volumes:
|
||||
# - bulwark-settings:/app/data/settings
|
||||
# SETTINGS_DATA_DIR=./data/settings
|
||||
@@ -106,41 +101,81 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Branding (all optional)
|
||||
# =============================================================================
|
||||
|
||||
# Custom favicon for the browser tab.
|
||||
# ---------------------------------------------------------------------------
|
||||
# App identity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Short name for the app, used in contexts where space is limited
|
||||
# (e.g. home screen label on mobile). Defaults to APP_NAME if not set.
|
||||
# APP_SHORT_NAME=Bulwark
|
||||
|
||||
# Description shown in the PWA manifest (displayed by the OS during install).
|
||||
# Defaults to a generic Bulwark description if not set.
|
||||
# APP_DESCRIPTION=Your personal webmail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Icons & favicon
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Custom favicon shown in the browser tab.
|
||||
# Supported formats: SVG (recommended), PNG, ICO.
|
||||
# Recommended size: 32×32px minimum, 512×512px maximum (or SVG for best scaling).
|
||||
# Can be an absolute URL or a path relative to the public/ directory.
|
||||
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||
# Defaults to the Bulwark favicon if not set.
|
||||
# FAVICON_URL=/branding/my-favicon.svg
|
||||
|
||||
# Custom logos for the sidebar (shown in the main app after login).
|
||||
# Source image used to auto-generate PWA icons (192×192 and 512×512 PNG).
|
||||
# Supported formats: SVG (recommended for best quality) or PNG (≥512×512px recommended).
|
||||
# Can be an absolute URL (https://...) or a path relative to the public/ directory.
|
||||
# Falls back to FAVICON_URL if not set, and to the default Bulwark icons if neither is set.
|
||||
# PWA_ICON_URL=/branding/my-icon.svg
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PWA appearance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Color applied to the browser UI chrome when the app is installed as a PWA
|
||||
# (address bar, status bar on Android). Default: #ffffff
|
||||
# PWA_THEME_COLOR=#3b82f6
|
||||
|
||||
# Background color shown on the PWA splash screen while the app is loading.
|
||||
# Should match your app's main background color. Default: #ffffff
|
||||
# PWA_BACKGROUND_COLOR=#ffffff
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Logos shown in the sidebar (main app, after login).
|
||||
# Supported formats: SVG (recommended), PNG, WebP.
|
||||
# Recommended size: min 24×24px, max 128×128px
|
||||
# Recommended size: min 24×24px, max 128×128px.
|
||||
# Can be absolute URLs or paths relative to the public/ directory.
|
||||
# If not set, no logo is shown in the sidebar.
|
||||
# APP_LOGO_LIGHT_URL=/branding/my-logo-color.svg
|
||||
# APP_LOGO_DARK_URL=/branding/my-logo-white.svg
|
||||
|
||||
# Custom logo images for the login page.
|
||||
# Logos shown on the login page.
|
||||
# Supported formats: SVG (recommended), PNG, WebP.
|
||||
# Recommended size: min 32×32px, max 512×512px
|
||||
# Recommended size: min 32×32px, max 512×512px.
|
||||
# Can be absolute URLs or paths relative to the public/ directory.
|
||||
# Light mode logo (shown on light backgrounds), defaults to Bulwark logo.
|
||||
# Light mode logo (shown on light backgrounds). Defaults to the Bulwark logo.
|
||||
LOGIN_LOGO_LIGHT_URL=/branding/Bulwark_Logo_Color.svg
|
||||
#
|
||||
# Dark mode logo (shown on dark backgrounds), defaults to Bulwark white logo.
|
||||
# Dark mode logo (shown on dark backgrounds). Defaults to the Bulwark white logo.
|
||||
LOGIN_LOGO_DARK_URL=/branding/Bulwark_Logo_Color.svg
|
||||
|
||||
# Company or organization name displayed above the version on the login page
|
||||
# ---------------------------------------------------------------------------
|
||||
# Login page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Company name shown above the version number on the login page.
|
||||
LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||
|
||||
# URL for the imprint/legal notice link on the login page
|
||||
# URL for the imprint / legal notice link on the login page.
|
||||
# LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
|
||||
# URL for the privacy policy link on the login page
|
||||
# URL for the privacy policy link on the login page.
|
||||
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
|
||||
# URL for the company website link on the login page
|
||||
# URL for the company website link on the login page.
|
||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
|
||||
# =============================================================================
|
||||
|
||||
+46
-2
@@ -1,14 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4.14 (2026-04-16)
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||
|
||||
**One-time**
|
||||
|
||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||
- [@boris22100](https://github.com/boris22100)
|
||||
|
||||
**Monthly**
|
||||
|
||||
- [@pr0ton11](https://github.com/pr0ton11)
|
||||
|
||||
### Features
|
||||
|
||||
- **Email**: Add unified mailbox across accounts and sidebar icons toggle
|
||||
- **Email**: Enhance email deletion and spam handling with improved parameterization
|
||||
- **Sieve**: Enhance external rule handling in parser and store (#201)
|
||||
- **Plugins**: Add i18n API, render hooks, and new intercept hooks to plugin system
|
||||
- **PWA**: Dynamic PWA manifest with configurable name, description, and icons
|
||||
- **PWA**: Show app name and logo in install prompt
|
||||
- **i18n**: Add Ukrainian language with flags and missing translation keys
|
||||
- **i18n**: Configurable locale prefix via `NEXT_PUBLIC_LOCALE_PREFIX`
|
||||
- **API**: Add `apiFetch` helper for mount-prefix-aware API calls
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Calendar**: Send iMIP invitation emails when creating or updating calendar events (#192)
|
||||
- **Calendar**: RFC 5545/6047 compliance for outgoing iMIP calendar emails
|
||||
- **Calendar**: Add `calendarAddress` and `replyTo` to participants for Stalwart compatibility (#189, #192)
|
||||
- **Calendar**: Improve CalDAV task detection for external clients like Thunderbird (#84)
|
||||
- **Email**: Hide ICS attachments from attachment list when invitation banner is shown
|
||||
- **Email**: Send before storing in Sent via `onSuccessUpdateEmail` (#188)
|
||||
- **Email**: Standardize tag naming and fix unknown keyword display (#184, #185)
|
||||
- **i18n**: Skip intl middleware for paths already containing a locale prefix
|
||||
- **Docs**: Document PWA and branding env vars in `.env.example`
|
||||
- **Docs**: Use `company` consistently in `.env.example` branding comments
|
||||
|
||||
## 1.4.13 (2026-04-12)
|
||||
|
||||
Thank you for your donations:
|
||||
|
||||
**One-time**
|
||||
|
||||
- [@boris22100](https://github.com/boris22100)
|
||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||
|
||||
**Monthly**
|
||||
|
||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||
|
||||
### Features
|
||||
@@ -38,9 +80,11 @@ Thank you for your donations:
|
||||
Thank you for your donations:
|
||||
|
||||
**One-time**
|
||||
|
||||
- [@mkorthaus-private](https://github.com/mkorthaus-private)
|
||||
|
||||
**Monthly**
|
||||
|
||||
- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_
|
||||
|
||||
### Features
|
||||
@@ -436,7 +480,7 @@ Thank you for your donations:
|
||||
### Fixes
|
||||
|
||||
- **Context menu**: Fix "Move to folder" submenu closing when scrolling the folder list or moving the mouse to the submenu (#19)
|
||||
- **Move to folder**: Fix emails not actually moving on the server — JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
||||
- **Move to folder**: Fix emails not actually moving on the server - JMAP response errors were silently ignored and shared account IDs were not resolved correctly
|
||||
- **Dependencies**: Update tailwindcss, lucide-react, @tanstack/react-virtual, @typescript-eslint/\*, globals, @types/node
|
||||
|
||||
## 1.1.1 (2026-02-28)
|
||||
@@ -446,7 +490,7 @@ Thank you for your donations:
|
||||
- **Email viewer**: Show/hide details toggle now stays in place when expanded instead of jumping to the bottom of the details section (#18)
|
||||
- **Email viewer**: Details toggle text is now properly translated (was hardcoded in English)
|
||||
- **Instrumentation**: Resolve Edge Runtime warnings by splitting Node.js-only code into a separate module
|
||||
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) — upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
||||
- **Security**: Patch minimatch ReDoS vulnerability (CVE-2026-27903) - upgrade 9.0.6→9.0.9 and 3.1.3→3.1.5
|
||||
|
||||
## 1.1.0 (2026-02-28)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
@@ -26,16 +26,16 @@ Built with Next.js and the JMAP protocol.
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox — three-pane layout with sidebar, email list, and viewer (dark mode)">
|
||||
<img src="screenshots/inbox.png" width="100%" alt="Inbox - three-pane layout with sidebar, email list, and viewer (dark mode)">
|
||||
|
||||
**Mail** — Three-pane layout with sidebar, email list, and viewer
|
||||
**Mail** - Three-pane layout with sidebar, email list, and viewer
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/calendar.png" width="100%" alt="Calendar">
|
||||
|
||||
**Calendar** — Month, week, day, and agenda views with event management
|
||||
**Calendar** - Month, week, day, and agenda views with event management
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -44,14 +44,14 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
<img src="screenshots/contacts.png" width="100%" alt="Contacts">
|
||||
|
||||
**Contacts** — Contact management with groups and vCard support
|
||||
**Contacts** - Contact management with groups and vCard support
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/files.png" width="100%" alt="File browser">
|
||||
|
||||
**Files** — Cloud file browser with upload, preview, and folder navigation
|
||||
**Files** - Cloud file browser with upload, preview, and folder navigation
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -63,16 +63,16 @@ Built with Next.js and the JMAP protocol.
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox — light mode">
|
||||
<img src="screenshots/inbox%20whitemode.png" width="100%" alt="Inbox - light mode">
|
||||
|
||||
**Light mode** — Full theme support with intelligent color transformation
|
||||
**Light mode** - Full theme support with intelligent color transformation
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
<img src="screenshots/settings.png" width="100%" alt="Settings">
|
||||
|
||||
**Settings** — Appearance, identities, filters, templates, and more
|
||||
**Settings** - Appearance, identities, filters, templates, and more
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -81,7 +81,7 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
<img src="screenshots/login.png" width="100%" alt="Login page">
|
||||
|
||||
**Login** — Configurable branding with OAuth2/OIDC and 2FA support
|
||||
**Login** - Configurable branding with OAuth2/OIDC and 2FA support
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -94,34 +94,46 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
### Mail
|
||||
|
||||
- **Read, compose, reply, reply-all, forward** with rich HTML rendering
|
||||
- **Threading** — Gmail-style inline expansion with thread navigation
|
||||
- **Draft auto-save** with discard confirmation
|
||||
- **Attachments** — upload, download, and inline preview
|
||||
- **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
|
||||
- **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
|
||||
- **Color tags/labels** and star/unstar
|
||||
- **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
|
||||
- **Sender avatars** — favicon-based with negative caching for performance
|
||||
- **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
|
||||
- **TNEF support** — extract Outlook `winmail.dat` message bodies and attachments automatically
|
||||
- **Folder management** — create, rename, delete folders with icon picker and subfolder support
|
||||
- **Tag counts** — unread and total counts displayed in sidebar
|
||||
- **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
|
||||
- **Event hover preview** popover with details
|
||||
- **Drag-and-drop rescheduling**, click-drag creation, edge-resize (15-min snap)
|
||||
- **Recurring events** with edit/delete scope (this / this and following / all)
|
||||
- **Participant scheduling** — iTIP invitations, organizer/attendee UI, RSVP
|
||||
- **Inline calendar invitations** in email viewer — auto-detect `.ics`, RSVP, import
|
||||
- **iCalendar import** with preview and bulk create
|
||||
- **Task management** — create, edit, and track tasks with due dates, priority, and completion status
|
||||
- **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
|
||||
@@ -129,77 +141,103 @@ Built with Next.js and the JMAP protocol.
|
||||
### 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
|
||||
- **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…)
|
||||
- **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.)
|
||||
- **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 and folder upload support
|
||||
- **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
|
||||
- **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
|
||||
- **Bulk operations** - multi-select, delete, move, download
|
||||
|
||||
### Security & Privacy
|
||||
|
||||
- **External content blocked** by default — trusted senders list for auto-load
|
||||
- **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
|
||||
- **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 and non-interactive SSO for embedded/iframe deployments
|
||||
- **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** — CSP with per-request nonce, X-Frame-Options, Referrer-Policy
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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 and open them inline or in a new tab
|
||||
- **Settings sync** — preferences synchronized with the server (encrypted)
|
||||
- **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
|
||||
- **Shared folders** — multi-account access
|
||||
- **Accessibility** — WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||
- **Version badge** in settings
|
||||
- **Focused mode** with proper viewport bounds
|
||||
- **Accessibility** - WCAG AA contrast, reduced-motion support, focus trap, screen reader live regions
|
||||
|
||||
### Internationalization
|
||||
|
||||
8 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português
|
||||
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
|
||||
|
||||
Automatic browser detection with persistent preference.
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
|
||||
### Identity Management
|
||||
|
||||
- **Multiple sender identities** with per-identity signatures
|
||||
- **Identity refresh** — keep the identity manager aligned with server-side changes after edits
|
||||
- **Sub-addressing** — `user+tag@domain.com` with contextual tag suggestions
|
||||
- **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
|
||||
|
||||
- **Automatic update check** — server logs when a newer release is available
|
||||
- **Demo mode** — try the webmail with fixture data for emails, calendars, contacts, files, filters, identities, and mailboxes — no mail server required
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
@@ -217,7 +255,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
|
||||
```
|
||||
|
||||
@@ -228,7 +266,7 @@ 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
|
||||
```
|
||||
|
||||
@@ -252,7 +290,7 @@ JMAP_SERVER_URL=https://mail.example.com
|
||||
APP_NAME=My Webmail
|
||||
```
|
||||
|
||||
All variables are **runtime** — Docker deployments can be configured without rebuilding.
|
||||
All variables are **runtime** - Docker deployments can be configured without rebuilding.
|
||||
|
||||
<details>
|
||||
<summary>Server Listen Address</summary>
|
||||
@@ -280,14 +318,78 @@ Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Remember Me</summary>
|
||||
<summary>Remember Me & Settings Sync</summary>
|
||||
|
||||
```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
|
||||
|
||||
SETTINGS_SYNC_ENABLED=true # Persist encrypted user settings on the server
|
||||
SETTINGS_DATA_DIR=./data/settings # Storage location (mount 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`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Custom JMAP Endpoint</summary>
|
||||
|
||||
```env
|
||||
ALLOW_CUSTOM_JMAP_ENDPOINT=true # Shows a "JMAP Server" field on login
|
||||
```
|
||||
|
||||
Lets users connect to any JMAP-compatible server. External servers must CORS-allow the webmail origin.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Branding & PWA</summary>
|
||||
|
||||
```env
|
||||
APP_NAME=My Webmail
|
||||
APP_SHORT_NAME=Webmail # Home-screen label on mobile
|
||||
APP_DESCRIPTION=Your personal mail # Shown during PWA install
|
||||
|
||||
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
|
||||
|
||||
APP_LOGO_LIGHT_URL=/branding/logo-light.svg
|
||||
APP_LOGO_DARK_URL=/branding/logo-dark.svg
|
||||
LOGIN_LOGO_LIGHT_URL=/branding/login-light.svg
|
||||
LOGIN_LOGO_DARK_URL=/branding/login-dark.svg
|
||||
|
||||
LOGIN_COMPANY_NAME=My Company
|
||||
LOGIN_WEBSITE_URL=https://example.com
|
||||
LOGIN_IMPRINT_URL=https://example.com/imprint
|
||||
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Extension Directory</summary>
|
||||
|
||||
```env
|
||||
EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
```
|
||||
|
||||
Enables the admin marketplace for browsing and installing plugins and themes.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Stalwart Integration & Logging</summary>
|
||||
|
||||
```env
|
||||
STALWART_FEATURES=true # Password change, sieve filters, etc.
|
||||
|
||||
LOG_FORMAT=text # "text" or "json"
|
||||
LOG_LEVEL=info # "error", "warn", "info", "debug"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -321,7 +423,7 @@ Credentials encrypted with AES-256-GCM, stored in an httpOnly cookie (30-day exp
|
||||
|
||||
## 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 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.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function OAuthCallbackInner() {
|
||||
const savedState = sessionStorage.getItem("oauth_state");
|
||||
|
||||
if (savedState) {
|
||||
// Classic flow — sessionStorage has the PKCE state (same-tab OAuth)
|
||||
// Classic flow - sessionStorage has the PKCE state (same-tab OAuth)
|
||||
if (!state || state !== savedState) {
|
||||
setError("invalid_state");
|
||||
return;
|
||||
@@ -76,7 +76,7 @@ function OAuthCallbackInner() {
|
||||
setError("token_exchange_failed");
|
||||
});
|
||||
} else if (state) {
|
||||
// Server-side SSO flow — state was stored in encrypted httpOnly cookie
|
||||
// Server-side SSO flow - state was stored in encrypted httpOnly cookie
|
||||
const ssoPrefix = getPathPrefix(params.locale as string);
|
||||
loginWithServerSso(code, state)
|
||||
.then((success) => {
|
||||
|
||||
@@ -30,6 +30,10 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||
import { EventContextMenu } from "@/components/calendar/event-context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { downloadEventICS } from "@/lib/calendar-ics-export";
|
||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
|
||||
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
|
||||
@@ -112,6 +116,16 @@ export default function CalendarPage() {
|
||||
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
|
||||
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||
|
||||
// Keep detailEvent in sync with store events (e.g. after update + refetch)
|
||||
useEffect(() => {
|
||||
if (detailEvent) {
|
||||
const updated = events.find(e => e.id === detailEvent.id);
|
||||
if (updated && updated !== detailEvent) {
|
||||
setDetailEvent(updated);
|
||||
}
|
||||
}
|
||||
}, [events, detailEvent]);
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
checkAuth().finally(() => {
|
||||
@@ -151,9 +165,14 @@ export default function CalendarPage() {
|
||||
return () => clearInterval(interval);
|
||||
}, [client, refreshAllSubscriptions]);
|
||||
|
||||
// Auto-add birthday calendar to selected IDs when enabled
|
||||
// Auto-add birthday calendar to selected IDs only when the setting flips
|
||||
// off→on. Firing on every mount would undo a user's manual hide via the
|
||||
// sidebar each time they navigate back to the calendar (see #204).
|
||||
const prevShowBirthdayRef = useRef(showBirthdayCalendar);
|
||||
useEffect(() => {
|
||||
if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||
const wasShown = prevShowBirthdayRef.current;
|
||||
prevShowBirthdayRef.current = showBirthdayCalendar;
|
||||
if (!wasShown && showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||
toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID);
|
||||
}
|
||||
}, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -347,6 +366,18 @@ export default function CalendarPage() {
|
||||
openEditModal(event);
|
||||
}, [closeDetail, openEditModal]);
|
||||
|
||||
const {
|
||||
contextMenu: eventContextMenu,
|
||||
openContextMenu: openEventContextMenu,
|
||||
closeContextMenu: closeEventContextMenu,
|
||||
menuRef: eventContextMenuRef,
|
||||
} = useContextMenu<CalendarEvent>();
|
||||
|
||||
const handleContextMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
|
||||
closeDetail();
|
||||
openEventContextMenu(e, event);
|
||||
}, [closeDetail, openEventContextMenu]);
|
||||
|
||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||
if (isMobile) return;
|
||||
if (calendarHoverPreview === 'off') return;
|
||||
@@ -407,6 +438,20 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [client, fetchEvents]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh calendar data via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
await Promise.all([
|
||||
fetchCalendars(client),
|
||||
refetchCurrentRange(),
|
||||
refreshAllSubscriptions(client),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
|
||||
if (!event.start) {
|
||||
return;
|
||||
@@ -722,6 +767,73 @@ export default function CalendarPage() {
|
||||
}
|
||||
}, [detailEvent, client, updateEvent, t]);
|
||||
|
||||
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
|
||||
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||
const start = parseISO(event.start);
|
||||
const newStart = addDays(start, 1);
|
||||
const data = sanitizeOutgoingCalendarEventData<Partial<CalendarEvent>>({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
duration: event.duration,
|
||||
timeZone: event.timeZone,
|
||||
showWithoutTime: event.showWithoutTime,
|
||||
calendarIds: { ...event.calendarIds },
|
||||
status: "confirmed",
|
||||
freeBusyStatus: event.freeBusyStatus,
|
||||
privacy: event.privacy,
|
||||
});
|
||||
if (event.locations) data.locations = structuredClone(event.locations);
|
||||
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||
if (event.participants) data.participants = structuredClone(event.participants);
|
||||
try {
|
||||
const created = await createEvent(client, data);
|
||||
if (created) {
|
||||
toast.success(t("notifications.event_duplicated"));
|
||||
openEditModal(created);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [client, createEvent, openEditModal, t]);
|
||||
|
||||
const handleExportICS = useCallback((event: CalendarEvent) => {
|
||||
try {
|
||||
downloadEventICS(event);
|
||||
toast.success(t("notifications.event_exported"));
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleCopyTitle = useCallback(async (event: CalendarEvent) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(event.title || "");
|
||||
toast.success(t("notifications.title_copied"));
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => {
|
||||
const uri = event.virtualLocations
|
||||
? Object.values(event.virtualLocations).find((v) => v.uri)?.uri
|
||||
: undefined;
|
||||
if (!uri) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(uri);
|
||||
toast.success(t("notifications.link_copied"));
|
||||
} catch {
|
||||
toast.error(t("notifications.event_error"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleDeleteContextMenu = useCallback((event: CalendarEvent) => {
|
||||
const hasParticipants = event.participants && Object.keys(event.participants).length > 0;
|
||||
handleDeleteEvent(event.id, hasParticipants || undefined);
|
||||
}, [handleDeleteEvent]);
|
||||
|
||||
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
|
||||
if (!detailEvent || !client) return;
|
||||
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
|
||||
@@ -831,6 +943,7 @@ export default function CalendarPage() {
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onCreateAtTime={openCreateModal}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
isMobile={isMobile}
|
||||
@@ -847,6 +960,7 @@ export default function CalendarPage() {
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onCreateAtTime={openCreateModal}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
timeFormat={timeFormat}
|
||||
@@ -865,6 +979,7 @@ export default function CalendarPage() {
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onCreateAtTime={openCreateModal}
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
@@ -882,6 +997,7 @@ export default function CalendarPage() {
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
timeFormat={timeFormat}
|
||||
/>
|
||||
);
|
||||
@@ -1096,6 +1212,22 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventContextMenu.data && (
|
||||
<EventContextMenu
|
||||
event={eventContextMenu.data}
|
||||
position={eventContextMenu.position}
|
||||
isOpen={eventContextMenu.isOpen}
|
||||
onClose={closeEventContextMenu}
|
||||
menuRef={eventContextMenuRef}
|
||||
onEdit={() => openEditModal(eventContextMenu.data!)}
|
||||
onDuplicate={() => handleDuplicateContextMenu(eventContextMenu.data!)}
|
||||
onExportICS={() => handleExportICS(eventContextMenu.data!)}
|
||||
onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)}
|
||||
onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)}
|
||||
onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailEvent && detailAnchorRect && (
|
||||
<EventDetailPopover
|
||||
event={detailEvent}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
|
||||
type View =
|
||||
@@ -123,6 +124,16 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [client, supportsSync, fetchContacts]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh contacts via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client && supportsSync,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
await fetchContacts(client);
|
||||
},
|
||||
});
|
||||
|
||||
const groups = useMemo(() => contacts.filter(c => c.kind === 'group'), [contacts]);
|
||||
const individuals = useMemo(() => contacts.filter(c => c.kind !== 'group'), [contacts]);
|
||||
const selectedContact = contacts.find((c) => c.id === selectedContactId) || null;
|
||||
@@ -248,9 +259,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"),
|
||||
@@ -261,18 +270,42 @@ 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 handleSaveNew = useCallback(async (data: Partial<ContactCard>) => {
|
||||
if (supportsSync && client) {
|
||||
await createContact(client, data);
|
||||
@@ -679,6 +712,9 @@ export default function ContactsPage() {
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkAddToGroup={handleBulkAddToGroup}
|
||||
onBulkExport={handleBulkExport}
|
||||
onEditContact={handleEditContact}
|
||||
onDeleteContact={handleDeleteContact}
|
||||
onAddContactToGroup={handleAddContactToGroup}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { FileBrowser } from "@/components/files/file-browser";
|
||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||
@@ -127,6 +128,15 @@ export default function FilesPage() {
|
||||
}
|
||||
}, [isAuthenticated, client, initClient]);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh files via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client && supportsFiles === true,
|
||||
onRefresh: async () => {
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
|
||||
// Check support and load root after client is initialized
|
||||
const storeClient = useFileStore(s => s.client);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
@@ -234,7 +234,7 @@ export default function LoginPage() {
|
||||
try {
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||
const res = await fetch('/api/auth/sso/start', {
|
||||
const res = await apiFetch('/api/auth/sso/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
@@ -255,7 +255,7 @@ export default function LoginPage() {
|
||||
try {
|
||||
window.top!.location.href = authorize_url;
|
||||
} catch {
|
||||
// Cross-origin restriction — fall back to current frame
|
||||
// Cross-origin restriction - fall back to current frame
|
||||
window.location.href = authorize_url;
|
||||
}
|
||||
} else {
|
||||
@@ -759,7 +759,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Dev mode — logging in as dev@localhost
|
||||
Dev mode - logging in as dev@localhost
|
||||
</p>
|
||||
</div>
|
||||
) : oauthOnly ? (
|
||||
|
||||
+157
-31
@@ -9,7 +9,9 @@ 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 { ThreadGroup, Email } from "@/lib/jmap/types";
|
||||
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";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
@@ -19,6 +21,7 @@ import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -155,8 +158,54 @@ export default function Home() {
|
||||
hasMoreEmails,
|
||||
fetchTagCounts,
|
||||
fetchEmailContent,
|
||||
isUnifiedView,
|
||||
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||
refreshUnifiedCounts,
|
||||
exitUnifiedView,
|
||||
} = useEmailStore();
|
||||
|
||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const connectedAccountsSignature = useMemo(
|
||||
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
|
||||
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
|
||||
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||
const result: UnifiedAccountClient[] = [];
|
||||
for (const account of connected) {
|
||||
const accountClient = clients.get(account.id);
|
||||
if (!accountClient) continue;
|
||||
result.push({
|
||||
accountId: account.id,
|
||||
accountLabel: account.label || account.email,
|
||||
client: accountClient,
|
||||
mailboxes: [],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const populateUnifiedAccountMailboxes = useCallback(
|
||||
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
|
||||
const populated = await Promise.all(
|
||||
list.map(async (entry) => {
|
||||
try {
|
||||
const mailboxes = await entry.client.getMailboxes();
|
||||
return { ...entry, mailboxes };
|
||||
} catch (err) {
|
||||
debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
|
||||
return entry;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return populated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Browser back / forward integration. The restore handler reads the
|
||||
// latest values from a ref so we don't have to recreate the callback on
|
||||
// every render (and so the popstate listener is never stale).
|
||||
@@ -208,7 +257,7 @@ export default function Home() {
|
||||
|
||||
// Restore conversation thread (mobile only). We can clear it directly,
|
||||
// but reopening requires the thread group; if the user pressed forward
|
||||
// to return to a thread, we silently skip — back navigation always works.
|
||||
// to return to a thread, we silently skip - back navigation always works.
|
||||
if ((state.threadId ?? null) !== ctx.conversationThreadId) {
|
||||
if (state.threadId === null) {
|
||||
setConversationThread(null);
|
||||
@@ -360,6 +409,20 @@ export default function Home() {
|
||||
handlers: keyboardHandlers,
|
||||
});
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
// and refresh mail data via JMAP instead of reloading the page.
|
||||
useRefreshGesture({
|
||||
enabled: isAuthenticated && !!client,
|
||||
onRefresh: async () => {
|
||||
if (!client) return;
|
||||
const state = useEmailStore.getState();
|
||||
await Promise.all([
|
||||
state.fetchMailboxes(client),
|
||||
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
// Update page title based on context
|
||||
useEffect(() => {
|
||||
let title = appName;
|
||||
@@ -485,13 +548,30 @@ export default function Home() {
|
||||
};
|
||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]);
|
||||
|
||||
// Keep unified mailbox counts in sync when the feature is enabled and more
|
||||
// than one account is connected. Runs whenever the set of connected accounts
|
||||
// or the primary account's mailboxes change (a proxy for "something worth
|
||||
// recounting happened").
|
||||
useEffect(() => {
|
||||
if (!enableUnifiedMailbox || !isAuthenticated || !client) return;
|
||||
const built = buildUnifiedAccounts();
|
||||
if (built.length < 2) return;
|
||||
populateUnifiedAccountMailboxes(built).then((populated) => {
|
||||
refreshUnifiedCounts(populated);
|
||||
});
|
||||
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
||||
|
||||
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
|
||||
useEffect(() => {
|
||||
if (!selectedEmail || !client) return;
|
||||
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
|
||||
if (!selectedEmail.bodyValues) {
|
||||
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
||||
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
||||
: undefined;
|
||||
const fetchClient = perAccountClient ?? client;
|
||||
setLoadingEmail(true);
|
||||
fetchEmailContent(client, selectedEmail.id).finally(() => {
|
||||
fetchEmailContent(fetchClient, selectedEmail.id).finally(() => {
|
||||
setLoadingEmail(false);
|
||||
});
|
||||
}
|
||||
@@ -579,7 +659,7 @@ export default function Home() {
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
identityId?: string;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
}) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -693,8 +773,8 @@ export default function Home() {
|
||||
if (isMobile) setActiveView('viewer');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
||||
if (!client || !emailToDelete) return;
|
||||
|
||||
// Check if we're currently in the trash or junk folder
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
@@ -713,7 +793,7 @@ export default function Home() {
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteEmail(client, selectedEmail.id, true);
|
||||
await deleteEmail(client, emailToDelete.id, true);
|
||||
} catch (error) {
|
||||
console.error("Failed to permanently delete email:", error);
|
||||
}
|
||||
@@ -722,7 +802,7 @@ export default function Home() {
|
||||
const trashMailbox = mailboxes.find(m => m.role === 'trash' && !m.isShared);
|
||||
if (trashMailbox) {
|
||||
try {
|
||||
await moveToMailbox(client, selectedEmail.id, trashMailbox.id);
|
||||
await moveToMailbox(client, emailToDelete.id, trashMailbox.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to move email to trash:", error);
|
||||
}
|
||||
@@ -761,7 +841,7 @@ export default function Home() {
|
||||
if (archiveMode === 'year') {
|
||||
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||
} else {
|
||||
// archiveMode === 'month' — find or create month subfolder under year
|
||||
// archiveMode === 'month' - find or create month subfolder under year
|
||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||
let monthMailbox = mailboxes.find(
|
||||
m => m.name === month && m.parentId === yearId
|
||||
@@ -795,10 +875,10 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAsSpam = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
const handleMarkAsSpam = async (emailToMark: Email | null = selectedEmail) => {
|
||||
if (!client || !emailToMark) return;
|
||||
|
||||
const emailId = selectedEmail.id;
|
||||
const emailId = emailToMark.id;
|
||||
|
||||
try {
|
||||
await markAsSpam(client, emailId);
|
||||
@@ -826,11 +906,11 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUndoSpam = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
const handleUndoSpam = async (emailToRestore: Email | null = selectedEmail) => {
|
||||
if (!client || !emailToRestore) return;
|
||||
|
||||
try {
|
||||
await undoSpam(client, selectedEmail.id);
|
||||
await undoSpam(client, emailToRestore.id);
|
||||
|
||||
const toastInstance = (await import('sonner')).toast;
|
||||
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
|
||||
@@ -886,6 +966,32 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const handleMailboxSelect = async (mailboxId: string) => {
|
||||
if (isUnifiedMailboxId(mailboxId)) {
|
||||
const role = UNIFIED_ROLE_BY_ID[mailboxId];
|
||||
if (!role) return;
|
||||
|
||||
selectMailbox(mailboxId);
|
||||
selectEmail(null);
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView("list");
|
||||
}
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
}
|
||||
|
||||
const built = buildUnifiedAccounts();
|
||||
const populated = await populateUnifiedAccountMailboxes(built);
|
||||
await fetchUnifiedEmailsAction(populated, role);
|
||||
refreshUnifiedCounts(populated);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnifiedView) {
|
||||
exitUnifiedView();
|
||||
}
|
||||
|
||||
selectMailbox(mailboxId);
|
||||
selectEmail(null); // Clear selected email when switching mailboxes
|
||||
|
||||
@@ -969,6 +1075,7 @@ export default function Home() {
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!client) return;
|
||||
if (isUnifiedView) return;
|
||||
setSearchQuery(query);
|
||||
if (!isFilterEmpty(searchFilters)) {
|
||||
await advancedSearch(client);
|
||||
@@ -987,6 +1094,7 @@ export default function Home() {
|
||||
|
||||
const handleAdvancedSearch = async () => {
|
||||
if (!client) return;
|
||||
if (isUnifiedView) return;
|
||||
await advancedSearch(client);
|
||||
};
|
||||
|
||||
@@ -996,9 +1104,9 @@ export default function Home() {
|
||||
clearTimeout(advancedSearchDebounceRef.current);
|
||||
}
|
||||
advancedSearchDebounceRef.current = setTimeout(() => {
|
||||
if (client) advancedSearch(client);
|
||||
if (client && !isUnifiedView) advancedSearch(client);
|
||||
}, 300);
|
||||
}, [client, advancedSearch]);
|
||||
}, [client, advancedSearch, isUnifiedView]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1127,13 +1235,29 @@ export default function Home() {
|
||||
|
||||
// Fetch the full content
|
||||
try {
|
||||
// Find selected mailbox to determine accountId (for shared folders)
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
// Only pass accountId for shared mailboxes
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
// In unified view each email carries its own accountId. Use that
|
||||
// account's client so we fetch from the server that actually owns it.
|
||||
const listEmail = emails.find(e => e.id === email.id);
|
||||
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
||||
const perAccountClient = emailAccountId
|
||||
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
||||
: undefined;
|
||||
const fetchClient = perAccountClient ?? client;
|
||||
|
||||
const fullEmail = await client.getEmail(email.id, accountId);
|
||||
// For shared folders on the primary client, we still need to pass the
|
||||
// shared account's id. In unified view we use the per-account client
|
||||
// directly, so no explicit accountId is needed.
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const accountId = perAccountClient
|
||||
? undefined
|
||||
: mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
|
||||
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||
if (fullEmail) {
|
||||
if (emailAccountId) {
|
||||
fullEmail.accountId = emailAccountId;
|
||||
fullEmail.accountLabel = listEmail?.accountLabel;
|
||||
}
|
||||
selectEmail(fullEmail);
|
||||
// Mark-as-read logic is now handled by useEffect
|
||||
}
|
||||
@@ -1146,7 +1270,7 @@ export default function Home() {
|
||||
|
||||
// Handle back navigation from viewer on mobile.
|
||||
// Delegate to the browser history stack so this button is equivalent to
|
||||
// the OS back button / mouse back button — popstate then restores the
|
||||
// the OS back button / mouse back button - popstate then restores the
|
||||
// previous snapshot via handleNavRestore. The viewer is only reachable
|
||||
// from a state that pushed history, so back() always lands on an app entry.
|
||||
const handleMobileBack = () => {
|
||||
@@ -1397,6 +1521,8 @@ export default function Home() {
|
||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||
data-search-input
|
||||
data-tour="search-input"
|
||||
disabled={isUnifiedView}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -1412,13 +1538,15 @@ export default function Home() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAdvancedSearch}
|
||||
disabled={isUnifiedView}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
isUnifiedView && "opacity-50 cursor-not-allowed",
|
||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={t("advanced_search.toggle_filters")}
|
||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||
@@ -1602,8 +1730,7 @@ export default function Home() {
|
||||
}
|
||||
}}
|
||||
onDelete={async (email) => {
|
||||
selectEmail(email);
|
||||
await handleDelete();
|
||||
await handleDelete(email);
|
||||
}}
|
||||
onArchive={async (email) => {
|
||||
await handleArchive(email);
|
||||
@@ -1617,12 +1744,10 @@ export default function Home() {
|
||||
}
|
||||
}}
|
||||
onMarkAsSpam={async (email) => {
|
||||
selectEmail(email);
|
||||
await handleMarkAsSpam();
|
||||
await handleMarkAsSpam(email);
|
||||
}}
|
||||
onUndoSpam={async (email) => {
|
||||
selectEmail(email);
|
||||
await handleUndoSpam();
|
||||
await handleUndoSpam(email);
|
||||
}}
|
||||
onEditDraft={(email) => {
|
||||
handleEditDraft(email);
|
||||
@@ -1694,7 +1819,8 @@ export default function Home() {
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
|
||||
receivedAt: selectedEmail.receivedAt
|
||||
receivedAt: selectedEmail.receivedAt,
|
||||
attachments: selectedEmail.attachments,
|
||||
} : undefined)}
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
@@ -19,7 +20,7 @@ export default function AdminAuthPage() {
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/config');
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -39,7 +40,7 @@ export default function AdminAuthPage() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/config', {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
@@ -57,7 +58,7 @@ export default function AdminAuthPage() {
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
@@ -38,7 +39,7 @@ export default function AdminBrandingPage() {
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/config');
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) setConfig(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -58,7 +59,7 @@ export default function AdminBrandingPage() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/config', {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
@@ -83,7 +84,7 @@ export default function AdminBrandingPage() {
|
||||
formData.append('file', file);
|
||||
formData.append('slot', slot);
|
||||
|
||||
const res = await fetch('/api/admin/branding', {
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
@@ -112,7 +113,7 @@ export default function AdminBrandingPage() {
|
||||
async function handleDeleteUpload(slot: string) {
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/branding', {
|
||||
const res = await apiFetch('/api/admin/branding', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slot }),
|
||||
@@ -133,7 +134,7 @@ export default function AdminBrandingPage() {
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter();
|
||||
@@ -28,7 +29,7 @@ export default function ChangePasswordPage() {
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/change-password', {
|
||||
const res = await apiFetch('/api/admin/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const NAV_GROUPS = [
|
||||
{
|
||||
@@ -85,7 +86,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const jmapHeaders = getJmapHeaders();
|
||||
const res = await fetch('/api/admin/auth', { headers: jmapHeaders });
|
||||
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
|
||||
const data = await res.json();
|
||||
|
||||
const stalwartAdmin = data.stalwartAdmin === true;
|
||||
@@ -104,7 +105,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
// If Stalwart admin but not yet authenticated, auto-login
|
||||
if (stalwartAdmin) {
|
||||
const loginRes = await fetch('/api/admin/auth', {
|
||||
const loginRes = await apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
|
||||
body: JSON.stringify({ stalwartAuth: true }),
|
||||
@@ -122,7 +123,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/admin/auth', { method: 'DELETE' });
|
||||
await apiFetch('/api/admin/auth', { method: 'DELETE' });
|
||||
router.replace('/admin/login');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -21,7 +22,7 @@ export default function AdminLoginPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/auth', {
|
||||
const res = await apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
@@ -17,7 +18,7 @@ export default function AdminLogsPage() {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (actionFilter) params.set('action', actionFilter);
|
||||
|
||||
const res = await fetch(`/api/admin/audit?${params}`);
|
||||
const res = await apiFetch(`/api/admin/audit?${params}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
@@ -138,7 +139,7 @@ export default function AdminLogsPage() {
|
||||
}
|
||||
|
||||
function formatDetail(detail: Record<string, unknown>): string {
|
||||
if (!detail || Object.keys(detail).length === 0) return '—';
|
||||
if (!detail || Object.keys(detail).length === 0) return '-';
|
||||
if (detail.reason) return String(detail.reason);
|
||||
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
|
||||
if (detail.changes && Array.isArray(detail.changes)) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Filter } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface Extension {
|
||||
slug: string;
|
||||
@@ -57,7 +58,7 @@ export default function AdminMarketplacePage() {
|
||||
params.set('perPage', String(perPage));
|
||||
params.set('sort', 'newest');
|
||||
|
||||
const res = await fetch(`/api/admin/marketplace?${params}`);
|
||||
const res = await apiFetch(`/api/admin/marketplace?${params}`);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error || 'Failed to connect to extension directory');
|
||||
@@ -95,7 +96,7 @@ export default function AdminMarketplacePage() {
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/marketplace', {
|
||||
const res = await apiFetch('/api/admin/marketplace', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -116,7 +117,7 @@ export default function AdminMarketplacePage() {
|
||||
setMessage({ type: 'error', text: data.error || 'Installation failed' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Installation failed — network error' });
|
||||
setMessage({ type: 'error', text: 'Installation failed - network error' });
|
||||
} finally {
|
||||
setInstalling(null);
|
||||
}
|
||||
|
||||
+13
-12
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import type { AuditEntry } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface AdminStatus {
|
||||
enabled: boolean;
|
||||
@@ -38,13 +39,13 @@ export default function AdminDashboardPage() {
|
||||
|
||||
async function fetchDashboardData() {
|
||||
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([
|
||||
fetch('/api/admin/auth'),
|
||||
fetch('/api/admin/audit?limit=10'),
|
||||
fetch('/api/config'),
|
||||
fetch('/api/admin/config'),
|
||||
fetch('/api/admin/plugins').catch(() => null),
|
||||
fetch('/api/admin/themes').catch(() => null),
|
||||
fetch('/api/admin/policy').catch(() => null),
|
||||
apiFetch('/api/admin/auth'),
|
||||
apiFetch('/api/admin/audit?limit=10'),
|
||||
apiFetch('/api/config'),
|
||||
apiFetch('/api/admin/config'),
|
||||
apiFetch('/api/admin/plugins').catch(() => null),
|
||||
apiFetch('/api/admin/themes').catch(() => null),
|
||||
apiFetch('/api/admin/policy').catch(() => null),
|
||||
]);
|
||||
|
||||
if (statusRes.ok) setStatus(await statusRes.json());
|
||||
@@ -75,7 +76,7 @@ export default function AdminDashboardPage() {
|
||||
|
||||
if (configData?.jmapServerUrl) {
|
||||
try {
|
||||
const jmapRes = await fetch('/api/config');
|
||||
const jmapRes = await apiFetch('/api/config');
|
||||
setJmapHealth(jmapRes.ok ? 'ok' : 'error');
|
||||
} catch {
|
||||
setJmapHealth('error');
|
||||
@@ -98,8 +99,8 @@ export default function AdminDashboardPage() {
|
||||
setWarnings(w);
|
||||
}
|
||||
|
||||
const jmapUrl = config?.jmapServerUrl || '—';
|
||||
const jmapHostname = jmapUrl !== '—' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '—';
|
||||
const jmapUrl = config?.jmapServerUrl || '-';
|
||||
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
@@ -126,9 +127,9 @@ export default function AdminDashboardPage() {
|
||||
{/* Server Info */}
|
||||
<SettingsSection title="Server" description="Application and connection details">
|
||||
<SettingItem label="Application">
|
||||
<span className="text-sm text-foreground">{config?.appName || '—'}</span>
|
||||
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="JMAP Server" description={jmapUrl !== '—' ? jmapUrl : undefined}>
|
||||
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
|
||||
<span className="text-sm text-foreground">{jmapHostname}</span>
|
||||
</SettingItem>
|
||||
<SettingItem label="JMAP Connection">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigField {
|
||||
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
|
||||
@@ -68,8 +69,8 @@ export default function PluginConfigPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [pluginsRes, configRes] = await Promise.all([
|
||||
fetch('/api/admin/plugins'),
|
||||
fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||
apiFetch('/api/admin/plugins'),
|
||||
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
|
||||
]);
|
||||
|
||||
if (pluginsRes.ok) {
|
||||
@@ -117,7 +118,7 @@ export default function PluginConfigPage() {
|
||||
|
||||
// Delete if clearing a non-required field
|
||||
if (!newVal && !field.required) {
|
||||
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
@@ -130,7 +131,7 @@ export default function PluginConfigPage() {
|
||||
continue;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value }),
|
||||
@@ -230,7 +231,7 @@ export default function PluginConfigPage() {
|
||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">— Select —</option>
|
||||
<option value="">- Select -</option>
|
||||
{field.options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface PluginEntry {
|
||||
id: string;
|
||||
@@ -34,7 +35,7 @@ export default function AdminPluginsPage() {
|
||||
|
||||
async function fetchPolicy() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy');
|
||||
const res = await apiFetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy(data);
|
||||
@@ -73,7 +74,7 @@ export default function AdminPluginsPage() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy', {
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
@@ -95,7 +96,7 @@ export default function AdminPluginsPage() {
|
||||
async function fetchPlugins() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/plugins');
|
||||
const res = await apiFetch('/api/admin/plugins');
|
||||
if (res.ok) setPlugins(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -113,7 +114,7 @@ export default function AdminPluginsPage() {
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
@@ -136,7 +137,7 @@ export default function AdminPluginsPage() {
|
||||
|
||||
async function togglePlugin(id: string, enabled: boolean) {
|
||||
setMessage(null);
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, enabled }),
|
||||
@@ -156,7 +157,7 @@ export default function AdminPluginsPage() {
|
||||
const body: Record<string, unknown> = { id, forceEnabled };
|
||||
if (forceEnabled) body.enabled = true;
|
||||
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -190,7 +191,7 @@ export default function AdminPluginsPage() {
|
||||
}
|
||||
let failed = 0;
|
||||
for (const p of disabled) {
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: p.id, enabled: true }),
|
||||
@@ -216,7 +217,7 @@ export default function AdminPluginsPage() {
|
||||
}
|
||||
let failed = 0;
|
||||
for (const p of enabled) {
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: p.id, enabled: false }),
|
||||
@@ -236,7 +237,7 @@ export default function AdminPluginsPage() {
|
||||
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
setMessage(null);
|
||||
const res = await fetch('/api/admin/plugins', {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, Lock } from 'lucide-react';
|
||||
import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
// Feature gates managed on their own admin pages (excluded from this list)
|
||||
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
|
||||
@@ -54,7 +55,7 @@ export default function AdminPolicyPage() {
|
||||
async function fetchPolicy() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy');
|
||||
const res = await apiFetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy(data);
|
||||
@@ -106,7 +107,7 @@ export default function AdminPolicyPage() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/policy', {
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
@@ -21,7 +22,7 @@ export default function AdminSettingsPage() {
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/config');
|
||||
const res = await apiFetch('/api/admin/config');
|
||||
if (res.ok) {
|
||||
setConfig(await res.json());
|
||||
}
|
||||
@@ -43,7 +44,7 @@ export default function AdminSettingsPage() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await fetch('/api/admin/config', {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
@@ -61,7 +62,7 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
@@ -121,7 +122,6 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<TextSetting label="Stalwart API URL" configKey="stalwartApiUrl" value={currentValue('stalwartApiUrl') as string} source={config.stalwartApiUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/api" />
|
||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useRef } from 'react';
|
||||
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const BUILTIN_THEME_OPTIONS = [
|
||||
{ id: 'builtin-nord', name: 'Nord' },
|
||||
@@ -38,7 +39,7 @@ export default function AdminThemesPage() {
|
||||
|
||||
async function fetchPolicy() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy');
|
||||
const res = await apiFetch('/api/admin/policy');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicy({
|
||||
@@ -122,7 +123,7 @@ export default function AdminThemesPage() {
|
||||
setSavingPolicy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/policy', {
|
||||
const res = await apiFetch('/api/admin/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(policy),
|
||||
@@ -144,7 +145,7 @@ export default function AdminThemesPage() {
|
||||
async function fetchThemes() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/themes');
|
||||
const res = await apiFetch('/api/admin/themes');
|
||||
if (res.ok) setThemes(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -162,7 +163,7 @@ export default function AdminThemesPage() {
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
@@ -185,7 +186,7 @@ export default function AdminThemesPage() {
|
||||
|
||||
async function toggleTheme(id: string, enabled: boolean) {
|
||||
setMessage(null);
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, enabled }),
|
||||
@@ -204,7 +205,7 @@ export default function AdminThemesPage() {
|
||||
const body: Record<string, unknown> = { id, forceEnabled };
|
||||
if (forceEnabled) body.enabled = true;
|
||||
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -237,7 +238,7 @@ export default function AdminThemesPage() {
|
||||
}
|
||||
let failed = 0;
|
||||
for (const t of disabled) {
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: t.id, enabled: true }),
|
||||
@@ -262,7 +263,7 @@ export default function AdminThemesPage() {
|
||||
}
|
||||
let failed = 0;
|
||||
for (const t of enabled) {
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: t.id, enabled: false }),
|
||||
@@ -282,7 +283,7 @@ export default function AdminThemesPage() {
|
||||
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
|
||||
|
||||
setMessage(null);
|
||||
const res = await fetch('/api/admin/themes', {
|
||||
const res = await apiFetch('/api/admin/themes', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { readAuditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/admin/audit — Get paginated audit log entries (admin-protected)
|
||||
* GET /api/admin/audit - Get paginated audit log entries (admin-protected)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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) {
|
||||
@@ -30,7 +48,7 @@ async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/auth — Login
|
||||
* POST /api/admin/auth - Login
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -92,7 +110,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/auth — Check session status
|
||||
* GET /api/admin/auth - Check session status
|
||||
* Also checks if the user is a Stalwart admin (admin panel enabled even without password).
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -136,7 +154,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/auth — Logout
|
||||
* DELETE /api/admin/auth - Logout
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -14,7 +14,7 @@ const MIME_TYPES: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/branding/[filename] — Serve uploaded branding images
|
||||
* GET /api/admin/branding/[filename] - Serve uploaded branding images
|
||||
*
|
||||
* This endpoint is public (no admin auth) so browsers can load images.
|
||||
* Only files in the branding directory are served; directory traversal is prevented.
|
||||
|
||||
@@ -33,7 +33,7 @@ function sanitizeFilename(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/branding — Upload a branding image file
|
||||
* POST /api/admin/branding - Upload a branding image file
|
||||
*
|
||||
* Expects multipart/form-data with:
|
||||
* - file: the image file
|
||||
@@ -105,7 +105,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/branding — Remove an uploaded branding file
|
||||
* DELETE /api/admin/branding - Remove an uploaded branding file
|
||||
*
|
||||
* Expects JSON body: { slot: string }
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@ import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* POST /api/admin/change-password — Change admin password
|
||||
* POST /api/admin/change-password - Change admin password
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/admin/config — Get full config with sources (admin-protected)
|
||||
* GET /api/admin/config - Get full config with sources (admin-protected)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -26,7 +26,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/config — Update config overrides (admin-protected)
|
||||
* PATCH /api/admin/config - Update config overrides (admin-protected)
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
@@ -64,7 +64,7 @@ export async function PATCH(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/config — Remove admin override for a key (revert to env/default)
|
||||
* DELETE /api/admin/config - Remove admin override for a key (revert to env/default)
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
const DIRECTORY_URL = process.env.EXTENSION_DIRECTORY_URL || 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* GET /api/admin/marketplace — Search/browse the extension directory
|
||||
* GET /api/admin/marketplace - Search/browse the extension directory
|
||||
* Proxies to the extension directory API
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -75,7 +75,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/marketplace — Install an extension from the directory
|
||||
* POST /api/admin/marketplace - Install an extension from the directory
|
||||
* Body: { slug: string, version: string, type: 'plugin' | 'theme' }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/bundle — Serve plugin JS bundle
|
||||
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
|
||||
*
|
||||
* Public endpoint so the client-side plugin loader can fetch bundles.
|
||||
* Only serves plugins that exist in the registry and are enabled.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/a
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/config — Read all config for a plugin
|
||||
* GET /api/admin/plugins/[id]/config - Read all config for a plugin
|
||||
*
|
||||
* Returns the full config object for admin-configured plugin settings.
|
||||
* This endpoint is accessible from the client-side plugin API.
|
||||
@@ -35,7 +35,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/plugins/[id]/config — Set a config key
|
||||
* PUT /api/admin/plugins/[id]/config - Set a config key
|
||||
*
|
||||
* Body: { key: string, value: unknown }
|
||||
* Requires admin authentication (checked via admin session).
|
||||
@@ -83,7 +83,7 @@ export async function PUT(
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/plugins/[id]/config — Delete a config key
|
||||
* DELETE /api/admin/plugins/[id]/config - Delete a config key
|
||||
*
|
||||
* Body: { key: string }
|
||||
*/
|
||||
|
||||
@@ -23,7 +23,7 @@ const SUSPICIOUS_JS_PATTERNS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins — List all admin-managed plugins
|
||||
* GET /api/admin/plugins - List all admin-managed plugins
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -41,7 +41,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/plugins — Upload and install a plugin ZIP
|
||||
* POST /api/admin/plugins - Upload and install a plugin ZIP
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -181,7 +181,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/plugins — Update plugin metadata (enable/disable)
|
||||
* PATCH /api/admin/plugins - Update plugin metadata (enable/disable)
|
||||
* Body: { id: string, enabled: boolean }
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
@@ -218,7 +218,7 @@ export async function PATCH(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/plugins — Remove a plugin
|
||||
* DELETE /api/admin/plugins - Remove a plugin
|
||||
* Body: { id: string }
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { logger } from '@/lib/logger';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
|
||||
/**
|
||||
* GET /api/admin/policy — Get settings policy (NOT admin-protected — users read this)
|
||||
* GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -22,7 +22,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/policy — Update settings policy (admin-protected)
|
||||
* PUT /api/admin/policy - Update settings policy (admin-protected)
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/admin/themes/[id]/css — Serve theme CSS to clients
|
||||
* GET /api/admin/themes/[id]/css - Serve theme CSS to clients
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { MAX_THEME_SIZE } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
|
||||
/**
|
||||
* GET /api/admin/themes — List all admin-managed themes
|
||||
* GET /api/admin/themes - List all admin-managed themes
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -32,7 +32,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/themes — Upload and install a theme ZIP
|
||||
* POST /api/admin/themes - Upload and install a theme ZIP
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -151,7 +151,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/themes — Update theme metadata (enable/disable)
|
||||
* PATCH /api/admin/themes - Update theme metadata (enable/disable)
|
||||
* Body: { id: string, enabled: boolean }
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
@@ -188,7 +188,7 @@ export async function PATCH(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/themes — Remove a theme
|
||||
* DELETE /api/admin/themes - Remove a theme
|
||||
* Body: { id: string }
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT — retrieve full credentials (including password) for session restoration.
|
||||
* PUT - retrieve full credentials (including password) for session restoration.
|
||||
* Protected by multiple Sec-Fetch-* headers to ensure only same-origin
|
||||
* browser fetch() requests succeed. Non-browser clients cannot forge these.
|
||||
*/
|
||||
|
||||
@@ -66,7 +66,7 @@ async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
return url;
|
||||
}
|
||||
} catch {
|
||||
// Network error — endpoint not reachable
|
||||
// Network error - endpoint not reachable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
* GET /api/plugins — Public endpoint for clients to discover server-managed plugins & themes
|
||||
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
|
||||
*
|
||||
* Returns all enabled plugins and themes so the client can sync them to IndexedDB.
|
||||
* No admin auth required — this is how regular users receive plugins/themes.
|
||||
* No admin auth required - this is how regular users receive plugins/themes.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import sharp from 'sharp';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const VALID_SIZES = new Set([192, 512]);
|
||||
|
||||
// Cache resized images in memory to avoid reprocessing on every request
|
||||
const cache = new Map<number, Blob>();
|
||||
|
||||
async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
|
||||
// Absolute URL (http/https)
|
||||
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||||
const res = await fetch(iconUrl);
|
||||
if (!res.ok) throw new Error(`Failed to fetch PWA icon: ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// Path relative to public/ directory
|
||||
const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, ''));
|
||||
return readFile(publicPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ size: string }> }
|
||||
) {
|
||||
const { size: sizeParam } = await params;
|
||||
const size = parseInt(sizeParam, 10);
|
||||
|
||||
if (!VALID_SIZES.has(size)) {
|
||||
return new NextResponse('Invalid size. Allowed: 192, 512', { status: 400 });
|
||||
}
|
||||
|
||||
const iconUrl = process.env.PWA_ICON_URL || process.env.FAVICON_URL;
|
||||
if (!iconUrl) {
|
||||
return new NextResponse('No PWA icon configured', { status: 404 });
|
||||
}
|
||||
|
||||
const pngHeaders = {
|
||||
'Content-Type': 'image/png',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
};
|
||||
|
||||
try {
|
||||
if (cache.has(size)) {
|
||||
return new NextResponse(cache.get(size)!, { headers: pngHeaders });
|
||||
}
|
||||
|
||||
const sourceBuffer = await fetchSourceImage(iconUrl);
|
||||
const resized = await sharp(sourceBuffer)
|
||||
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const ab = new ArrayBuffer(resized.byteLength);
|
||||
new Uint8Array(ab).set(resized);
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
cache.set(size, blob);
|
||||
|
||||
return new NextResponse(blob, { headers: pngHeaders });
|
||||
} catch (err) {
|
||||
console.error('Failed to generate PWA icon:', err);
|
||||
return new NextResponse('Failed to generate icon', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Enforce admin policy — strip locked settings so users can't override them
|
||||
// Enforce admin policy - strip locked settings so users can't override them
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
const filteredSettings = { ...settings };
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -209,10 +209,14 @@ body {
|
||||
}
|
||||
|
||||
.email-content-text a {
|
||||
color: var(--color-primary);
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dark .email-content-text a {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
|
||||
@@ -22,7 +22,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
const appName =
|
||||
process.env.APP_NAME ||
|
||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||
"Bulwark Webmail";
|
||||
|
||||
const shortName = process.env.APP_SHORT_NAME || appName;
|
||||
const description =
|
||||
process.env.APP_DESCRIPTION ||
|
||||
"A modern webmail client built for Stalwart Mail Server";
|
||||
const themeColor = process.env.PWA_THEME_COLOR || "#ffffff";
|
||||
const backgroundColor = process.env.PWA_BACKGROUND_COLOR || "#ffffff";
|
||||
|
||||
// If PWA_ICON_URL or FAVICON_URL is configured, serve dynamically resized PNGs
|
||||
// via /api/pwa-icon/[size]. Otherwise fall back to the default Bulwark PNGs.
|
||||
const hasCustomIcon = !!(process.env.PWA_ICON_URL || process.env.FAVICON_URL);
|
||||
|
||||
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
|
||||
? [
|
||||
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{ src: "/api/pwa-icon/192", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||
{ src: "/api/pwa-icon/512", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
]
|
||||
: [
|
||||
{ src: "/icon-192x192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: "/icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{ src: "/icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||
{ src: "/icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
{ src: "/icon-maskable-dark-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||
{ src: "/icon-maskable-dark-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
];
|
||||
|
||||
return {
|
||||
name: appName,
|
||||
short_name: shortName,
|
||||
description,
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
orientation: "portrait-primary",
|
||||
theme_color: themeColor,
|
||||
background_color: backgroundColor,
|
||||
icons,
|
||||
categories: ["productivity"],
|
||||
screenshots: [
|
||||
{ src: "/screenshot-540x720.png", sizes: "540x720", type: "image/png" },
|
||||
{ src: "/screenshot-1280x720.png", sizes: "1280x720", type: "image/png" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ interface CalendarAgendaViewProps {
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
}
|
||||
|
||||
@@ -33,6 +34,7 @@ export function CalendarAgendaView({
|
||||
onSelectEvent,
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
timeFormat = "24h",
|
||||
}: CalendarAgendaViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
@@ -157,6 +159,7 @@ export function CalendarAgendaView({
|
||||
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
||||
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
|
||||
onMouseLeave={() => onHoverLeave?.()}
|
||||
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
|
||||
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@ interface CalendarDayViewProps {
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
@@ -37,6 +38,7 @@ export function CalendarDayView({
|
||||
onSelectEvent,
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onCreateAtTime,
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
@@ -157,6 +159,7 @@ export function CalendarDayView({
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -264,6 +267,7 @@ export function CalendarDayView({
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -23,6 +23,7 @@ interface CalendarMonthViewProps {
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onCreateAtTime?: (date: Date) => void;
|
||||
firstDayOfWeek?: number;
|
||||
isMobile?: boolean;
|
||||
@@ -37,6 +38,7 @@ export function CalendarMonthView({
|
||||
onSelectEvent,
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onCreateAtTime,
|
||||
firstDayOfWeek = 1,
|
||||
isMobile,
|
||||
@@ -279,6 +281,7 @@ export function CalendarMonthView({
|
||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface CalendarWeekViewProps {
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||
firstDayOfWeek?: number;
|
||||
timeFormat?: "12h" | "24h";
|
||||
@@ -42,6 +43,7 @@ export function CalendarWeekView({
|
||||
onSelectEvent,
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
onContextMenuEvent,
|
||||
onCreateAtTime,
|
||||
firstDayOfWeek = 1,
|
||||
timeFormat = "24h",
|
||||
@@ -242,6 +244,7 @@ export function CalendarWeekView({
|
||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -402,6 +405,7 @@ export function CalendarWeekView({
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(ev, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -17,6 +17,7 @@ interface EventCardProps {
|
||||
onClick?: (anchorRect: DOMRect) => void;
|
||||
onMouseEnter?: (anchorRect: DOMRect) => void;
|
||||
onMouseLeave?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
isSelected?: boolean;
|
||||
draggable?: boolean;
|
||||
continuesBefore?: boolean;
|
||||
@@ -65,7 +66,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
||||
return el;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||
const color = getEventColor(event, calendar);
|
||||
@@ -109,12 +110,15 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
"aria-roledescription": "draggable event",
|
||||
} : {};
|
||||
|
||||
const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined;
|
||||
|
||||
if (variant === "chip") {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||
onMouseLeave={() => onMouseLeave?.()}
|
||||
onContextMenu={handleContextMenu}
|
||||
aria-label={ariaLabel}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
@@ -142,6 +146,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||
onMouseLeave={() => onMouseLeave?.()}
|
||||
onContextMenu={handleContextMenu}
|
||||
aria-label={ariaLabel}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
@@ -172,6 +177,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
|
||||
onMouseLeave={() => onMouseLeave?.()}
|
||||
onContextMenu={handleContextMenu}
|
||||
aria-label={ariaLabel}
|
||||
{...dragProps}
|
||||
data-calendar-event
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
Pencil,
|
||||
Copy,
|
||||
Download,
|
||||
ClipboardCopy,
|
||||
Link as LinkIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface EventContextMenuProps {
|
||||
event: CalendarEvent;
|
||||
position: Position;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onExportICS: () => void;
|
||||
onCopyTitle: () => void;
|
||||
onCopyMeetingLink?: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function EventContextMenu({
|
||||
event,
|
||||
position,
|
||||
isOpen,
|
||||
onClose,
|
||||
menuRef,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onExportICS,
|
||||
onCopyTitle,
|
||||
onCopyMeetingLink,
|
||||
onDelete,
|
||||
}: EventContextMenuProps) {
|
||||
const t = useTranslations("calendar");
|
||||
|
||||
const handle = (fn: () => void) => () => {
|
||||
fn();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const hasMeetingLink = !!(
|
||||
event.virtualLocations && Object.values(event.virtualLocations).some((v) => v.uri)
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />
|
||||
<ContextMenuItem icon={Copy} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("events.export_ics")}
|
||||
onClick={handle(onExportICS)}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={ClipboardCopy}
|
||||
label={t("events.copy_title")}
|
||||
onClick={handle(onCopyTitle)}
|
||||
/>
|
||||
{hasMeetingLink && onCopyMeetingLink && (
|
||||
<ContextMenuItem
|
||||
icon={LinkIcon}
|
||||
label={t("events.copy_link")}
|
||||
onClick={handle(onCopyMeetingLink)}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("events.delete")}
|
||||
onClick={handle(onDelete)}
|
||||
destructive
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { format, parseISO, addHours, addDays } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { ParticipantInput } from "./participant-input";
|
||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -233,6 +233,7 @@ export function EventModal({
|
||||
.map(p => ({ name: p.name, email: p.email }));
|
||||
});
|
||||
const [sendInvitations, setSendInvitations] = useState(true);
|
||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||
|
||||
// Report live preview to parent for grid outline
|
||||
useEffect(() => {
|
||||
@@ -264,6 +265,9 @@ export function EventModal({
|
||||
if (!trimmedTitle || isSaving) return;
|
||||
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return;
|
||||
|
||||
const pendingAttendee = participantInputRef.current?.flush() ?? null;
|
||||
const effectiveAttendees = pendingAttendee ? [...attendees, pendingAttendee] : attendees;
|
||||
|
||||
const startStr = allDay
|
||||
? `${startDate}T00:00:00`
|
||||
: `${startDate}T${startTime}:00`;
|
||||
@@ -301,6 +305,10 @@ export function EventModal({
|
||||
privacy: "public",
|
||||
};
|
||||
|
||||
if (!event) {
|
||||
data.uid = generateUUID();
|
||||
}
|
||||
|
||||
if (location.trim()) {
|
||||
data.locations = {
|
||||
loc1: {
|
||||
@@ -373,18 +381,20 @@ export function EventModal({
|
||||
data.alerts = null;
|
||||
}
|
||||
|
||||
if (attendees.length > 0 && currentUserEmails.length > 0) {
|
||||
if (effectiveAttendees.length > 0 && currentUserEmails.length > 0) {
|
||||
const organizerEmail = currentUserEmails[0];
|
||||
const organizerName = existingParticipants.find(p => p.isOrganizer)?.name || "";
|
||||
data.participants = buildParticipantMap(
|
||||
{ name: organizerName, email: organizerEmail },
|
||||
attendees
|
||||
effectiveAttendees
|
||||
) as Record<string, CalendarParticipant>;
|
||||
} else if (attendees.length === 0 && event?.participants) {
|
||||
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
||||
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||
data.participants = null;
|
||||
data.replyTo = null;
|
||||
}
|
||||
|
||||
const shouldSendScheduling = attendees.length > 0 && sendInvitations;
|
||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(data, shouldSendScheduling);
|
||||
@@ -837,6 +847,7 @@ export function EventModal({
|
||||
</span>
|
||||
</label>
|
||||
<ParticipantInput
|
||||
ref={participantInputRef}
|
||||
participants={attendees}
|
||||
onAdd={handleAddAttendee}
|
||||
onRemove={handleRemoveAttendee}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
|
||||
interface ICalImportModalProps {
|
||||
calendars: Calendar[];
|
||||
@@ -126,7 +127,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
||||
setIsParsing(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/fetch-ical", {
|
||||
const response = await apiFetch("/api/fetch-ical", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: trimmed }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -18,9 +18,13 @@ interface ParticipantInputProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ParticipantInputHandle {
|
||||
flush: () => Participant | null;
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function ParticipantInput({ participants, onAdd, onRemove, disabled }: ParticipantInputProps) {
|
||||
export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled }, ref) {
|
||||
const t = useTranslations("calendar.participants");
|
||||
const [query, setQuery] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<Participant[]>([]);
|
||||
@@ -86,8 +90,28 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
||||
}, [showSuggestions, activeIndex, suggestions, query, addParticipant]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setTimeout(() => setShowSuggestions(false), 200);
|
||||
}, []);
|
||||
setTimeout(() => {
|
||||
setShowSuggestions(false);
|
||||
const trimmed = query.trim();
|
||||
if (trimmed && EMAIL_REGEX.test(trimmed)) {
|
||||
addParticipant({ name: "", email: trimmed });
|
||||
}
|
||||
}, 200);
|
||||
}, [query, addParticipant]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
flush: () => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed || !EMAIL_REGEX.test(trimmed)) return null;
|
||||
if (participants.some(e => e.email.toLowerCase() === trimmed.toLowerCase())) return null;
|
||||
const p = { name: "", email: trimmed };
|
||||
onAdd(p);
|
||||
setQuery("");
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
return p;
|
||||
},
|
||||
}), [query, participants, onAdd]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -160,4 +184,4 @@ export function ParticipantInput({ participants, onAdd, onRemove, disabled }: Pa
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -45,6 +45,9 @@ const defaultProps = {
|
||||
onBulkDelete: vi.fn(),
|
||||
onBulkAddToGroup: vi.fn(),
|
||||
onBulkExport: vi.fn(),
|
||||
onEditContact: vi.fn(),
|
||||
onDeleteContact: vi.fn(),
|
||||
onAddContactToGroup: vi.fn(),
|
||||
};
|
||||
|
||||
describe('ContactList', () => {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Mail, CalendarDays, Loader2 } from "lucide-react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
|
||||
|
||||
const EMAIL_LIMIT = 5;
|
||||
const EVENT_LIMIT = 5;
|
||||
const EVENT_LOOKAHEAD_DAYS = 365;
|
||||
|
||||
interface ContactActivityProps {
|
||||
contact: ContactCard;
|
||||
}
|
||||
|
||||
function getContactEmails(contact: ContactCard): string[] {
|
||||
if (!contact.emails) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const e of Object.values(contact.emails)) {
|
||||
const addr = e.address?.trim().toLowerCase();
|
||||
if (addr && !seen.has(addr)) {
|
||||
seen.add(addr);
|
||||
result.push(addr);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildEmailFilter(addresses: string[]): Record<string, unknown> {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
for (const addr of addresses) {
|
||||
conditions.push({ from: addr });
|
||||
conditions.push({ to: addr });
|
||||
}
|
||||
if (conditions.length === 1) return conditions[0];
|
||||
return { operator: "OR", conditions };
|
||||
}
|
||||
|
||||
function eventInvolvesContact(event: CalendarEvent, addresses: Set<string>): boolean {
|
||||
if (!event.participants) return false;
|
||||
for (const p of Object.values(event.participants)) {
|
||||
const email = p.email?.trim().toLowerCase();
|
||||
if (email && addresses.has(email)) return true;
|
||||
if (p.sendTo) {
|
||||
for (const target of Object.values(p.sendTo)) {
|
||||
const m = typeof target === "string" ? target.match(/mailto:(.+)/i) : null;
|
||||
if (m && addresses.has(m[1].trim().toLowerCase())) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.organizerCalendarAddress) {
|
||||
const m = event.organizerCalendarAddress.match(/mailto:(.+)/i);
|
||||
const org = m ? m[1].trim().toLowerCase() : event.organizerCalendarAddress.trim().toLowerCase();
|
||||
if (addresses.has(org)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function ContactActivity({ contact }: ContactActivityProps) {
|
||||
const t = useTranslations("contacts.activity");
|
||||
const format = useFormatter();
|
||||
const router = useRouter();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const selectEmail = useEmailStore((s) => s.selectEmail);
|
||||
const setSelectedEventId = useCalendarStore((s) => s.setSelectedEventId);
|
||||
const setSelectedDate = useCalendarStore((s) => s.setSelectedDate);
|
||||
|
||||
const [emails, setEmails] = useState<Email[] | null>(null);
|
||||
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
|
||||
const [emailsLoading, setEmailsLoading] = useState(false);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
const [emailsError, setEmailsError] = useState(false);
|
||||
const [eventsError, setEventsError] = useState(false);
|
||||
|
||||
const addresses = getContactEmails(contact);
|
||||
const addressKey = addresses.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || addresses.length === 0) {
|
||||
setEmails([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setEmailsLoading(true);
|
||||
setEmailsError(false);
|
||||
client
|
||||
.advancedSearchEmails(buildEmailFilter(addresses), undefined, EMAIL_LIMIT, 0)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setEmails(res.emails);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setEmailsError(true);
|
||||
setEmails([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setEmailsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || addresses.length === 0) {
|
||||
setEvents([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setEventsLoading(true);
|
||||
setEventsError(false);
|
||||
const now = new Date();
|
||||
const before = new Date(now.getTime() + EVENT_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000);
|
||||
const addrSet = new Set(addresses);
|
||||
client
|
||||
.queryAllCalendarEvents(
|
||||
{ after: now.toISOString(), before: before.toISOString() },
|
||||
[{ property: "start", isAscending: true }],
|
||||
500,
|
||||
)
|
||||
.then((all) => {
|
||||
if (cancelled) return;
|
||||
const matching = all
|
||||
.filter((e) => eventInvolvesContact(e, addrSet))
|
||||
.sort((a, b) => a.start.localeCompare(b.start))
|
||||
.slice(0, EVENT_LIMIT);
|
||||
setEvents(matching);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setEventsError(true);
|
||||
setEvents([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setEventsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, addressKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (addresses.length === 0) return null;
|
||||
|
||||
const handleOpenEmail = (email: Email) => {
|
||||
selectEmail(email);
|
||||
router.push("/mail");
|
||||
};
|
||||
|
||||
const handleOpenEvent = (event: CalendarEvent) => {
|
||||
const start = new Date(event.start);
|
||||
if (!isNaN(start.getTime())) setSelectedDate(start);
|
||||
setSelectedEventId(event.id);
|
||||
router.push("/calendar");
|
||||
};
|
||||
|
||||
const formatEmailDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
const now = new Date();
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
return format.dateTime(d, sameYear
|
||||
? { month: "short", day: "numeric" }
|
||||
: { year: "numeric", month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const formatEventDate = (event: CalendarEvent) => {
|
||||
const d = new Date(event.start);
|
||||
if (isNaN(d.getTime())) return event.start;
|
||||
if (event.showWithoutTime) {
|
||||
return format.dateTime(d, { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
return format.dateTime(d, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivitySection icon={Mail} title={t("recent_emails")}>
|
||||
{emailsLoading ? (
|
||||
<LoadingRow />
|
||||
) : emailsError ? (
|
||||
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
|
||||
) : !emails || emails.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("no_emails")}</p>
|
||||
) : (
|
||||
emails.map((email) => (
|
||||
<button
|
||||
key={email.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEmail(email)}
|
||||
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{email.subject || t("no_subject")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatEmailDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{email.preview && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{email.preview}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</ActivitySection>
|
||||
|
||||
<ActivitySection icon={CalendarDays} title={t("upcoming_events")}>
|
||||
{eventsLoading ? (
|
||||
<LoadingRow />
|
||||
) : eventsError ? (
|
||||
<p className="text-xs text-muted-foreground">{t("load_failed")}</p>
|
||||
) : !events || events.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("no_events")}</p>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={() => handleOpenEvent(event)}
|
||||
className="w-full text-left p-2 -mx-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{event.title || t("no_title")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatEventDate(event)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</ActivitySection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivitySection({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", "border-l-rose-400 dark:border-l-rose-500")}>
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
||||
</div>
|
||||
<div className="space-y-1 pl-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuHeader,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
Eye,
|
||||
Pencil,
|
||||
Mail,
|
||||
ClipboardCopy,
|
||||
Download,
|
||||
Users,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { exportContact } from "./contact-export";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ContactContextMenuProps {
|
||||
contact: ContactCard;
|
||||
position: Position;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
isMultiSelect?: boolean;
|
||||
selectedCount?: number;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onAddToGroup: () => void;
|
||||
onBatchExport?: () => void;
|
||||
onBatchAddToGroup?: () => void;
|
||||
onBatchDelete?: () => void;
|
||||
}
|
||||
|
||||
export function ContactContextMenu({
|
||||
contact,
|
||||
position,
|
||||
isOpen,
|
||||
onClose,
|
||||
menuRef,
|
||||
isMultiSelect = false,
|
||||
selectedCount = 1,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddToGroup,
|
||||
onBatchExport,
|
||||
onBatchAddToGroup,
|
||||
onBatchDelete,
|
||||
}: ContactContextMenuProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
|
||||
const handle = (fn: () => void) => () => {
|
||||
fn();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSendEmail = () => {
|
||||
if (!email) return;
|
||||
window.location.href = `mailto:${email}`;
|
||||
};
|
||||
|
||||
const handleCopyEmail = async () => {
|
||||
if (!email) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(email);
|
||||
toast.success(t("detail.copied"));
|
||||
} catch {
|
||||
toast.error(t("detail.copy_failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
exportContact(contact);
|
||||
toast.success(t("export.success", { count: 1 }));
|
||||
};
|
||||
|
||||
if (showBatchActions) {
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuHeader>
|
||||
{t("bulk.selected", { count: selectedCount })}
|
||||
</ContextMenuHeader>
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={t("bulk.add_to_group")}
|
||||
onClick={handle(() => onBatchAddToGroup?.())}
|
||||
disabled={!onBatchAddToGroup}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("bulk.export")}
|
||||
onClick={handle(() => onBatchExport?.())}
|
||||
disabled={!onBatchExport}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("bulk.delete")}
|
||||
onClick={handle(() => onBatchDelete?.())}
|
||||
disabled={!onBatchDelete}
|
||||
destructive
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
|
||||
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
|
||||
{email && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Mail}
|
||||
label={t("context_menu.send_email")}
|
||||
onClick={handle(handleSendEmail)}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={ClipboardCopy}
|
||||
label={t("detail.copy_email")}
|
||||
onClick={handle(handleCopyEmail)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={t("context_menu.add_to_group")}
|
||||
onClick={handle(onAddToGroup)}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("context_menu.export_vcard")}
|
||||
onClick={handle(handleExport)}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("context_menu.delete")}
|
||||
onClick={handle(onDelete)}
|
||||
destructive
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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 { 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";
|
||||
@@ -87,7 +88,7 @@ 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
|
||||
// data URI - extract base64 content
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) continue;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
@@ -178,7 +179,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "—"}</h2>
|
||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||
{hasNickname && (
|
||||
<p className="text-sm text-muted-foreground truncate">“{nicknames.map(n => n.name).join(", ")}”</p>
|
||||
)}
|
||||
@@ -204,6 +205,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
|
||||
<div className="px-6 py-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<ContactActivity contact={contact} />
|
||||
|
||||
{/* Contact info */}
|
||||
{emails.length > 0 && (
|
||||
<Section icon={Mail} title={t("detail.emails")} category="contact">
|
||||
@@ -278,7 +281,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
<div key={i} className="text-sm">
|
||||
{o.name}
|
||||
{o.units && o.units.length > 0 && (
|
||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
||||
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -371,7 +374,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
{contact.speakToAs.pronouns && (() => {
|
||||
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||
return firstPronoun ? (
|
||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun}</span>
|
||||
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun}</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
@@ -507,7 +507,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name & Identity — full width */}
|
||||
{/* Name & Identity - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={User} title={t("section_identity")} category="contact">
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||
@@ -575,7 +575,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
setEmails(next);
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="">-</option>
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{t("context_private")}</option>
|
||||
</Select>
|
||||
@@ -639,7 +639,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
setPhones(next);
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="">-</option>
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{t("context_private")}</option>
|
||||
</Select>
|
||||
@@ -677,7 +677,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Addresses — full width */}
|
||||
{/* Addresses - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen category="location">
|
||||
<div className="space-y-3">
|
||||
@@ -698,7 +698,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
value={addr.context}
|
||||
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="">-</option>
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{t("context_private")}</option>
|
||||
</Select>
|
||||
@@ -833,7 +833,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
|
||||
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
||||
<option value="">—</option>
|
||||
<option value="">-</option>
|
||||
<option value="masculine">{t("gender_male")}</option>
|
||||
<option value="feminine">{t("gender_female")}</option>
|
||||
<option value="other">{t("gender_other")}</option>
|
||||
@@ -866,7 +866,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Notes — full width */}
|
||||
{/* Notes - full width */}
|
||||
<div className="md:col-span-2 xl:col-span-3">
|
||||
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen category="notes">
|
||||
<textarea
|
||||
|
||||
@@ -199,7 +199,7 @@ export function ContactImportDialog({
|
||||
</div>
|
||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{cName || cEmail || "—"}</div>
|
||||
<div className="text-sm font-medium truncate">{cName || cEmail || "-"}</div>
|
||||
{cEmail && cName && (
|
||||
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
||||
)}
|
||||
|
||||
@@ -17,9 +17,10 @@ interface ContactListItemProps {
|
||||
selectedContactIds: Set<string>;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onCheckboxClick: (e: React.MouseEvent) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, contact: ContactCard) => void;
|
||||
}
|
||||
|
||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick }: ContactListItemProps) {
|
||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu }: ContactListItemProps) {
|
||||
const name = getContactDisplayName(contact);
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const org = contact.organizations
|
||||
@@ -56,6 +57,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu ? (e) => onContextMenu(e, contact) : undefined}
|
||||
className={cn(
|
||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
||||
isSelected
|
||||
@@ -90,7 +92,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{name || email || "—"}
|
||||
{name || email || "-"}
|
||||
</div>
|
||||
{density !== 'extra-compact' && email && name && (
|
||||
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Sq
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName } from "@/stores/contact-store";
|
||||
@@ -28,6 +30,9 @@ interface ContactListProps {
|
||||
onBulkDelete: () => void;
|
||||
onBulkAddToGroup: () => void;
|
||||
onBulkExport: () => void;
|
||||
onEditContact: (id: string) => void;
|
||||
onDeleteContact: (contact: ContactCard) => void;
|
||||
onAddContactToGroup: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ContactList({
|
||||
@@ -47,9 +52,13 @@ export function ContactList({
|
||||
onBulkDelete,
|
||||
onBulkAddToGroup,
|
||||
onBulkExport,
|
||||
onEditContact,
|
||||
onDeleteContact,
|
||||
onAddContactToGroup,
|
||||
}: ContactListProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery) return contacts;
|
||||
@@ -210,11 +219,31 @@ export function ContactList({
|
||||
e.stopPropagation();
|
||||
onToggleSelection(contact.id);
|
||||
}}
|
||||
onContextMenu={(e, c) => openContextMenu(e, c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contextMenu.data && (
|
||||
<ContactContextMenu
|
||||
contact={contextMenu.data}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onClose={closeContextMenu}
|
||||
menuRef={menuRef}
|
||||
isMultiSelect={selectedContactIds.has(contextMenu.data.id)}
|
||||
selectedCount={selectedContactIds.size}
|
||||
onOpen={() => onSelectContact(contextMenu.data!.id)}
|
||||
onEdit={() => onEditContact(contextMenu.data!.id)}
|
||||
onDelete={() => onDeleteContact(contextMenu.data!)}
|
||||
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
|
||||
onBatchExport={onBulkExport}
|
||||
onBatchAddToGroup={onBulkAddToGroup}
|
||||
onBatchDelete={onBulkDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -527,7 +527,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
|| null;
|
||||
|
||||
// Send the iMIP REPLY email to the organizer (client-side scheduling).
|
||||
// Called after updating the local calendar event. Best-effort — if it
|
||||
// Called after updating the local calendar event. Best-effort - if it
|
||||
// fails we still report the RSVP as sent since the calendar was updated.
|
||||
const sendImipReply = async () => {
|
||||
if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) {
|
||||
|
||||
@@ -6,12 +6,13 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime } from "@/lib/utils";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -65,7 +66,7 @@ interface EmailComposerProps {
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
identityId?: string;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
|
||||
}) => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
onDiscardDraft?: (draftId: string) => void;
|
||||
@@ -84,9 +85,22 @@ interface EmailComposerProps {
|
||||
body?: string;
|
||||
htmlBody?: string;
|
||||
receivedAt?: string;
|
||||
accountId?: string;
|
||||
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
type ComposerAttachment = {
|
||||
file?: File;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
blobId?: string;
|
||||
uploading?: boolean;
|
||||
error?: boolean;
|
||||
abortController?: AbortController;
|
||||
};
|
||||
|
||||
export function EmailComposer({
|
||||
onSend,
|
||||
onClose,
|
||||
@@ -199,7 +213,22 @@ export function EmailComposer({
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSavedDataRef = useRef<string>("");
|
||||
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
|
||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||
return replyTo.attachments
|
||||
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
|
||||
// (matches the viewer's hideInlineImageAttachments logic).
|
||||
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||
.map(att => ({
|
||||
name: att.name || 'attachment',
|
||||
type: att.type || 'application/octet-stream',
|
||||
size: att.size,
|
||||
blobId: att.blobId,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const inlineImagesRef = useRef<Array<{ cid: string; blobId: string; type: string; name: string; size: number; dataUrl: string }>>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
||||
const [shakeField, setShakeField] = useState<string | null>(null);
|
||||
@@ -254,12 +283,28 @@ export function EmailComposer({
|
||||
|
||||
if (matchedIdentityId) {
|
||||
setSelectedIdentityId(matchedIdentityId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: match identity by the account's email when replying from unified view
|
||||
if (replyTo?.accountId) {
|
||||
const account = useAccountStore.getState().getAccountById(replyTo.accountId);
|
||||
if (account?.email) {
|
||||
const accountEmail = account.email.trim().toLowerCase();
|
||||
const accountIdentity = identities.find(
|
||||
(identity) => identity.email.trim().toLowerCase() === accountEmail
|
||||
);
|
||||
if (accountIdentity) {
|
||||
setSelectedIdentityId(accountIdentity.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
autoSelectReplyIdentity,
|
||||
identities,
|
||||
initialData?.selectedIdentityId,
|
||||
mode,
|
||||
replyTo?.accountId,
|
||||
replyTo?.bcc,
|
||||
replyTo?.cc,
|
||||
replyTo?.to,
|
||||
@@ -306,7 +351,7 @@ export function EmailComposer({
|
||||
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
|
||||
|
||||
// Track initial values for dirty detection (captured once on first render)
|
||||
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: 0 });
|
||||
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
|
||||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
|
||||
@@ -505,9 +550,16 @@ export function EmailComposer({
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
if (!client || files.length === 0) return;
|
||||
|
||||
const newAttachments = files.map(file => {
|
||||
const newAttachments: ComposerAttachment[] = files.map(file => {
|
||||
const controller = new AbortController();
|
||||
return { file, uploading: true, abortController: controller };
|
||||
return {
|
||||
file,
|
||||
name: file.name,
|
||||
type: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
uploading: true,
|
||||
abortController: controller,
|
||||
};
|
||||
});
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
|
||||
@@ -542,18 +594,38 @@ export function EmailComposer({
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleImageUpload = useCallback((file: File): Promise<string | null> => {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||
reader.onerror = () => {
|
||||
debug.error(`Failed to read inline image ${file.name}`);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
resolve(null);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}, [t]);
|
||||
const handleImageUpload = useCallback(async (
|
||||
file: File,
|
||||
): Promise<{ src: string; cid: string } | null> => {
|
||||
if (!client) return null;
|
||||
try {
|
||||
const readAsDataUrl = new Promise<string | null>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve((e.target?.result as string) ?? null);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const [{ blobId }, dataUrl] = await Promise.all([
|
||||
client.uploadBlob(file),
|
||||
readAsDataUrl,
|
||||
]);
|
||||
if (!dataUrl) throw new Error('Failed to read image as data URL');
|
||||
const cid = `${generateUUID()}@webmail`;
|
||||
inlineImagesRef.current.push({
|
||||
cid,
|
||||
blobId,
|
||||
type: file.type || 'application/octet-stream',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
dataUrl,
|
||||
});
|
||||
return { src: dataUrl, cid };
|
||||
} catch (error) {
|
||||
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
return null;
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return;
|
||||
@@ -630,9 +702,9 @@ export function EmailComposer({
|
||||
.filter(att => att.blobId && !att.uploading)
|
||||
.map(att => ({
|
||||
blobId: att.blobId!,
|
||||
name: att.file.name,
|
||||
type: att.file.type,
|
||||
size: att.file.size,
|
||||
name: att.name,
|
||||
type: att.type,
|
||||
size: att.size,
|
||||
}));
|
||||
|
||||
// Create a hash of current data to compare with last saved
|
||||
@@ -733,6 +805,41 @@ export function EmailComposer({
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
|
||||
// references so recipient clients that strip data URIs can still render them.
|
||||
const rewriteInlineImages = (html: string): {
|
||||
html: string;
|
||||
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
|
||||
} => {
|
||||
const known = inlineImagesRef.current;
|
||||
if (known.length === 0) return { html, attachments: [] };
|
||||
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
||||
const used = new Map<string, typeof known[number]>();
|
||||
|
||||
doc.querySelectorAll('img[data-cid]').forEach((img) => {
|
||||
const cid = img.getAttribute('data-cid');
|
||||
if (!cid) return;
|
||||
const entry = known.find((e) => e.cid === cid);
|
||||
if (!entry) return;
|
||||
img.setAttribute('src', `cid:${cid}`);
|
||||
img.removeAttribute('data-cid');
|
||||
used.set(cid, entry);
|
||||
});
|
||||
|
||||
return {
|
||||
html: doc.body.innerHTML,
|
||||
attachments: Array.from(used.values()).map((e) => ({
|
||||
blobId: e.blobId,
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
size: e.size,
|
||||
disposition: 'inline' as const,
|
||||
cid: e.cid,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const handleSend = async (skipAttachmentCheck = false) => {
|
||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
@@ -803,9 +910,11 @@ export function EmailComposer({
|
||||
? appendPlainTextSignature(body, currentIdentity)
|
||||
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||
|
||||
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||
const finalHtmlBody = plainTextMode
|
||||
? undefined
|
||||
: `<div>${body}</div>${buildSignatureHtml()}`;
|
||||
: `<div>${rewritten!.html}</div>${buildSignatureHtml()}`;
|
||||
const inlineAttachments = rewritten?.attachments ?? [];
|
||||
|
||||
try {
|
||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||
@@ -834,19 +943,29 @@ export function EmailComposer({
|
||||
for (const att of attachments) {
|
||||
if (att.error || att.uploading) continue;
|
||||
let content: ArrayBuffer;
|
||||
if (att.file.size > 0) {
|
||||
if (att.file && att.file.size > 0) {
|
||||
content = await att.file.arrayBuffer();
|
||||
} else if (att.blobId && client) {
|
||||
content = await client.fetchBlobArrayBuffer(att.blobId, att.file.name, att.file.type);
|
||||
content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
mimeAttachments.push({
|
||||
filename: att.file.name,
|
||||
contentType: att.file.type || 'application/octet-stream',
|
||||
filename: att.name,
|
||||
contentType: att.type || 'application/octet-stream',
|
||||
content,
|
||||
});
|
||||
}
|
||||
for (const inline of inlineAttachments) {
|
||||
if (!client) break;
|
||||
const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type);
|
||||
mimeAttachments.push({
|
||||
filename: inline.name,
|
||||
contentType: inline.type,
|
||||
content,
|
||||
cid: inline.cid,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Build canonical MIME
|
||||
const mimeBytes = buildMimeMessage({
|
||||
@@ -906,9 +1025,10 @@ export function EmailComposer({
|
||||
} else {
|
||||
// Standard JMAP send path
|
||||
// Collect uploaded attachment blobIds for the send request
|
||||
const uploadedAttachments = attachments
|
||||
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments
|
||||
.filter(att => att.blobId && !att.uploading && !att.error)
|
||||
.map(att => ({ blobId: att.blobId!, name: att.file.name, type: att.file.type || 'application/octet-stream', size: att.file.size }));
|
||||
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
|
||||
uploadedAttachments.push(...inlineAttachments);
|
||||
|
||||
await onSend?.({
|
||||
to: toAddresses,
|
||||
@@ -1288,9 +1408,9 @@ export function EmailComposer({
|
||||
) : (
|
||||
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
||||
)}
|
||||
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.file.name}</span>
|
||||
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
({formatFileSize(att.file.size)})
|
||||
({formatFileSize(att.size)})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeAttachment(index)}
|
||||
|
||||
@@ -66,6 +66,7 @@ interface EmailContextMenuProps {
|
||||
// Batch actions
|
||||
onBatchMarkAsRead?: (read: boolean) => void;
|
||||
onBatchDelete?: () => void;
|
||||
onBatchArchive?: () => void;
|
||||
onBatchMoveToMailbox?: (mailboxId: string) => void;
|
||||
onBatchMarkAsSpam?: () => void;
|
||||
onBatchUndoSpam?: () => void;
|
||||
@@ -127,6 +128,7 @@ export function EmailContextMenu({
|
||||
onUndoSpam,
|
||||
onBatchMarkAsRead,
|
||||
onBatchDelete,
|
||||
onBatchArchive,
|
||||
onBatchMoveToMailbox,
|
||||
onBatchMarkAsSpam,
|
||||
onBatchUndoSpam,
|
||||
@@ -235,8 +237,10 @@ export function EmailContextMenu({
|
||||
<ContextMenuItem
|
||||
icon={Archive}
|
||||
label={t("archive")}
|
||||
onClick={() => handleAction(onArchive!)}
|
||||
disabled={!onArchive}
|
||||
onClick={() =>
|
||||
handleAction(showBatchActions ? onBatchArchive! : onArchive!)
|
||||
}
|
||||
disabled={showBatchActions ? !onBatchArchive : !onArchive}
|
||||
/>
|
||||
|
||||
{/* Delete */}
|
||||
|
||||
@@ -51,9 +51,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
|
||||
// Resolve color tags using keyword definitions from settings
|
||||
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||
const colorTagIds = getEmailColorTags(email.keywords);
|
||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
|
||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||
// Use first tag for background coloring
|
||||
const keywordDef = keywordDefs[0] ?? null;
|
||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
@@ -176,7 +176,7 @@ export function EmailList({
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await batchDelete(client);
|
||||
await batchDelete(client, isInTrash);
|
||||
} finally {
|
||||
setTimeout(() => setIsProcessing(false), 500);
|
||||
}
|
||||
@@ -496,6 +496,14 @@ export function EmailList({
|
||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchArchive={async () => {
|
||||
if (!onArchive) return;
|
||||
const selected = emails.filter((e) => selectedEmailIds.has(e.id));
|
||||
for (const email of selected) {
|
||||
await onArchive(email);
|
||||
}
|
||||
clearSelection();
|
||||
}}
|
||||
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
||||
onBatchMarkAsSpam={async () => {
|
||||
if (client) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } fr
|
||||
import ReactDOM from "react-dom";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -85,7 +85,7 @@ import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||
import { RecipientPopover } from "./recipient-popover";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { SmimeStatusBanner } from "./smime-status-banner";
|
||||
@@ -740,7 +740,7 @@ function ContactSidebarPanel({
|
||||
<div key={i} className="text-sm">
|
||||
{o.name}
|
||||
{o.units && o.units.length > 0 && (
|
||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
||||
<span className="text-muted-foreground"> - {o.units.map(u => u.name).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -896,6 +896,7 @@ export function EmailViewer({
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
|
||||
@@ -1170,7 +1171,7 @@ export function EmailViewer({
|
||||
return;
|
||||
}
|
||||
|
||||
// Already read — record that so manual unread toggle won't re-trigger auto-mark
|
||||
// Already read - record that so manual unread toggle won't re-trigger auto-mark
|
||||
if (email.keywords?.$seen) {
|
||||
autoMarkedEmailRef.current = email.id;
|
||||
return;
|
||||
@@ -1644,7 +1645,7 @@ export function EmailViewer({
|
||||
// Check if inner content is also signed
|
||||
const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes);
|
||||
if (nestedSignedData) {
|
||||
// Nested sign-then-encrypt — verify inner signature
|
||||
// Nested sign-then-encrypt - verify inner signature
|
||||
const innerBytes = normalizeCmsBytes(nestedSignedData.bytes);
|
||||
smimeDebug('[S/MIME] nested signed-data candidate:', {
|
||||
source: nestedSignedData.source,
|
||||
@@ -1853,7 +1854,7 @@ export function EmailViewer({
|
||||
|
||||
// Check if the email already has a usable HTML body with real content
|
||||
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
||||
// boilerplate (CSS + ) — treat these as effectively empty.
|
||||
// boilerplate (CSS + ) - treat these as effectively empty.
|
||||
const htmlPartId = email.htmlBody?.[0]?.partId;
|
||||
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
||||
let hasRealHtmlBody = !!htmlValue;
|
||||
@@ -1898,7 +1899,7 @@ export function EmailViewer({
|
||||
return;
|
||||
}
|
||||
|
||||
debug.log('email', 'TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
||||
debug.log('email', 'TNEF parse result - htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length);
|
||||
|
||||
if (parsed.htmlBody && !hasRealHtmlBody) {
|
||||
setTnefHtml(parsed.htmlBody);
|
||||
@@ -1912,7 +1913,7 @@ export function EmailViewer({
|
||||
}
|
||||
|
||||
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
||||
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
||||
debug.warn('email', 'TNEF: Parsing succeeded but no content was extracted - the winmail.dat may use an unsupported format');
|
||||
}
|
||||
|
||||
debug.groupEnd();
|
||||
@@ -1975,7 +1976,7 @@ export function EmailViewer({
|
||||
const parsed = await parser.parse(new Uint8Array(blobBytes));
|
||||
if (cancelled) { debug.groupEnd(); return; }
|
||||
|
||||
debug.log('email', 'Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
||||
debug.log('email', 'Embedded RFC822 parsed - html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)',
|
||||
', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)',
|
||||
', attachments:', parsed.attachments?.length ?? 0);
|
||||
|
||||
@@ -2074,21 +2075,30 @@ export function EmailViewer({
|
||||
|
||||
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
|
||||
if (smimeDecryptedAttachments.length > 0) {
|
||||
return smimeDecryptedAttachments.map((attachment, index) => ({
|
||||
id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
|
||||
name: attachment.filename,
|
||||
type: attachment.mimeType || 'application/octet-stream',
|
||||
size: getPostalMimeAttachmentSize(attachment),
|
||||
cid: attachment.contentId,
|
||||
decryptedAttachment: attachment,
|
||||
}));
|
||||
return smimeDecryptedAttachments
|
||||
.filter(att => !(hideInlineImageAttachments && att.contentId && (att.mimeType || '').startsWith('image/')))
|
||||
.map((attachment, index) => ({
|
||||
id: `smime-${index}-${attachment.filename || attachment.mimeType}`,
|
||||
name: attachment.filename,
|
||||
type: attachment.mimeType || 'application/octet-stream',
|
||||
size: getPostalMimeAttachmentSize(attachment),
|
||||
cid: attachment.contentId,
|
||||
decryptedAttachment: attachment,
|
||||
}));
|
||||
}
|
||||
|
||||
const hasCalInvitation = calendarInvitationParsingEnabled && !!email && !!findCalendarAttachment(email);
|
||||
const jmapAttachments = (email?.attachments ?? [])
|
||||
// Hide winmail.dat when we have successfully extracted TNEF content or attachments
|
||||
.filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type))
|
||||
// Hide message/rfc822 when we have unwrapped the embedded email
|
||||
.filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822')
|
||||
// Hide calendar MIME parts (text/calendar, application/ics) when the invitation
|
||||
// banner is shown - prevents raw ICS files appearing as spurious attachments.
|
||||
.filter(att => !hasCalInvitation || !isCalendarMimeType(att.type))
|
||||
// Hide inline cid-referenced images when the user has opted to keep them
|
||||
// out of the attachment list (default on): these are embedded in the body.
|
||||
.filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
|
||||
.map((attachment, index) => ({
|
||||
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
||||
name: attachment.name || null,
|
||||
@@ -2119,7 +2129,7 @@ export function EmailViewer({
|
||||
}));
|
||||
|
||||
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
||||
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments]);
|
||||
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]);
|
||||
|
||||
// Generate email source for viewing
|
||||
const generateEmailSource = (email: Email): string => {
|
||||
@@ -2393,16 +2403,8 @@ export function EmailViewer({
|
||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||
const textContent = email.bodyValues[email.textBody[0].partId].value;
|
||||
|
||||
// Convert plain text to HTML with proper formatting
|
||||
// Uses white-space: pre-wrap on the container to preserve newlines/whitespace
|
||||
const htmlFromText = textContent
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
|
||||
return {
|
||||
html: htmlFromText,
|
||||
html: plainTextToSafeHtml(textContent),
|
||||
isHtml: false
|
||||
};
|
||||
}
|
||||
@@ -2440,12 +2442,7 @@ export function EmailViewer({
|
||||
return { html: cleanHtml, isHtml: true };
|
||||
}
|
||||
if (smimeDecryptedText) {
|
||||
const htmlFromText = smimeDecryptedText
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
return { html: htmlFromText, isHtml: false };
|
||||
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false };
|
||||
}
|
||||
// TNEF (winmail.dat) extracted content
|
||||
if (tnefHtml) {
|
||||
@@ -2453,12 +2450,7 @@ export function EmailViewer({
|
||||
return { html: cleanHtml, isHtml: true };
|
||||
}
|
||||
if (tnefText) {
|
||||
const htmlFromText = tnefText
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
return { html: htmlFromText, isHtml: false };
|
||||
return { html: plainTextToSafeHtml(tnefText), isHtml: false };
|
||||
}
|
||||
// Embedded message/rfc822 unwrapped content
|
||||
if (embeddedEmailHtml) {
|
||||
@@ -2466,12 +2458,7 @@ export function EmailViewer({
|
||||
return { html: cleanHtml, isHtml: true };
|
||||
}
|
||||
if (embeddedEmailText) {
|
||||
const htmlFromText = embeddedEmailText
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
return { html: htmlFromText, isHtml: false };
|
||||
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false };
|
||||
}
|
||||
return emailContent;
|
||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||
@@ -2957,7 +2944,7 @@ export function EmailViewer({
|
||||
<PluginSlot name="toolbar-actions" />
|
||||
</div>
|
||||
|
||||
{/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
|
||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||
{isLoading && (
|
||||
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
||||
@@ -3054,7 +3041,7 @@ export function EmailViewer({
|
||||
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
||||
</Button>
|
||||
|
||||
{/* Tag Picker — hidden on mobile, overflows to More menu */}
|
||||
{/* Tag Picker - hidden on mobile, overflows to More menu */}
|
||||
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
||||
<div className="w-px h-5 bg-border mx-0.5" />
|
||||
<div ref={tagMenuRef} className="relative">
|
||||
@@ -3070,13 +3057,13 @@ export function EmailViewer({
|
||||
<>
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.slice(0, 3).map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId);
|
||||
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
|
||||
})}
|
||||
</span>
|
||||
{showToolbarLabels && currentColors.length === 1 && (
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{emailKeywords.find(k => k.id === currentColors[0])?.label}
|
||||
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -3123,7 +3110,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spam — hidden on mobile, overflows to More menu */}
|
||||
{/* Spam - hidden on mobile, overflows to More menu */}
|
||||
{(onMarkAsSpam || onUndoSpam) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -3145,7 +3132,7 @@ export function EmailViewer({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Toggle read state — hidden on mobile, overflows to More menu */}
|
||||
{/* Toggle read state - hidden on mobile, overflows to More menu */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3158,7 +3145,7 @@ export function EmailViewer({
|
||||
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
{/* Print — hidden on mobile, overflows to More menu */}
|
||||
{/* Print - hidden on mobile, overflows to More menu */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3172,7 +3159,7 @@ export function EmailViewer({
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
||||
</Button>
|
||||
|
||||
{/* View source — hidden on mobile, overflows to More menu */}
|
||||
{/* View source - hidden on mobile, overflows to More menu */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3200,7 +3187,7 @@ export function EmailViewer({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* More menu — click-based */}
|
||||
{/* More menu - click-based */}
|
||||
<div ref={moreMenuRef} className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -3246,7 +3233,7 @@ export function EmailViewer({
|
||||
<Archive className="w-4 h-4" />
|
||||
{t('archive')}
|
||||
</button>
|
||||
{/* Overflow: move to folder — submenu */}
|
||||
{/* Overflow: move to folder - submenu */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
|
||||
onMouseEnter={() => setMoreMenuSub('move')}
|
||||
@@ -3298,7 +3285,7 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Overflow: tag — submenu */}
|
||||
{/* Overflow: tag - submenu */}
|
||||
{colorOptions.length > 0 && (
|
||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||
@@ -3674,11 +3661,11 @@ export function EmailViewer({
|
||||
{currentColors.length > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId);
|
||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
||||
return dotClass ? (
|
||||
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
||||
) : null;
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
|
||||
return (
|
||||
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
@@ -4035,7 +4022,7 @@ export function EmailViewer({
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
||||
Spam Score
|
||||
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better — scores above 5 are likely spam" />
|
||||
<InfoTooltip text="A score assigned by the server based on spam analysis. Lower is better - scores above 5 are likely spam" />
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-xs",
|
||||
@@ -4787,61 +4774,61 @@ export function EmailViewer({
|
||||
onClick={onNavigatePrev}
|
||||
disabled={!onNavigatePrev}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
|
||||
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||
)}
|
||||
aria-label={t('tooltips.previous')}
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('previous')}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('previous')}</span>
|
||||
</button>
|
||||
{isDraft && onEditDraft ? (
|
||||
<button
|
||||
onClick={() => onEditDraft()}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-primary active:text-primary/80 transition-colors duration-150"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary active:text-primary/80 transition-colors duration-150"
|
||||
aria-label={t('tooltips.edit_draft')}
|
||||
>
|
||||
<EditIcon className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('edit_draft')}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('edit_draft')}</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onReply?.()}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
aria-label={t('tooltips.reply')}
|
||||
>
|
||||
<Reply className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('reply')}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onReplyAll}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
aria-label={t('tooltips.reply_all')}
|
||||
>
|
||||
<ReplyAll className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('reply_all')}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('reply_all')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onForward}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
aria-label={t('tooltips.forward')}
|
||||
>
|
||||
<Forward className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('forward')}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('forward')}</span>
|
||||
</button>
|
||||
</>)}
|
||||
<button
|
||||
onClick={onNavigateNext}
|
||||
disabled={!onNavigateNext}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0 transition-colors duration-150",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150",
|
||||
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||
)}
|
||||
aria-label={t('tooltips.next')}
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('next')}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t('next')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -14,7 +14,7 @@ interface RecipientPopoverProps {
|
||||
email: string;
|
||||
/** Display label override (e.g. "me") */
|
||||
displayLabel?: string;
|
||||
/** Called when user clicks "View contact" — receives the contact and email */
|
||||
/** Called when user clicks "View contact" - receives the contact and email */
|
||||
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -113,6 +113,11 @@ export const ResizableImage = Node.create({
|
||||
alt: { default: null },
|
||||
title: { default: null },
|
||||
width: { default: null },
|
||||
cid: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-cid"),
|
||||
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -31,10 +31,15 @@ import {
|
||||
Heading2,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface InlineImageUpload {
|
||||
src: string;
|
||||
cid?: string;
|
||||
}
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
onChange: (html: string) => void;
|
||||
onImageUpload?: (file: File) => Promise<string | null>;
|
||||
onImageUpload?: (file: File) => Promise<InlineImageUpload | null>;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
hasError?: boolean;
|
||||
@@ -120,11 +125,11 @@ export function RichTextEditor({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
for (const file of imageFiles) {
|
||||
upload(file).then((url) => {
|
||||
if (url) {
|
||||
upload(file).then((result) => {
|
||||
if (result) {
|
||||
const { state } = view;
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
||||
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
|
||||
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
@@ -141,10 +146,10 @@ export function RichTextEditor({
|
||||
if (imageFiles.length === 0) return false;
|
||||
event.preventDefault();
|
||||
for (const file of imageFiles) {
|
||||
upload(file).then((url) => {
|
||||
if (url) {
|
||||
upload(file).then((result) => {
|
||||
if (result) {
|
||||
const { state } = view;
|
||||
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
||||
const node = state.schema.nodes.image.create({ src: result.src, alt: file.name, cid: result.cid });
|
||||
const tr = state.tr.replaceSelectionWith(node);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
@@ -237,6 +237,7 @@ function EmailCard({
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
|
||||
const sender = email.from?.[0];
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
@@ -419,12 +420,7 @@ function EmailCard({
|
||||
// Plain text fallback
|
||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||
const text = email.bodyValues[email.textBody[0].partId].value;
|
||||
const htmlEscaped = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">$1</a>');
|
||||
return { html: htmlEscaped, isHtml: false };
|
||||
return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,10 +545,14 @@ function EmailCard({
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
{email.attachments && email.attachments.length > 0 && (
|
||||
{(() => {
|
||||
const visibleAttachments = (email.attachments ?? []).filter(
|
||||
att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))
|
||||
);
|
||||
return visibleAttachments.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{email.attachments.map((attachment, idx) => {
|
||||
{visibleAttachments.map((attachment, idx) => {
|
||||
const Icon = getFileIcon(attachment.name, attachment.type);
|
||||
const isPreviewable = isFilePreviewable(attachment.name, attachment.type);
|
||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||
@@ -581,7 +581,8 @@ function EmailCard({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="px-4 pb-4 flex gap-2">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
@@ -63,13 +64,16 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
|
||||
|
||||
// Resolve color tags using keyword definitions
|
||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
|
||||
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||
const resolvedColorTag = (() => {
|
||||
if (colorTag) return colorTag;
|
||||
@@ -184,6 +188,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{isUnifiedView && email.accountId && accountColor && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: accountColor }}
|
||||
title={email.accountLabel}
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
@@ -228,6 +239,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{isUnifiedView && email.accountId && accountColor && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: accountColor }}
|
||||
title={email.accountLabel}
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
@@ -345,7 +363,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
@@ -375,7 +395,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||
const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null;
|
||||
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
||||
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
@@ -548,6 +568,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: threadAccountColor }}
|
||||
title={latestEmail.accountLabel}
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
@@ -602,6 +629,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: threadAccountColor }}
|
||||
title={latestEmail.accountLabel}
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
hasUnread
|
||||
|
||||
@@ -412,7 +412,7 @@ export function FileBrowser({
|
||||
// Filter and sort resources
|
||||
const displayResources = useMemo(() => {
|
||||
let filtered = resources;
|
||||
// In sidebar mode, folders are shown in the sidebar tree — hide them from the main list
|
||||
// In sidebar mode, folders are shown in the sidebar tree - hide them from the main list
|
||||
if (folderLayout === "sidebar") {
|
||||
filtered = filtered.filter(r => !r.isDirectory);
|
||||
}
|
||||
@@ -1466,7 +1466,7 @@ export function FileBrowser({
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
||||
{resource.isDirectory ? "—" : formatFileSize(resource.contentLength)}
|
||||
{resource.isDirectory ? "-" : formatFileSize(resource.contentLength)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
|
||||
{formatDate(resource.lastModified)}
|
||||
|
||||
@@ -223,7 +223,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
{!loading && !error && fileType === "pdf" && objectUrl && (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
sandbox="allow-same-origin"
|
||||
sandbox="allow-scripts"
|
||||
className="w-full max-w-5xl h-full rounded-lg bg-white"
|
||||
title={name}
|
||||
/>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
};
|
||||
});
|
||||
|
||||
// Don't cache empty root results — empty root likely means client wasn't ready yet
|
||||
// Don't cache empty root results - empty root likely means client wasn't ready yet
|
||||
if (folders.length > 0 || parentId !== null) {
|
||||
setChildrenCache(prev => new Map(prev).set(cacheKey, folders));
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from "@/lib/jmap/sieve-types";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface FilterRuleModalProps {
|
||||
rule?: FilterRule;
|
||||
@@ -58,6 +59,7 @@ export function FilterRuleModal({
|
||||
}: FilterRuleModalProps) {
|
||||
const t = useTranslations("settings.filters");
|
||||
const isEdit = !!rule;
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
|
||||
const [name, setName] = useState(rule?.name || "");
|
||||
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
||||
@@ -375,12 +377,17 @@ export function FilterRuleModal({
|
||||
)}
|
||||
|
||||
{action.type === "add_label" && (
|
||||
<Input
|
||||
<select
|
||||
value={action.value || ""}
|
||||
onChange={(e) => updateAction(index, { value: e.target.value })}
|
||||
placeholder={t("label_placeholder")}
|
||||
className="flex-1 min-w-[140px]"
|
||||
/>
|
||||
className={`${selectClass} flex-1 min-w-[140px]`}
|
||||
aria-label={t("label_placeholder")}
|
||||
>
|
||||
<option value="">{t("label_placeholder")}</option>
|
||||
{emailKeywords.map((kw) => (
|
||||
<option key={kw.id} value={kw.id}>{kw.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AccountSwitcher } from "./account-switcher";
|
||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { usePathname, Link } from "@/i18n/navigation";
|
||||
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
||||
import NextLink from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
@@ -18,10 +18,11 @@ import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { getInitials } from "@/lib/account-utils";
|
||||
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
@@ -162,6 +163,7 @@ export function NavigationRail({
|
||||
}: NavigationRailProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
@@ -222,13 +224,14 @@ export function NavigationRail({
|
||||
let cancelled = false;
|
||||
const headers = getActiveAccountSlotHeaders();
|
||||
if (!headers['X-JMAP-Cookie-Slot']) return;
|
||||
fetch('/api/admin/stalwart-check', { headers })
|
||||
apiFetch('/api/admin/auth', { headers })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (!cancelled && data.isStalwartAdmin) {
|
||||
setIsStalwartAdmin(true);
|
||||
if (cancelled || !data.stalwartAdmin) return;
|
||||
setIsStalwartAdmin(true);
|
||||
if (!data.authenticated) {
|
||||
// Pre-create admin session so /admin works even after full page navigation
|
||||
fetch('/api/admin/auth', {
|
||||
apiFetch('/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ stalwartAuth: true }),
|
||||
@@ -274,7 +277,7 @@ export function NavigationRail({
|
||||
href={item.href}
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "text-primary"
|
||||
@@ -293,7 +296,7 @@ export function NavigationRail({
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight">{t(item.labelKey)}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t(item.labelKey)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -315,7 +318,7 @@ export function NavigationRail({
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "text-primary"
|
||||
@@ -328,7 +331,7 @@ export function NavigationRail({
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-[64px]">{app.name}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{app.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -338,13 +341,13 @@ export function NavigationRail({
|
||||
<NextLink
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"transition-colors duration-150",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Shield className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t("admin") || "Admin"}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
|
||||
</NextLink>
|
||||
)}
|
||||
|
||||
@@ -353,7 +356,7 @@ export function NavigationRail({
|
||||
href="/settings"
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] shrink-0",
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
|
||||
"transition-colors duration-150",
|
||||
isSettingsActive
|
||||
? "text-primary"
|
||||
@@ -367,7 +370,7 @@ export function NavigationRail({
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight">{t("settings")}</span>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("settings")}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
);
|
||||
@@ -604,6 +607,16 @@ export function NavigationRail({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{accounts.length < MAX_ACCOUNTS && (
|
||||
<button
|
||||
onClick={() => router.push(`/login?mode=add-account` as never)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
|
||||
title={t("add_account")}
|
||||
aria-label={t("add_account")}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logout button with popover */}
|
||||
|
||||
@@ -155,7 +155,7 @@ function SidebarAppForm({
|
||||
{t('icon_label')} <span className="text-destructive">*</span>
|
||||
{SelectedIcon && (
|
||||
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
||||
— <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
||||
- <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
+570
-359
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ import nlMessages from '@/locales/nl/common.json';
|
||||
import plMessages from '@/locales/pl/common.json';
|
||||
import ptMessages from '@/locales/pt/common.json';
|
||||
import ruMessages from '@/locales/ru/common.json';
|
||||
import ukMessages from '@/locales/uk/common.json';
|
||||
import zhMessages from '@/locales/zh/common.json';
|
||||
|
||||
// Pre-loaded translations (loaded at build time, not runtime)
|
||||
@@ -31,6 +32,7 @@ const ALL_MESSAGES = {
|
||||
pl: plMessages,
|
||||
pt: ptMessages,
|
||||
ru: ruMessages,
|
||||
uk: ukMessages,
|
||||
zh: zhMessages,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
@@ -14,6 +15,7 @@ export function PWAInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] =
|
||||
useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(DISMISSED_KEY)) return;
|
||||
@@ -56,14 +58,31 @@ export function PWAInstallPrompt() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const logoSrc = appLogoLightUrl || faviconUrl;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Download className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
{logoSrc ? (
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt={appName}
|
||||
className="w-8 h-8 shrink-0 object-contain dark:hidden"
|
||||
/>
|
||||
) : (
|
||||
<Download className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 shrink-0" />
|
||||
)}
|
||||
{logoSrc && (
|
||||
<img
|
||||
src={appLogoDarkUrl || faviconUrl}
|
||||
alt={appName}
|
||||
className="w-8 h-8 shrink-0 object-contain hidden dark:block"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
|
||||
Install Bulwark
|
||||
Install {appName}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
Install our app for quick access and offline support.
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { useAccountSecurityStore } from '@/stores/account-security-store';
|
||||
import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type AppCredentialInput } from '@/stores/account-security-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -172,37 +174,95 @@ function DisplayNameSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function generateTotp(accountLabel: string): { totp: OTPAuth.TOTP; url: string } {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'Stalwart',
|
||||
label: accountLabel || 'account',
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: new OTPAuth.Secret({ size: 20 }),
|
||||
});
|
||||
return { totp, url: totp.toString() };
|
||||
}
|
||||
|
||||
function TotpSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { otpEnabled, enableTotp, disableTotp, isSaving, isLoadingAuth } = useAccountSecurityStore();
|
||||
const [totpUrl, setTotpUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { client } = useAuthStore();
|
||||
|
||||
const [setupUrl, setSetupUrl] = useState<string | null>(null);
|
||||
const [setupTotp, setSetupTotp] = useState<OTPAuth.TOTP | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [otpCode, setOtpCode] = useState('');
|
||||
const [setupError, setSetupError] = useState<string | null>(null);
|
||||
const [disableOpen, setDisableOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setupUrl) { setQrDataUrl(null); return; }
|
||||
let cancelled = false;
|
||||
QRCode.toDataURL(setupUrl, { width: 220, margin: 1 })
|
||||
.then((url) => { if (!cancelled) setQrDataUrl(url); })
|
||||
.catch(() => { /* ignore */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [setupUrl]);
|
||||
|
||||
const startSetup = () => {
|
||||
const { totp, url } = generateTotp(client?.getUsername() ?? 'account');
|
||||
setSetupTotp(totp);
|
||||
setSetupUrl(url);
|
||||
setPassword('');
|
||||
setOtpCode('');
|
||||
setSetupError(null);
|
||||
};
|
||||
|
||||
const cancelSetup = () => {
|
||||
setSetupTotp(null);
|
||||
setSetupUrl(null);
|
||||
setPassword('');
|
||||
setOtpCode('');
|
||||
setSetupError(null);
|
||||
};
|
||||
|
||||
const confirmSetup = async () => {
|
||||
if (!setupTotp || !setupUrl) return;
|
||||
if (!password) { setSetupError(t('totp.password_required')); return; }
|
||||
if (!otpCode.trim()) { setSetupError(t('totp.code_required')); return; }
|
||||
if (setupTotp.validate({ token: otpCode.trim(), window: 1 }) === null) {
|
||||
setSetupError(t('totp.code_invalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleToggle = async (enable: boolean) => {
|
||||
try {
|
||||
if (enable) {
|
||||
const url = await enableTotp();
|
||||
setTotpUrl(url);
|
||||
toast.success(t('totp.enabled'));
|
||||
} else {
|
||||
await disableTotp();
|
||||
setTotpUrl(null);
|
||||
toast.success(t('totp.disabled'));
|
||||
}
|
||||
await enableTotp(password, setupUrl, otpCode.trim());
|
||||
cancelSetup();
|
||||
toast.success(t('totp.enabled'));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
enable ? t('totp.enable_error') : t('totp.disable_error'),
|
||||
err instanceof Error ? err.message : undefined
|
||||
);
|
||||
setSetupError(err instanceof Error ? err.message : t('totp.enable_error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (totpUrl) {
|
||||
navigator.clipboard.writeText(totpUrl).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
const handleDisable = async () => {
|
||||
if (!password) { setSetupError(t('totp.password_required')); return; }
|
||||
try {
|
||||
await disableTotp(password);
|
||||
setDisableOpen(false);
|
||||
setPassword('');
|
||||
setSetupError(null);
|
||||
toast.success(t('totp.disabled'));
|
||||
} catch (err) {
|
||||
setSetupError(err instanceof Error ? err.message : t('totp.disable_error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (enable: boolean) => {
|
||||
setSetupError(null);
|
||||
if (enable) {
|
||||
startSetup();
|
||||
} else {
|
||||
setDisableOpen(true);
|
||||
setPassword('');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -218,30 +278,65 @@ function TotpSection() {
|
||||
<div className="space-y-3">
|
||||
<SettingItem label={t('totp.label')} description={t('totp.description')}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ToggleSwitch
|
||||
checked={otpEnabled}
|
||||
onChange={handleToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
)}
|
||||
<ToggleSwitch
|
||||
checked={otpEnabled || !!setupUrl}
|
||||
onChange={handleToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<span className={cn('text-xs font-medium', otpEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
|
||||
{otpEnabled ? t('totp.active') : t('totp.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
{totpUrl && (
|
||||
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
|
||||
{setupUrl && (
|
||||
<div className="ml-4 p-3 bg-muted rounded-md space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p>
|
||||
{qrDataUrl && (
|
||||
<div className="flex justify-center">
|
||||
<img src={qrDataUrl} alt="TOTP QR code" className="rounded bg-white p-2" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">
|
||||
{totpUrl}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyUrl}>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 truncate">{setupUrl}</code>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('password.current')}</label>
|
||||
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('totp.verification_code')}</label>
|
||||
<Input value={otpCode} onChange={(e) => setOtpCode(e.target.value)} inputMode="numeric" maxLength={6} />
|
||||
</div>
|
||||
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={confirmSetup} disabled={isSaving || !password || !otpCode}>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
{t('totp.confirm')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelSetup}>{t('app_passwords.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{disableOpen && (
|
||||
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('totp.disable_confirm_prompt')}</p>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('password.current')}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="destructive" onClick={handleDisable} disabled={isSaving || !password}>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
{t('totp.disable')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setDisableOpen(false); setPassword(''); setSetupError(null); }}>
|
||||
{t('app_passwords.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,58 +345,113 @@ function TotpSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function AppPasswordsSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { appPasswords, addAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
function parseIpList(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const generatePassword = useCallback(() => {
|
||||
const chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
let result = '';
|
||||
const array = new Uint8Array(24);
|
||||
crypto.getRandomValues(array);
|
||||
for (const byte of array) {
|
||||
result += chars[byte % chars.length];
|
||||
}
|
||||
// Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx
|
||||
return result.match(/.{1,4}/g)?.join('-') ?? result;
|
||||
}, []);
|
||||
function CredentialRow({ entry, onRemove, isSaving }: { entry: AppPasswordInfo | ApiKeyInfo; onRemove: (id: string) => void; isSaving: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between py-2 px-3 bg-muted/50 rounded-md gap-2">
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm text-foreground truncate">{entry.description || entry.id}</span>
|
||||
{entry.createdAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
{entry.expiresAt ? ` · expires ${new Date(entry.expiresAt).toLocaleDateString()}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{entry.allowedIps.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{entry.allowedIps.map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="text-[10px] font-mono bg-background border border-border rounded px-1.5 py-0.5 text-muted-foreground"
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(entry.id)}
|
||||
disabled={isSaving}
|
||||
className="text-destructive hover:text-destructive shrink-0"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CredentialSectionProps {
|
||||
icon: typeof Smartphone;
|
||||
i18nNamespace: 'app_passwords' | 'api_keys';
|
||||
entries: Array<AppPasswordInfo | ApiKeyInfo>;
|
||||
onCreate: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
|
||||
onRemove: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRemove }: CredentialSectionProps) {
|
||||
const t = useTranslations('settings.security');
|
||||
const tk = (key: string) => t(`${i18nNamespace}.${key}`);
|
||||
const { isSaving, isLoadingAuth } = useAccountSecurityStore();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newDescription, setNewDescription] = useState('');
|
||||
const [expiresAt, setExpiresAt] = useState('');
|
||||
const [allowedIpsRaw, setAllowedIpsRaw] = useState('');
|
||||
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
|
||||
const password = newPassword || generatePassword();
|
||||
if (!newDescription.trim()) return;
|
||||
|
||||
try {
|
||||
await addAppPassword(newName.trim(), password);
|
||||
setNewName('');
|
||||
setNewPassword('');
|
||||
const result = await onCreate({
|
||||
description: newDescription.trim(),
|
||||
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
||||
allowedIps: parseIpList(allowedIpsRaw),
|
||||
});
|
||||
setCreatedSecret(result.secret);
|
||||
setNewDescription('');
|
||||
setExpiresAt('');
|
||||
setAllowedIpsRaw('');
|
||||
setShowAdd(false);
|
||||
toast.success(t('app_passwords.added'));
|
||||
toast.success(tk('added'));
|
||||
} catch (err) {
|
||||
toast.error(t('app_passwords.add_error'), err instanceof Error ? err.message : undefined);
|
||||
toast.error(tk('add_error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (name: string) => {
|
||||
const handleRemove = async (id: string) => {
|
||||
try {
|
||||
await removeAppPassword(name);
|
||||
toast.success(t('app_passwords.removed'));
|
||||
await onRemove(id);
|
||||
toast.success(tk('removed'));
|
||||
} catch (err) {
|
||||
toast.error(t('app_passwords.remove_error'), err instanceof Error ? err.message : undefined);
|
||||
toast.error(tk('remove_error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySecret = () => {
|
||||
if (!createdSecret) return;
|
||||
navigator.clipboard.writeText(createdSecret).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingAuth) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Smartphone className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
|
||||
</div>
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
@@ -312,53 +462,61 @@ function AppPasswordsSection() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
{t('app_passwords.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('app_passwords.description')}</p>
|
||||
<p className="text-xs text-muted-foreground">{tk('description')}</p>
|
||||
|
||||
{createdSecret && (
|
||||
<div className="p-3 bg-muted rounded-md space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{tk('copy_now_warning')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 font-mono break-all">
|
||||
{createdSecret}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleCopySecret}>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCreatedSecret(null)}>
|
||||
{t('app_passwords.done')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.name_label')}</label>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{tk('name_label')}</label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t('app_passwords.name_placeholder')}
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
placeholder={tk('name_placeholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.password_label')}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder={t('app_passwords.password_placeholder')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setNewPassword(generatePassword())}>
|
||||
{t('app_passwords.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.expires_label')}</label>
|
||||
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.allowed_ips_label')}</label>
|
||||
<textarea
|
||||
value={allowedIpsRaw}
|
||||
onChange={(e) => setAllowedIpsRaw(e.target.value)}
|
||||
placeholder={t('app_passwords.allowed_ips_placeholder')}
|
||||
rows={2}
|
||||
className="w-full text-xs font-mono px-3 py-2 rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t('app_passwords.allowed_ips_hint')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={isSaving || !newName.trim()}>
|
||||
<Button type="submit" size="sm" disabled={isSaving || !newDescription.trim()}>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
{t('app_passwords.create')}
|
||||
</Button>
|
||||
@@ -369,47 +527,48 @@ function AppPasswordsSection() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{appPasswords.length > 0 ? (
|
||||
{entries.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{appPasswords.map((name) => (
|
||||
<div key={name} className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md">
|
||||
<span className="text-sm text-foreground">{name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(name)}
|
||||
disabled={isSaving}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{entries.map((entry) => (
|
||||
<CredentialRow key={entry.id} entry={entry} onRemove={handleRemove} isSaving={isSaving} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">{t('app_passwords.none')}</p>
|
||||
<p className="text-xs text-muted-foreground italic">{tk('none')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppPasswordsSection() {
|
||||
const { appPasswords, createAppPassword, removeAppPassword } = useAccountSecurityStore();
|
||||
return (
|
||||
<CredentialSection
|
||||
icon={Smartphone}
|
||||
i18nNamespace="app_passwords"
|
||||
entries={appPasswords}
|
||||
onCreate={createAppPassword}
|
||||
onRemove={removeAppPassword}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeysSection() {
|
||||
const { apiKeys, createApiKey, removeApiKey } = useAccountSecurityStore();
|
||||
return (
|
||||
<CredentialSection
|
||||
icon={Terminal}
|
||||
i18nNamespace="api_keys"
|
||||
entries={apiKeys}
|
||||
onCreate={createApiKey}
|
||||
onRemove={removeApiKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EncryptionSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { encryptionType, updateEncryption, isSaving, isLoadingCrypto } = useAccountSecurityStore();
|
||||
|
||||
const handleToggle = async (enabled: boolean) => {
|
||||
try {
|
||||
if (enabled) {
|
||||
await updateEncryption({ type: 'pgp', algo: 'Aes256' });
|
||||
toast.success(t('encryption.enabled'));
|
||||
} else {
|
||||
await updateEncryption({ type: 'disabled' });
|
||||
toast.success(t('encryption.disabled_success'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t('encryption.error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
const { encryptionType, isLoadingCrypto } = useAccountSecurityStore();
|
||||
|
||||
if (isLoadingCrypto) {
|
||||
return (
|
||||
@@ -419,24 +578,12 @@ function EncryptionSection() {
|
||||
);
|
||||
}
|
||||
|
||||
const isEnabled = encryptionType !== 'disabled';
|
||||
|
||||
const isEnabled = encryptionType !== 'Disabled';
|
||||
return (
|
||||
<SettingItem label={t('encryption.label')} description={t('encryption.description')}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ToggleSwitch
|
||||
checked={isEnabled}
|
||||
onChange={handleToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
)}
|
||||
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
|
||||
{isEnabled ? t('encryption.active', { type: encryptionType.toUpperCase() }) : t('encryption.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
<span className={cn('text-xs font-medium', isEnabled ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground')}>
|
||||
{isEnabled ? t('encryption.active', { type: encryptionType }) : t('encryption.inactive')}
|
||||
</span>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
@@ -446,7 +593,7 @@ function EmailClientSection() {
|
||||
const { client } = useAuthStore();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const jmapUsername = client?.getUsername() || '';
|
||||
const jmapUsername = useMemo(() => client?.getUsername() || '', [client]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(jmapUsername).then(() => {
|
||||
@@ -551,6 +698,9 @@ export function AccountSecuritySettings() {
|
||||
|
||||
<AppPasswordsSection />
|
||||
|
||||
<div className="border-t border-border" />
|
||||
<ApiKeysSection />
|
||||
|
||||
{isOAuth && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTour } from '@/components/tour/tour-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlayCircle } from 'lucide-react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
||||
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
|
||||
@@ -21,7 +22,7 @@ const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: bo
|
||||
function DensityPreview({ density }: { density: Density }) {
|
||||
const cfg = DENSITY_PREVIEW[density];
|
||||
const rows = [
|
||||
{ unread: true, sender: 'Alice Johnson', subject: 'Project update — Q1 roadmap', preview: 'Here are the latest numbers from…' },
|
||||
{ unread: true, sender: 'Alice Johnson', subject: 'Project update - Q1 roadmap', preview: 'Here are the latest numbers from…' },
|
||||
{ unread: false, sender: 'Bob Smith', subject: 'Re: Meeting notes', preview: 'Thanks, will review and get back...' },
|
||||
{ unread: true, sender: 'Carol Lee', subject: 'Invoice #4092', preview: 'Please find attached the invoice…' },
|
||||
];
|
||||
@@ -67,9 +68,10 @@ export function AppearanceSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tTour = useTranslations('tour');
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, updateSetting } = useSettingsStore();
|
||||
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, updateSetting } = useSettingsStore();
|
||||
const { startTour, resetTourCompletion } = useTour();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -161,6 +163,27 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Colorful Sidebar Icons */}
|
||||
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
|
||||
<ToggleSwitch
|
||||
checked={colorfulSidebarIcons}
|
||||
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Unified Mailbox */}
|
||||
{accounts.length > 1 && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
description={t('unified_mailbox.description')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableUnifiedMailbox}
|
||||
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Animations */}
|
||||
{!isSettingHidden('animationsEnabled') && (
|
||||
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cn, formatDateTime } from '@/lib/utils';
|
||||
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
const CALENDAR_COLORS = [
|
||||
"#3b82f6", // blue
|
||||
@@ -202,7 +203,7 @@ export function CalendarManagementSettings() {
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch('/api/caldav/discover', {
|
||||
apiFetch('/api/caldav/discover', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -135,6 +135,7 @@ export function EmailSettings() {
|
||||
trustedSendersAddressBook,
|
||||
attachmentReminderEnabled,
|
||||
attachmentReminderKeywords,
|
||||
hideInlineImageAttachments,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
const { trustedSenderEmails } = useContactStore();
|
||||
@@ -401,6 +402,14 @@ export function EmailSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hide inline images from attachment list */}
|
||||
<SettingItem label={t('hide_inline_image_attachments.label')} description={t('hide_inline_image_attachments.description')}>
|
||||
<ToggleSwitch
|
||||
checked={hideInlineImageAttachments}
|
||||
onChange={(checked) => updateSetting('hideInlineImageAttachments', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Quick Hover Actions */}
|
||||
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
||||
<div className="py-3 border-b border-border space-y-3">
|
||||
@@ -575,7 +584,7 @@ export function EmailSettings() {
|
||||
</button>
|
||||
</SettingItem>
|
||||
|
||||
{/* Trusted Senders — address book storage */}
|
||||
{/* Trusted Senders - address book storage */}
|
||||
<SettingItem label={t('trusted_senders.use_address_book_label')} description={t('trusted_senders.use_address_book_description')}>
|
||||
<ToggleSwitch
|
||||
checked={trustedSendersAddressBook}
|
||||
|
||||
@@ -49,7 +49,7 @@ function getPreviewIcon(file: SampleFile, colored: boolean, size: "sm" | "lg") {
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return "—";
|
||||
if (bytes === 0) return "-";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
|
||||
@@ -23,8 +23,13 @@ import {
|
||||
Filter,
|
||||
RotateCcw,
|
||||
PalmtreeIcon,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
|
||||
function isReadonlyRule(r: FilterRule): boolean {
|
||||
return r.origin === "external" || r.origin === "opaque";
|
||||
}
|
||||
|
||||
function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||
const t = useTranslations("settings.filters");
|
||||
|
||||
@@ -429,90 +434,137 @@ export function FilterSettings() {
|
||||
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<div className="space-y-1" role="list" aria-label={t("rule_list")}>
|
||||
{rules.map((rule, index) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
role="listitem"
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
|
||||
dragOverIndex === index
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted/50"
|
||||
} ${!rule.enabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
{rules.map((rule, index) => {
|
||||
const readonly = isReadonlyRule(rule);
|
||||
|
||||
if (readonly) {
|
||||
const label = rule.originLabel || t("origin_external");
|
||||
const tooltip = t("managed_by_tooltip", { source: label });
|
||||
const hasStructuredSummary =
|
||||
rule.origin === "external" &&
|
||||
rule.conditions.length > 0 &&
|
||||
rule.actions.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={rule.id}
|
||||
role="listitem"
|
||||
className="flex items-start gap-3 p-3 rounded-md border border-border"
|
||||
title={tooltip}
|
||||
>
|
||||
<div className="pt-0.5 text-muted-foreground" aria-label={tooltip}>
|
||||
<Lock className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{rule.name}
|
||||
</p>
|
||||
<span className="inline-flex items-baseline px-1.5 py-px rounded-sm bg-muted/60 text-muted-foreground text-[10px]">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{hasStructuredSummary ? (
|
||||
expandedFilterView ? (
|
||||
<VisualRuleSummary rule={rule} />
|
||||
) : (
|
||||
<RuleSummary rule={rule} />
|
||||
)
|
||||
) : rule.rawBlock ? (
|
||||
<pre className="mt-1.5 text-xs font-mono whitespace-pre-wrap break-all text-muted-foreground bg-muted rounded p-2 max-h-32 overflow-y-auto">
|
||||
{rule.rawBlock.trim()}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
|
||||
aria-label={t("drag_to_reorder")}
|
||||
key={rule.id}
|
||||
role="listitem"
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
|
||||
dragOverIndex === index
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted/50"
|
||||
} ${!rule.enabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
|
||||
aria-label={t("drag_to_reorder")}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="pt-0.5">
|
||||
<ToggleSwitch
|
||||
checked={rule.enabled}
|
||||
onChange={() => handleToggle(rule.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-0.5">
|
||||
<ToggleSwitch
|
||||
checked={rule.enabled}
|
||||
onChange={() => handleToggle(rule.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => {
|
||||
setEditingRule(rule);
|
||||
setShowRuleModal(true);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => {
|
||||
setEditingRule(rule);
|
||||
setShowRuleModal(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{rule.name}
|
||||
</p>
|
||||
{expandedFilterView ? (
|
||||
<VisualRuleSummary rule={rule} />
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setEditingRule(rule);
|
||||
setShowRuleModal(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{rule.name}
|
||||
</p>
|
||||
{expandedFilterView ? (
|
||||
<VisualRuleSummary rule={rule} />
|
||||
) : (
|
||||
<RuleSummary rule={rule} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId === rule.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(rule.id)}
|
||||
>
|
||||
{t("confirm_delete")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<RuleSummary rule={rule} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteConfirmId(rule.id)}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
aria-label={t("delete_rule")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId === rule.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(rule.id)}
|
||||
>
|
||||
{t("confirm_delete")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteConfirmId(rule.id)}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
aria-label={t("delete_rule")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
@@ -490,7 +490,7 @@ export function FolderSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Folder List — primary section */}
|
||||
{/* Folder List - primary section */}
|
||||
<SettingsSection title={t('folder_list')} description={t('folder_list_description')}>
|
||||
<div className="space-y-0.5">
|
||||
{folderTree.length === 0 ? (
|
||||
@@ -516,7 +516,7 @@ export function FolderSettings() {
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{/* Standard Folder Roles — advanced section */}
|
||||
{/* Standard Folder Roles - advanced section */}
|
||||
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
|
||||
{STANDARD_ROLES.map((role) => {
|
||||
// Disambiguate duplicate folder names by appending parent path
|
||||
|
||||
@@ -40,7 +40,7 @@ export function SmimeCertificateModal({
|
||||
{ label: t("cert_email"), value: record.email },
|
||||
{
|
||||
label: t("cert_validity"),
|
||||
value: `${new Date(record.notBefore).toLocaleDateString()} — ${new Date(record.notAfter).toLocaleDateString()}`,
|
||||
value: `${new Date(record.notBefore).toLocaleDateString()} - ${new Date(record.notAfter).toLocaleDateString()}`,
|
||||
},
|
||||
{ label: t("cert_fingerprint"), value: record.fingerprint },
|
||||
];
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Shield, Mail, X, AlertTriangle, Trophy, RotateCcw, Inbox, MailCheck } from "lucide-react";
|
||||
import { Shield, Mail, X, AlertTriangle, MailCheck, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const GAME_WIDTH = 400;
|
||||
const GAME_HEIGHT = 520;
|
||||
const FORTRESS_Y = GAME_HEIGHT - 48;
|
||||
const SPAWN_INTERVAL_START = 850;
|
||||
const SPAWN_INTERVAL_MIN = 320;
|
||||
const INBOX_Y = GAME_HEIGHT - 40;
|
||||
const SPAWN_INTERVAL_START = 900;
|
||||
const SPAWN_INTERVAL_MIN = 340;
|
||||
const GAME_DURATION = 30;
|
||||
const ENEMY_SPEED_START = 1.2;
|
||||
const ENEMY_SPEED_INCREASE = 0.04;
|
||||
const MAX_MISSES = 3;
|
||||
|
||||
interface Enemy {
|
||||
id: number;
|
||||
@@ -21,81 +23,85 @@ interface Enemy {
|
||||
type: "spam" | "phishing" | "legit";
|
||||
}
|
||||
|
||||
type GameState = "idle" | "playing" | "won" | "lost";
|
||||
type GameState = "idle" | "playing" | "over";
|
||||
|
||||
export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
|
||||
const [gameState, setGameState] = useState<GameState>("idle");
|
||||
const [enemies, setEnemies] = useState<Enemy[]>([]);
|
||||
const [score, setScore] = useState(0);
|
||||
const [timeLeft, setTimeLeft] = useState(GAME_DURATION);
|
||||
const [shieldHealth, setShieldHealth] = useState(3);
|
||||
const [hitEffects, setHitEffects] = useState<{ id: number; x: number; y: number; color: string }[]>([]);
|
||||
const [destroyEffects, setDestroyEffects] = useState<{ id: number; x: number; y: number }[]>([]);
|
||||
const [deliverEffects, setDeliverEffects] = useState<{ id: number; x: number; y: number }[]>([]);
|
||||
const [misses, setMisses] = useState(0);
|
||||
const [survived, setSurvived] = useState(false);
|
||||
const nextId = useRef(0);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const lastTimeRef = useRef<number>(0);
|
||||
const spawnTimerRef = useRef<number>(0);
|
||||
const gameStateRef = useRef<GameState>("idle");
|
||||
const elapsedRef = useRef(0);
|
||||
const destroyedRef = useRef(new Set<number>());
|
||||
const clickedRef = useRef(new Set<number>());
|
||||
const enemiesRef = useRef<Enemy[]>([]);
|
||||
const missesRef = useRef(0);
|
||||
const scoreRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
gameStateRef.current = gameState;
|
||||
}, [gameState]);
|
||||
|
||||
const endGame = useCallback((didSurvive: boolean) => {
|
||||
setSurvived(didSurvive);
|
||||
setGameState("over");
|
||||
}, []);
|
||||
|
||||
const startGame = useCallback(() => {
|
||||
setGameState("playing");
|
||||
setEnemies([]);
|
||||
setScore(0);
|
||||
setTimeLeft(GAME_DURATION);
|
||||
setShieldHealth(3);
|
||||
setHitEffects([]);
|
||||
setDestroyEffects([]);
|
||||
setDeliverEffects([]);
|
||||
setMisses(0);
|
||||
setSurvived(false);
|
||||
nextId.current = 0;
|
||||
spawnTimerRef.current = 0;
|
||||
elapsedRef.current = 0;
|
||||
destroyedRef.current = new Set();
|
||||
clickedRef.current = new Set();
|
||||
enemiesRef.current = [];
|
||||
missesRef.current = 0;
|
||||
scoreRef.current = 0;
|
||||
lastTimeRef.current = performance.now();
|
||||
}, []);
|
||||
|
||||
const spawnEnemy = useCallback(() => {
|
||||
const id = nextId.current++;
|
||||
const rand = Math.random();
|
||||
const type = rand > 0.7 ? "legit" : rand > 0.45 ? "phishing" : "spam";
|
||||
const type = rand > 0.75 ? "legit" : rand > 0.45 ? "phishing" : "spam";
|
||||
const x = 20 + Math.random() * (GAME_WIDTH - 60);
|
||||
const elapsed = elapsedRef.current;
|
||||
const speed = ENEMY_SPEED_START + (elapsed / 1000) * ENEMY_SPEED_INCREASE;
|
||||
setEnemies((prev) => [...prev, { id, x, y: -30, speed, type }]);
|
||||
const speed = ENEMY_SPEED_START + (elapsedRef.current / 1000) * ENEMY_SPEED_INCREASE;
|
||||
enemiesRef.current = [...enemiesRef.current, { id, x, y: -32, speed, type }];
|
||||
setEnemies(enemiesRef.current);
|
||||
}, []);
|
||||
|
||||
const handleHover = useCallback((enemy: Enemy) => {
|
||||
if (destroyedRef.current.has(enemy.id)) return;
|
||||
destroyedRef.current.add(enemy.id);
|
||||
const handleClick = useCallback(
|
||||
(ev: React.MouseEvent, enemy: Enemy) => {
|
||||
ev.stopPropagation();
|
||||
if (clickedRef.current.has(enemy.id)) return;
|
||||
clickedRef.current.add(enemy.id);
|
||||
|
||||
if (enemy.type === "legit") {
|
||||
// Penalty for blocking legit mail
|
||||
setShieldHealth((prev) => {
|
||||
const nh = prev - 1;
|
||||
if (nh <= 0) setGameState("lost");
|
||||
return Math.max(0, nh);
|
||||
});
|
||||
setScore((prev) => Math.max(0, prev - 15));
|
||||
const effectId = nextId.current++;
|
||||
setHitEffects((p) => [...p, { id: effectId, x: enemy.x, y: enemy.y, color: "rgba(34, 197, 94, 0.5)" }]);
|
||||
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
|
||||
} else {
|
||||
setScore((prev) => prev + 10);
|
||||
const effectId = nextId.current++;
|
||||
setDestroyEffects((prev) => [...prev, { id: effectId, x: enemy.x, y: enemy.y }]);
|
||||
setTimeout(() => setDestroyEffects((prev) => prev.filter((e) => e.id !== effectId)), 400);
|
||||
}
|
||||
enemiesRef.current = enemiesRef.current.filter((e) => e.id !== enemy.id);
|
||||
setEnemies(enemiesRef.current);
|
||||
|
||||
setEnemies((prev) => prev.filter((e) => e.id !== enemy.id));
|
||||
}, []);
|
||||
if (enemy.type === "legit") {
|
||||
missesRef.current += 1;
|
||||
setMisses(missesRef.current);
|
||||
scoreRef.current = Math.max(0, scoreRef.current - 15);
|
||||
setScore(scoreRef.current);
|
||||
if (missesRef.current >= MAX_MISSES) endGame(false);
|
||||
} else {
|
||||
scoreRef.current += enemy.type === "phishing" ? 15 : 10;
|
||||
setScore(scoreRef.current);
|
||||
}
|
||||
},
|
||||
[endGame]
|
||||
);
|
||||
|
||||
// Game loop
|
||||
useEffect(() => {
|
||||
if (gameState !== "playing") return;
|
||||
|
||||
@@ -106,15 +112,13 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
|
||||
lastTimeRef.current = now;
|
||||
elapsedRef.current += dt;
|
||||
|
||||
// Timer
|
||||
const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000);
|
||||
setTimeLeft(Math.max(0, newTimeLeft));
|
||||
if (newTimeLeft <= 0) {
|
||||
setGameState("won");
|
||||
endGame(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn
|
||||
spawnTimerRef.current += dt;
|
||||
const spawnInterval = Math.max(
|
||||
SPAWN_INTERVAL_MIN,
|
||||
@@ -125,261 +129,187 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) {
|
||||
spawnEnemy();
|
||||
}
|
||||
|
||||
// Move enemies
|
||||
setEnemies((prev) => {
|
||||
const next: Enemy[] = [];
|
||||
let spamBreached = false;
|
||||
for (const e of prev) {
|
||||
const ny = e.y + e.speed * (dt / 16);
|
||||
if (ny >= FORTRESS_Y) {
|
||||
if (e.type === "legit") {
|
||||
// Legit mail delivered — bonus
|
||||
setScore((s) => s + 5);
|
||||
const effectId = nextId.current++;
|
||||
setDeliverEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y }]);
|
||||
setTimeout(() => setDeliverEffects((p) => p.filter((d) => d.id !== effectId)), 500);
|
||||
} else {
|
||||
spamBreached = true;
|
||||
const effectId = nextId.current++;
|
||||
setHitEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y, color: "rgba(219, 45, 84, 0.3)" }]);
|
||||
setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500);
|
||||
}
|
||||
} else {
|
||||
next.push({ ...e, y: ny });
|
||||
}
|
||||
const nextEnemies: Enemy[] = [];
|
||||
let missed = 0;
|
||||
let scoreDelta = 0;
|
||||
for (const e of enemiesRef.current) {
|
||||
const ny = e.y + e.speed * (dt / 16);
|
||||
if (ny >= INBOX_Y) {
|
||||
if (e.type === "legit") scoreDelta += 5;
|
||||
else missed++;
|
||||
} else {
|
||||
nextEnemies.push({ ...e, y: ny });
|
||||
}
|
||||
if (spamBreached) {
|
||||
setShieldHealth((prev) => {
|
||||
const nh = prev - 1;
|
||||
if (nh <= 0) setGameState("lost");
|
||||
return Math.max(0, nh);
|
||||
});
|
||||
}
|
||||
enemiesRef.current = nextEnemies;
|
||||
setEnemies(nextEnemies);
|
||||
|
||||
if (scoreDelta > 0) {
|
||||
scoreRef.current += scoreDelta;
|
||||
setScore(scoreRef.current);
|
||||
}
|
||||
if (missed > 0) {
|
||||
missesRef.current += missed;
|
||||
setMisses(missesRef.current);
|
||||
if (missesRef.current >= MAX_MISSES) {
|
||||
endGame(false);
|
||||
return;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(animFrameRef.current);
|
||||
}, [gameState, spawnEnemy]);
|
||||
|
||||
const getEnemyStyle = (type: Enemy["type"]) => {
|
||||
switch (type) {
|
||||
case "phishing":
|
||||
return { bg: "rgba(234, 179, 8, 0.15)", border: "rgba(234, 179, 8, 0.4)", color: "rgb(234, 179, 8)" };
|
||||
case "legit":
|
||||
return { bg: "rgba(34, 197, 94, 0.12)", border: "rgba(34, 197, 94, 0.4)", color: "rgb(34, 197, 94)" };
|
||||
default:
|
||||
return { bg: "rgba(219, 45, 84, 0.1)", border: "rgba(219, 45, 84, 0.3)", color: "rgb(219, 45, 84)" };
|
||||
}
|
||||
};
|
||||
}, [gameState, spawnEnemy, endGame]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="relative rounded-xl border border-border bg-card shadow-2xl overflow-hidden select-none"
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative rounded-lg border border-border bg-card shadow-xl overflow-hidden select-none"
|
||||
style={{ width: GAME_WIDTH, maxWidth: "95vw" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" style={{ color: "rgb(219, 45, 84)" }} />
|
||||
<span className="text-sm font-semibold text-foreground">Spam Siege</span>
|
||||
<Shield className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-medium text-foreground">Spam Siege</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* HUD */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground">Score: <span className="font-semibold text-foreground">{score}</span></span>
|
||||
<span className="text-muted-foreground">Time: <span className="font-semibold text-foreground">{timeLeft}s</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Shield
|
||||
key={i}
|
||||
className="w-3.5 h-3.5 transition-colors"
|
||||
style={{ color: i < shieldHealth ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
|
||||
fill={i < shieldHealth ? "rgb(219, 45, 84)" : "none"}
|
||||
strokeWidth={i < shieldHealth ? 0 : 1.5}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/40 border-b border-border text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>
|
||||
Score <span className="font-medium text-foreground tabular-nums">{score}</span>
|
||||
</span>
|
||||
<span>
|
||||
Time <span className="font-medium text-foreground tabular-nums">{timeLeft}s</span>
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
Misses{" "}
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium tabular-nums",
|
||||
misses >= MAX_MISSES - 1 ? "text-destructive" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{misses}/{MAX_MISSES}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Game area */}
|
||||
<div
|
||||
className="relative bg-background overflow-hidden"
|
||||
style={{ height: GAME_HEIGHT }}
|
||||
>
|
||||
{/* Grid lines for depth */}
|
||||
<div className="absolute inset-0 opacity-[0.03]" style={{
|
||||
backgroundImage: "linear-gradient(to bottom, currentColor 1px, transparent 1px), linear-gradient(to right, currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
}} />
|
||||
|
||||
{/* Fortress wall */}
|
||||
<div className="absolute left-0 right-0 bottom-0 flex flex-col items-center" style={{ height: GAME_HEIGHT - FORTRESS_Y }}>
|
||||
<div className="relative w-full">
|
||||
{/* Shield centered above the line */}
|
||||
<div className="absolute -top-5 left-1/2 -translate-x-1/2 z-10">
|
||||
<Shield
|
||||
className="w-7 h-7 drop-shadow-sm"
|
||||
style={{ color: shieldHealth > 0 ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
|
||||
fill={shieldHealth > 0 ? "rgba(219, 45, 84, 0.2)" : "none"}
|
||||
/>
|
||||
</div>
|
||||
{/* Solid line */}
|
||||
<div
|
||||
className="h-[2px] w-full"
|
||||
style={{ backgroundColor: shieldHealth > 0 ? "rgba(219, 45, 84, 0.35)" : "rgba(100, 100, 100, 0.3)" }}
|
||||
/>
|
||||
</div>
|
||||
{/* Subtle gradient fill below */}
|
||||
<div
|
||||
className="flex-1 w-full"
|
||||
style={{
|
||||
background: shieldHealth > 0
|
||||
? "linear-gradient(to bottom, rgba(219, 45, 84, 0.06), transparent)"
|
||||
: "linear-gradient(to bottom, rgba(100, 100, 100, 0.04), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-center gap-2 px-4"
|
||||
style={{ top: INBOX_Y }}
|
||||
>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Inbox
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Enemies */}
|
||||
{enemies.map((e) => {
|
||||
const style = getEnemyStyle(e.type);
|
||||
const variant =
|
||||
e.type === "phishing"
|
||||
? "text-warning border-warning/40 bg-warning/10 hover:bg-warning/20"
|
||||
: e.type === "legit"
|
||||
? "text-success border-success/40 bg-success/10 hover:bg-success/20"
|
||||
: "text-destructive border-destructive/40 bg-destructive/10 hover:bg-destructive/20";
|
||||
const Icon =
|
||||
e.type === "phishing" ? AlertTriangle : e.type === "legit" ? MailCheck : Mail;
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={e.id}
|
||||
className="absolute flex items-center justify-center w-8 h-8 rounded-md transition-transform"
|
||||
style={{
|
||||
left: e.x,
|
||||
top: e.y,
|
||||
backgroundColor: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
}}
|
||||
onMouseEnter={() => handleHover(e)}
|
||||
>
|
||||
{e.type === "phishing" ? (
|
||||
<AlertTriangle className="w-4 h-4" style={{ color: style.color }} />
|
||||
) : e.type === "legit" ? (
|
||||
<MailCheck className="w-4 h-4" style={{ color: style.color }} />
|
||||
) : (
|
||||
<Mail className="w-4 h-4" style={{ color: style.color }} />
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute flex items-center justify-center w-8 h-8 rounded-md border cursor-pointer",
|
||||
"active:scale-95 transition-transform",
|
||||
variant
|
||||
)}
|
||||
</div>
|
||||
style={{ left: e.x, top: e.y }}
|
||||
onMouseEnter={(ev) => handleClick(ev, e)}
|
||||
onClick={(ev) => handleClick(ev, e)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Destroy effects */}
|
||||
{destroyEffects.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="absolute pointer-events-none animate-ping"
|
||||
style={{ left: e.x + 4, top: e.y + 4 }}
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-foreground/50" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Deliver effects (legit mail arrived) */}
|
||||
{deliverEffects.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="absolute pointer-events-none animate-ping"
|
||||
style={{ left: e.x + 4, top: e.y - 8 }}
|
||||
>
|
||||
<Inbox className="w-5 h-5" style={{ color: "rgb(34, 197, 94)" }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Hit effects on fortress */}
|
||||
{hitEffects.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="absolute pointer-events-none"
|
||||
style={{ left: e.x, top: e.y - 10 }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full animate-ping" style={{ backgroundColor: e.color }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Idle overlay */}
|
||||
{gameState === "idle" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
|
||||
<Shield className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} fill="rgba(219, 45, 84, 0.1)" />
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-foreground">Spam Siege</p>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 max-w-[280px] leading-relaxed">
|
||||
Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds.
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
|
||||
<Shield className="w-10 h-10 text-primary" />
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-base font-medium text-foreground">Spam Siege</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Click spam and phishing before they hit your inbox. Don't block legitimate
|
||||
mail. Three misses and it's over.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4 mt-3 text-[11px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" style={{ color: "rgb(219, 45, 84)" }} /> Spam
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" style={{ color: "rgb(234, 179, 8)" }} /> Phishing
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MailCheck className="w-3 h-3" style={{ color: "rgb(34, 197, 94)" }} /> Legit
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={startGame} className="mt-1 text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
|
||||
<Shield className="w-3.5 h-3.5 mr-1.5" />
|
||||
Defend
|
||||
<div className="flex items-center gap-4 text-[11px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Mail className="w-3 h-3 text-destructive" />
|
||||
Spam
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||
Phishing
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MailCheck className="w-3 h-3 text-success" />
|
||||
Legit
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" onClick={startGame}>
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Won overlay */}
|
||||
{gameState === "won" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
|
||||
<Trophy className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} />
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-foreground">Fortress Secured</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Score: <span className="font-semibold text-foreground">{score}</span>
|
||||
{gameState === "over" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
|
||||
<Shield
|
||||
className={cn(
|
||||
"w-10 h-10",
|
||||
survived ? "text-success" : "text-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{survived ? "Inbox held" : "Inbox overrun"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Final score{" "}
|
||||
<span className="font-medium text-foreground tabular-nums">{score}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
|
||||
<Button size="sm" onClick={startGame}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lost overlay */}
|
||||
{gameState === "lost" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
|
||||
<Shield className="w-14 h-14 text-muted-foreground/40" />
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-foreground">Fortress Breached</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Score: <span className="font-semibold text-foreground">{score}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user