Compare commits
209
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fcd37650d | ||
|
|
13ec05da83 | ||
|
|
e7acf56753 | ||
|
|
83e29b3ef1 | ||
|
|
67f61f18d0 | ||
|
|
f34537adcf | ||
|
|
931d1fa06a | ||
|
|
47b9ab4398 | ||
|
|
4653de6d30 | ||
|
|
671857722d | ||
|
|
62b0455388 | ||
|
|
372722a903 | ||
|
|
35ed6a2858 | ||
|
|
bd778adf12 | ||
|
|
1a19e96bcb | ||
|
|
6dc6ad0936 | ||
|
|
87336981d3 | ||
|
|
d8bebb531f | ||
|
|
57c06e38a4 | ||
|
|
f6fc34fab3 | ||
|
|
98dcd3b1e9 | ||
|
|
98f8487784 | ||
|
|
1f199fdc1d | ||
|
|
8ddb9c6dbd | ||
|
|
81c720d630 | ||
|
|
3bf36de0c8 | ||
|
|
b648c1c267 | ||
|
|
a4b330bace | ||
|
|
971afbecc1 | ||
|
|
2ac022df50 | ||
|
|
7b2047681e | ||
|
|
2aae8ed842 | ||
|
|
2a8778c905 | ||
|
|
2dc224e882 | ||
|
|
3d5c05ffb9 | ||
|
|
f46614a483 | ||
|
|
30d6c23908 | ||
|
|
bde63c1322 | ||
|
|
dfa5667548 | ||
|
|
64f031201e | ||
|
|
77a9060c00 | ||
|
|
7082566f51 | ||
|
|
cd02a9f806 | ||
|
|
59048e5ad3 | ||
|
|
9377684fb1 | ||
|
|
26e1f31945 | ||
|
|
611ae0624e | ||
|
|
68b0826587 | ||
|
|
eda3302298 | ||
|
|
295170a842 | ||
|
|
30e5059b94 | ||
|
|
6fd17c2ade | ||
|
|
61651b1ed1 | ||
|
|
daa40ec72d | ||
|
|
c2c07293b7 | ||
|
|
91b282d746 | ||
|
|
dda7adf565 | ||
|
|
bde8455df5 | ||
|
|
bde9f14832 | ||
|
|
3f99a2fc9e | ||
|
|
147660aef3 | ||
|
|
5d7ae230ce | ||
|
|
2a35019b21 | ||
|
|
4ba2d34555 | ||
|
|
cfe8ca96e1 | ||
|
|
e1a12b2c23 | ||
|
|
e44f2ac97e | ||
|
|
9b5870ca69 | ||
|
|
2e8bb9983a | ||
|
|
d0a1cee6fd | ||
|
|
19663610d7 | ||
|
|
36167eaa84 | ||
|
|
c71175e596 | ||
|
|
68d08dbae6 | ||
|
|
7cce5c0393 | ||
|
|
6b6ff72c38 | ||
|
|
57a5c692be | ||
|
|
84290a67be | ||
|
|
ada2b3a7a1 | ||
|
|
3338ceb5eb | ||
|
|
15b189357e | ||
|
|
ab79288be8 | ||
|
|
e6e1612435 | ||
|
|
f01f50922e | ||
|
|
48b18a853f | ||
|
|
665a392ce0 | ||
|
|
505e65f319 | ||
|
|
177b2aca57 | ||
|
|
3512f935d1 | ||
|
|
12908ab706 | ||
|
|
a10ee48ef3 | ||
|
|
31b4ea2ecd | ||
|
|
0271df4338 | ||
|
|
7e9aefcfa1 | ||
|
|
b966d285a9 | ||
|
|
16466c7296 | ||
|
|
2ff4b7847e | ||
|
|
46fc221f9e | ||
|
|
b15098a6eb | ||
|
|
0f15132ec0 | ||
|
|
3f3f3a36b1 | ||
|
|
5d77a5d7ef | ||
|
|
75876725df | ||
|
|
2416f1863b | ||
|
|
b6fdfe72ca | ||
|
|
568b7137ea | ||
|
|
3afa7ce012 | ||
|
|
0bb098438a | ||
|
|
759ab7fe8c | ||
|
|
fb40e74713 | ||
|
|
cab43b8d06 | ||
|
|
4d817ea932 | ||
|
|
b8f668d25a | ||
|
|
9254a7fa20 | ||
|
|
fe77e9f52b | ||
|
|
4ff15fffaa | ||
|
|
218a584fb3 | ||
|
|
a9af816012 | ||
|
|
90c1176f93 | ||
|
|
5c1f14fa8b | ||
|
|
a4155aa342 | ||
|
|
d047891ded | ||
|
|
bc5d2a57e8 | ||
|
|
f7e487171c | ||
|
|
e9746fcf78 | ||
|
|
d91db37b34 | ||
|
|
ae19ad888b | ||
|
|
f0e63de09b | ||
|
|
88670d4bcf | ||
|
|
1bc5a6a0ce | ||
|
|
be7bfd1f02 | ||
|
|
22e5e97da9 | ||
|
|
476c76c420 | ||
|
|
a3d551b640 | ||
|
|
83a9c5a809 | ||
|
|
fb4e8f3591 | ||
|
|
0189935e7b | ||
|
|
eace443fdf | ||
|
|
e94a1429d5 | ||
|
|
15982c2468 | ||
|
|
d15c58f8da | ||
|
|
6698ec8456 | ||
|
|
e738941950 | ||
|
|
1890cade08 | ||
|
|
fc7fec44a8 | ||
|
|
1652a0ec62 | ||
|
|
e659fe3d38 | ||
|
|
348e032dce | ||
|
|
a6c15b8ad6 | ||
|
|
5a2bc6b671 | ||
|
|
59bc7fd64c | ||
|
|
aa7a814b86 | ||
|
|
c5c6509867 | ||
|
|
1cdbf75270 | ||
|
|
ea7892b497 | ||
|
|
d52dfebad4 | ||
|
|
7bb58f4f9f | ||
|
|
f1e1ed1df7 | ||
|
|
d9d9f91a86 | ||
|
|
108406a885 | ||
|
|
013ef7d557 | ||
|
|
56d11b5759 | ||
|
|
0c1e238223 | ||
|
|
ca0ba818b7 | ||
|
|
ce97a54aaa | ||
|
|
b605b6d49d | ||
|
|
cea3ec0fe6 | ||
|
|
f56ca594dc | ||
|
|
66ff523553 | ||
|
|
7969fd09eb | ||
|
|
3108b2f336 | ||
|
|
71565e9328 | ||
|
|
edd11ac27b | ||
|
|
f81b02ead9 | ||
|
|
25e542867b | ||
|
|
0a1114f710 | ||
|
|
3bfa73f375 | ||
|
|
fc50e5b569 | ||
|
|
a34314cef5 | ||
|
|
0563e88eb8 | ||
|
|
7a483b3dd4 | ||
|
|
351f2d4e26 | ||
|
|
f3b6819463 | ||
|
|
3ea22161d9 | ||
|
|
246df49c03 | ||
|
|
9c04950a94 | ||
|
|
934967b9df | ||
|
|
755201c92a | ||
|
|
9b69d89dfd | ||
|
|
17b69e68f3 | ||
|
|
83cd675ccf | ||
|
|
ddcab88e56 | ||
|
|
f188e29152 | ||
|
|
b48b6e0871 | ||
|
|
6248bb9825 | ||
|
|
15ad783848 | ||
|
|
fc116a8b2f | ||
|
|
a16478ffad | ||
|
|
144d6503cc | ||
|
|
9c6292b4b7 | ||
|
|
457063a48b | ||
|
|
21ea5009f4 | ||
|
|
74f9335e36 | ||
|
|
010f082c73 | ||
|
|
00dc509e60 | ||
|
|
af2b00dd35 | ||
|
|
e442c55931 | ||
|
|
2245ad2024 | ||
|
|
7bf62e4bdc |
@@ -6,6 +6,10 @@ node_modules
|
||||
!.env.example
|
||||
!.env.dev.example
|
||||
scripts/
|
||||
# ...except the first-party plugin builder, which the image build runs
|
||||
# (see Dockerfile). Without this the whole scripts/ dir is absent from the
|
||||
# build context and the RUN step fails with "Cannot find module".
|
||||
!scripts/build-plugins.mjs
|
||||
TODO.md
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
+20
-4
@@ -15,9 +15,15 @@
|
||||
DEV_MOCK_JMAP=true
|
||||
|
||||
# Point the app at its own mock endpoint.
|
||||
# IMPORTANT: This must match the origin the app runs on (default: port 3000).
|
||||
# Using a different port (e.g. 3001) will cause CORS errors.
|
||||
JMAP_SERVER_URL=/api/dev-jmap
|
||||
# IMPORTANT: must be an ABSOLUTE URL matching the origin the app runs on
|
||||
# (default: port 3000) - NOT a relative path. A relative path here makes
|
||||
# /api/auth/stalwart-context 400 on every request (resolveTrustedJmapUrl
|
||||
# rejects it), which silently breaks the real server-side session-cookie
|
||||
# flow that S/MIME enrollment, offline sync, and the AI server/retrieval
|
||||
# routes all depend on. The client-side mock fetch works either way, which
|
||||
# is why this is easy to miss - it only bites features needing a real
|
||||
# server-side session identity.
|
||||
JMAP_SERVER_URL=http://localhost:3000/api/dev-jmap
|
||||
|
||||
# =============================================================================
|
||||
# App
|
||||
@@ -29,7 +35,7 @@ APP_NAME=Bulwark Webmail (Dev)
|
||||
# Session & Settings Sync (optional for dev)
|
||||
# =============================================================================
|
||||
|
||||
SESSION_SECRET=dev-secret-not-for-production
|
||||
SESSION_SECRET=dev-secret-not-for-production-32chars
|
||||
SETTINGS_SYNC_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
@@ -39,6 +45,16 @@ SETTINGS_SYNC_ENABLED=true
|
||||
LOG_FORMAT=text
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# =============================================================================
|
||||
# Plugin Development
|
||||
# =============================================================================
|
||||
|
||||
# Load plugins from a directory on disk instead of installing them as ZIPs.
|
||||
# Each immediate subfolder is one plugin and needs a manifest.json. When the
|
||||
# manifest's entrypoint exists under src/, it's bundled on demand with esbuild,
|
||||
# so you can edit sources and just refresh the browser.
|
||||
# PLUGIN_DEV_DIR=../my-plugins
|
||||
|
||||
# =============================================================================
|
||||
# Login Page Customization (optional)
|
||||
# =============================================================================
|
||||
|
||||
+162
-1
@@ -19,6 +19,16 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Access-Control-Allow-Origin header, or browser requests will be blocked.
|
||||
# ALLOW_CUSTOM_JMAP_ENDPOINT=true
|
||||
|
||||
# Offer several JMAP servers on the login form. JSON array; each entry needs
|
||||
# id, label, and url. "domains" and a per-server "oauth" block are optional.
|
||||
# Prefer configuring this from the admin dashboard - the env form exists for
|
||||
# stateless deployments.
|
||||
# JMAP_SERVERS=[{"id":"eu","label":"Europe","url":"https://eu.example.com","domains":["example.com"]},{"id":"us","label":"US","url":"https://us.example.com","oauth":{"clientId":"webmail-us"}}]
|
||||
|
||||
# Pick the server automatically from the domain of the address the user types,
|
||||
# matching against each entry's "domains" list. Default: false.
|
||||
# JMAP_SERVER_AUTO_PICK_BY_DOMAIN=true
|
||||
|
||||
# =============================================================================
|
||||
# Stalwart Mail Server Integration
|
||||
# =============================================================================
|
||||
@@ -59,6 +69,19 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# OAuth issuer's public hostname resolves to an internal IP from this server.
|
||||
# OAUTH_ALLOW_PRIVATE_ENDPOINTS=true
|
||||
|
||||
# Replace the scopes requested at authorization. Space-separated. Leave unset
|
||||
# to use the defaults the client already asks for.
|
||||
# OAUTH_SCOPES=openid email profile offline_access
|
||||
|
||||
# Append scopes instead of replacing them. Use this when your IdP needs one
|
||||
# extra scope and you don't want to restate the defaults.
|
||||
# OAUTH_EXTRA_SCOPES=groups
|
||||
|
||||
# Send the user straight to the identity provider, skipping the login form.
|
||||
# Intended for embedded deployments where the parent app already authenticated
|
||||
# them. Default: false.
|
||||
# AUTO_SSO_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# Session & Security
|
||||
# =============================================================================
|
||||
@@ -132,6 +155,17 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# so the instance id and consent choice survive upgrades.
|
||||
# TELEMETRY_DATA_DIR=./data/telemetry
|
||||
|
||||
# Legacy kill switch, honoured only when BULWARK_TELEMETRY is unset.
|
||||
# BULWARK_TELEMETRY_DISABLED=1
|
||||
|
||||
# Let heartbeats reach a private/loopback address. Off by default as an SSRF
|
||||
# guard; only useful when running a collector locally during development.
|
||||
# BULWARK_TELEMETRY_ALLOW_PRIVATE=1
|
||||
|
||||
# Report a fixed Stalwart version instead of probing the JMAP server's Server
|
||||
# header. Useful when a proxy strips that header.
|
||||
# STALWART_VERSION=0.16.0
|
||||
|
||||
# =============================================================================
|
||||
# Server Listen Address
|
||||
# =============================================================================
|
||||
@@ -197,6 +231,12 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# Should match your app's main background color. Default: #ffffff
|
||||
# PWA_BACKGROUND_COLOR=#ffffff
|
||||
|
||||
# Screenshots shown in the browser's install prompt. Absolute URLs or paths
|
||||
# relative to public/. Both are optional; per-domain overrides are available
|
||||
# through DOMAIN_BRANDING.
|
||||
# PWA_SCREENSHOT_MOBILE_URL=/branding/screenshot-mobile.png
|
||||
# PWA_SCREENSHOT_DESKTOP_URL=/branding/screenshot-desktop.png
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logos
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -234,6 +274,23 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
|
||||
# URL for the company website link on the login page.
|
||||
LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
|
||||
# Cap the login logo's rendered size. Any CSS length ("120px", "8rem").
|
||||
# Unset means the logo renders at its natural size.
|
||||
# LOGIN_LOGO_MAX_HEIGHT=96px
|
||||
# LOGIN_LOGO_MAX_WIDTH=320px
|
||||
|
||||
# Hide parts of the login page. All default to true.
|
||||
# Turn the heading and subtitle off when the logo already reads as the brand.
|
||||
# LOGIN_SHOW_HEADING=false
|
||||
# LOGIN_SHOW_SUBTITLE=false
|
||||
#
|
||||
# Hide the optional TOTP field. A server that requires TOTP (totp_required)
|
||||
# still shows it regardless of this setting.
|
||||
# LOGIN_SHOW_TOTP=false
|
||||
#
|
||||
# Hide the version number, so it isn't disclosed to unauthenticated visitors.
|
||||
# LOGIN_SHOW_VERSION=false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-domain branding overrides (optional)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -266,6 +323,108 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
# your own directory (e.g. http://localhost:3001 for local development).
|
||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
|
||||
# =============================================================================
|
||||
# Admin Dashboard Access
|
||||
# =============================================================================
|
||||
|
||||
# Bootstrap password for the admin dashboard. Read only when admin.json does
|
||||
# not already exist; the app hashes it, writes admin.json, and logs a warning
|
||||
# telling you to remove this variable. Without it (and without the setup
|
||||
# wizard) the admin dashboard stays disabled.
|
||||
# Accepts a plaintext password or an existing hash.
|
||||
# ADMIN_PASSWORD=change-me
|
||||
|
||||
# Admin session lifetime in seconds. Default: 3600 (1 hour).
|
||||
# ADMIN_SESSION_TTL=3600
|
||||
|
||||
# How many trusted reverse proxies sit in front of the app. The client IP is
|
||||
# taken that many entries from the right of X-Forwarded-For, so an attacker
|
||||
# can't spoof it by prepending values. Default: 1.
|
||||
# TRUSTED_PROXY_DEPTH=2
|
||||
|
||||
# Allow search engines to index the app (robots.txt / noindex). Default: false.
|
||||
# SEARCH_ENGINE_INDEXING=true
|
||||
|
||||
# =============================================================================
|
||||
# Cookies, Embedding & Reverse Proxies
|
||||
# =============================================================================
|
||||
|
||||
# SameSite attribute for session cookies: lax (default), strict, or none.
|
||||
# Embedding the app cross-origin in an iframe requires "none".
|
||||
# COOKIE_SAME_SITE=none
|
||||
|
||||
# Force the Secure flag on cookies. Defaults to on when NODE_ENV=production or
|
||||
# COOKIE_SAME_SITE=none. Set to false only for local HTTP development.
|
||||
# COOKIE_SECURE=false
|
||||
|
||||
# Who may frame the app, as a CSP frame-ancestors value. Defaults to 'none',
|
||||
# which blocks all framing. Space-separate multiple origins.
|
||||
# ALLOWED_FRAME_ANCESTORS=https://portal.example.com
|
||||
|
||||
# Origin of the parent page when embedded, used for postMessage handshakes.
|
||||
# NEXT_PUBLIC_PARENT_ORIGIN=https://portal.example.com
|
||||
|
||||
# =============================================================================
|
||||
# Update Check
|
||||
# =============================================================================
|
||||
|
||||
# The app periodically checks for new releases and shows a notice. Set to
|
||||
# "off" (or false/0/no) to disable the check entirely.
|
||||
# BULWARK_UPDATE_CHECK=off
|
||||
|
||||
# Override the endpoint it checks. Takes priority over the on-disk state file.
|
||||
# An explicit empty value also disables the check.
|
||||
# BULWARK_UPDATE_CHECK_URL=https://updates.example.com/bulwark.json
|
||||
|
||||
# Where the check stores its state. Default: ./data/version-check
|
||||
# VERSION_CHECK_DATA_DIR=./data/version-check
|
||||
|
||||
# =============================================================================
|
||||
# Translation Proxy (optional)
|
||||
# =============================================================================
|
||||
|
||||
# /api/translate defaults to the public MyMemory API, which needs no setup.
|
||||
# Point it at a LibreTranslate instance instead to keep message text on
|
||||
# infrastructure you control. LibreTranslate also auto-detects the source
|
||||
# language natively.
|
||||
# LIBRETRANSLATE_URL=https://libretranslate.example.com
|
||||
# LIBRETRANSLATE_API_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# Web Push
|
||||
# =============================================================================
|
||||
|
||||
# Push notifications go through a hosted relay so self-hosters don't need
|
||||
# their own VAPID keys and Firebase project. Point this at your own relay to
|
||||
# avoid the default. Build-time variable.
|
||||
# Default: https://notifications.relay.bulwarkmail.org
|
||||
# NEXT_PUBLIC_PUSH_RELAY_URL=https://push.example.com
|
||||
|
||||
# =============================================================================
|
||||
# Demo Mode
|
||||
# =============================================================================
|
||||
|
||||
# Serve fixture data instead of talking to a mail server. Default: false.
|
||||
# DEMO_MODE=true
|
||||
|
||||
# =============================================================================
|
||||
# Stalwart Impersonation (advanced)
|
||||
# =============================================================================
|
||||
|
||||
# Lets a trusted platform mint a JWT that logs a user in without their
|
||||
# password, using a Stalwart master account. Intended for embedded
|
||||
# deployments where an outer platform already authenticated the user.
|
||||
#
|
||||
# SECURITY: this grants sign-in as any mailbox on the server. The endpoint
|
||||
# returns 404 unless all three required variables below are set, so leaving
|
||||
# them unset keeps the feature fully off. Treat the secret and the master
|
||||
# password as you would a root credential.
|
||||
#
|
||||
# BULWARK_JWT_AUTH_SECRET= # required, >= 32 characters
|
||||
# BULWARK_STALWART_MASTER_USER= # required, e.g. master@example.com
|
||||
# BULWARK_STALWART_MASTER_PASSWORD= # required
|
||||
# BULWARK_JWT_AUTH_ISSUER= # optional, default "platform-api/webmail"
|
||||
|
||||
# =============================================================================
|
||||
# Internationalization
|
||||
# =============================================================================
|
||||
@@ -274,7 +433,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
#
|
||||
# Fallback UI locale used when the visitor's Accept-Language header does not
|
||||
# match any supported locale. Defaults to "en".
|
||||
# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh
|
||||
# Supported: ar, ca, cs, da, de, en, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl,
|
||||
# pt, ro, ru, sk, tr, uk, zh
|
||||
# An unsupported value falls back to "en".
|
||||
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
|
||||
|
||||
# Locale prefix mode for URLs. Recommended "always" when proxying under a
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Build Electron Desktop App
|
||||
|
||||
# Phase 1 of the VNCprodbuild rollout (~/.claude/skills/VNCprodbuild/SKILL.md
|
||||
# on the machine that authored this - Phase 1 step 8). Builds the desktop
|
||||
# shell (electron/) for macOS, Windows, and Linux on every release, or
|
||||
# on-demand via workflow_dispatch for a one-off test build.
|
||||
#
|
||||
# Ships UNSIGNED. There's no Apple Developer ID or Windows code-signing cert
|
||||
# yet (VNCprodbuild Phase 1 step 9 - both are human-owned purchases, not
|
||||
# something CI can provide). CSC_IDENTITY_AUTO_DISCOVERY: "false" below stops
|
||||
# electron-builder from probing for a macOS signing identity it won't find.
|
||||
# Adding real certs later needs no rewrite here - just add CSC_LINK/
|
||||
# CSC_KEY_PASSWORD (macOS) and/or WIN_CSC_LINK/WIN_CSC_KEY_PASSWORD (Windows)
|
||||
# as repo secrets and electron-builder picks them up automatically.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build standalone Next.js server
|
||||
run: npm run build:standalone
|
||||
|
||||
- name: Bundle Electron main/preload
|
||||
run: npm run build:electron
|
||||
|
||||
# Only Linux runners lack a display server by default - macOS/Windows
|
||||
# GitHub-hosted runners can launch a real (if headless) GUI session
|
||||
# without one.
|
||||
- name: Install Xvfb (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
# Required gate (VNCprodbuild Phase 1 step 2) before any packaging or
|
||||
# artifact-upload step below, on every OS in the matrix - a
|
||||
# platform-specific regression in electron/main.ts (path handling,
|
||||
# spawn behavior, etc.) should fail exactly the leg it breaks, not
|
||||
# slip through because only one OS was ever smoke-tested.
|
||||
- name: Run Electron smoke test (Linux, via Xvfb)
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run --auto-servernum npm run test:electron
|
||||
|
||||
- name: Run Electron smoke test
|
||||
if: runner.os != 'Linux'
|
||||
run: npm run test:electron
|
||||
|
||||
- name: Package
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: npx electron-builder --config electron-builder.config.js --publish ${{ github.event_name == 'release' && 'always' || 'never' }}
|
||||
|
||||
- name: Upload artifact (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vncmail-plus-desktop-${{ matrix.os }}
|
||||
path: |
|
||||
dist-electron-builds/*.dmg
|
||||
dist-electron-builds/*.zip
|
||||
dist-electron-builds/*.exe
|
||||
dist-electron-builds/*.AppImage
|
||||
dist-electron-builds/*.deb
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,28 @@
|
||||
name: PR Verify
|
||||
|
||||
# Required status check on `main` (Settings -> Branches). Mirrors the GitLab
|
||||
# CI `verify` stage (.gitlab-ci.yml) so both remotes gate merges the same
|
||||
# way: typecheck, lint, translations, and a real production build — no
|
||||
# registry, no cluster, nothing that can be blocked by infra that's down.
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint
|
||||
- run: npm run test:translations
|
||||
- run: npm run build
|
||||
env:
|
||||
GIT_COMMIT: ${{ github.sha }}
|
||||
+24
@@ -38,6 +38,14 @@ yarn-error.log*
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# electron (see electron/, scripts/build-electron.mjs, electron-builder.config.js)
|
||||
/dist-electron/
|
||||
/dist-electron-builds/
|
||||
|
||||
# playwright output
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -50,3 +58,19 @@ next-env.d.ts
|
||||
|
||||
# Sibling repos
|
||||
/repos/
|
||||
|
||||
# k8s deploy secrets (create from the matching overlay's secret.example.yaml)
|
||||
/deploy/k8s/overlays/*/secret.yaml
|
||||
|
||||
# First-party plugin build output (rebuild with: npm run build:plugins).
|
||||
# vnc/plugins/build/ is the staging dir the server installs from at startup
|
||||
# (see lib/admin/bundled-plugins.ts) - built, never committed.
|
||||
vnc/plugins/build/
|
||||
vnc/plugins/*/node_modules/
|
||||
vnc/plugins/*/dist/
|
||||
vnc/plugins/smime/smime-vnc.zip
|
||||
vnc/plugins/smime/smime.zip
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
electron-ai-local-index-result.png
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
# GitLab-CI dev→prod pipeline for VNCmail+ — GitOps via ArgoCD.
|
||||
#
|
||||
# Design:
|
||||
# - MR into `dev`: verify only (typecheck/lint/unit test/build check). No
|
||||
# push, no deploy — this is the multi-developer merge gate.
|
||||
# - Push to `dev`: build+push an immutable `sha-<sha>` tag with Docker +
|
||||
# docker-in-docker, then commit a one-line tag-bump into
|
||||
# overlays/dev/image-tag/kustomization.yaml (`[skip ci]`). ArgoCD's
|
||||
# `vncmail-dev` Application syncs it automatically.
|
||||
# - Push to `main`: NEVER rebuilds. `main` only advances via
|
||||
# `git merge --ff-only dev`, so main's HEAD commit already has a built
|
||||
# image. This job just bumps overlays/prod/image-tag/kustomization.yaml
|
||||
# to point at that same tag. The actual promotion gate is a HUMAN
|
||||
# clicking Sync on the `vncmail-prod` ArgoCD Application.
|
||||
#
|
||||
# Deliberately single-platform (linux/amd64) — this pipeline serves two
|
||||
# known amd64 microk8s clusters, not public multi-arch distribution (that's
|
||||
# what the GHCR release workflows are for, untouched by this file).
|
||||
#
|
||||
# Prerequisite this file assumes:
|
||||
# - A GitLab Runner with Docker-in-Docker service support (Kubernetes or
|
||||
# Docker executor). The `docker:28.4.0-dind` service requires privileged
|
||||
# mode on most Kubernetes executors.
|
||||
# - Either "allow this job token to push to this project" enabled
|
||||
# (Settings → CI/CD → Job token permissions), OR a project access token
|
||||
# with `write_repository` scope in $GITLAB_PUSH_TOKEN. The bump jobs
|
||||
# try CI_JOB_TOKEN first (see the script).
|
||||
#
|
||||
# deploy/k8s/ca/ (the EJBCA internal CA) is never referenced anywhere below,
|
||||
# and neither ArgoCD Application in deploy/argocd/ points at it — that stays
|
||||
# a fully manual, human-only runbook (see deploy/k8s/ca/README.md).
|
||||
|
||||
stages:
|
||||
- verify
|
||||
- build
|
||||
- bump-dev
|
||||
- bump-prod
|
||||
|
||||
variables:
|
||||
IMAGE: $CI_REGISTRY_IMAGE
|
||||
GIT_STRATEGY: clone
|
||||
DOCKER_DRIVER: overlay2
|
||||
# DinD service is reached at the `docker` alias (set explicitly on the
|
||||
# service below), not localhost. TLS disabled so the daemon listens on
|
||||
# plaintext 2375 — same pattern as the working vnc-localidp pipeline.
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify — required check on every MR into dev. No registry, no cluster.
|
||||
# ---------------------------------------------------------------------------
|
||||
verify:
|
||||
stage: verify
|
||||
image: node:24-alpine
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
script:
|
||||
- npm ci
|
||||
- npm run typecheck
|
||||
- npm run lint
|
||||
- npm run test:translations
|
||||
- npm run build
|
||||
# test:integration is deliberately NOT here — it spins up a real Stalwart
|
||||
# fixture via docker-compose, which needs an actual Docker daemon this
|
||||
# runner's Kubernetes executor doesn't provide without privileged mode
|
||||
# (see the build job below). Candidate for a separate scheduled job on a
|
||||
# differently-configured runner, not a blocker on every MR.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build — push to dev only. Builds once; main never rebuilds (see header).
|
||||
# ---------------------------------------------------------------------------
|
||||
build:
|
||||
stage: build
|
||||
image: docker:28.4.0
|
||||
services:
|
||||
- name: docker:28.4.0-dind
|
||||
alias: docker
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"'
|
||||
before_script:
|
||||
- until docker info; do sleep 1; done
|
||||
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
|
||||
script:
|
||||
- >
|
||||
docker build
|
||||
--build-arg GIT_COMMIT=$CI_COMMIT_SHA
|
||||
-t "$IMAGE:sha-$CI_COMMIT_SHORT_SHA"
|
||||
-t "$IMAGE:dev-latest"
|
||||
.
|
||||
- docker push "$IMAGE:sha-$CI_COMMIT_SHORT_SHA"
|
||||
- docker push "$IMAGE:dev-latest"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bump-dev — no cluster access. Commits the just-built tag into the overlay
|
||||
# ArgoCD watches; ArgoCD's automated sync does the actual apply.
|
||||
# ---------------------------------------------------------------------------
|
||||
bump-dev:
|
||||
stage: bump-dev
|
||||
# alpine/git:2.47.0 was never published on Docker Hub — the 2.47.x line
|
||||
# starts at 2.47.1. Using 2.47.2 (latest 2.47.x).
|
||||
image: alpine/git:2.47.2
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "dev"'
|
||||
script:
|
||||
- TAG="sha-$CI_COMMIT_SHORT_SHA"
|
||||
- |
|
||||
cat > deploy/k8s/overlays/dev/image-tag/kustomization.yaml <<EOF
|
||||
# Owned by CI (bump-dev job in .gitlab-ci.yml) - regenerated every
|
||||
# push to dev. Do not hand-edit; edits here get overwritten.
|
||||
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||
kind: Component
|
||||
images:
|
||||
- name: vncmail-plus
|
||||
newName: $IMAGE
|
||||
newTag: $TAG
|
||||
EOF
|
||||
- git config user.name "vncmail-ci"
|
||||
- git config user.email "ci@vnc.biz"
|
||||
- git add deploy/k8s/overlays/dev/image-tag/kustomization.yaml
|
||||
- |
|
||||
if git diff --cached --quiet; then
|
||||
echo "No change (tag already pinned) - nothing to commit"
|
||||
else
|
||||
git commit -m "chore(deploy): pin dev to $TAG [skip ci]"
|
||||
git push "https://gitlab-ci-token:${GITLAB_PUSH_TOKEN:-$CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:dev
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bump-prod — no cluster access, no rebuild. Points overlays/prod at the
|
||||
# exact tag already running on dev. Does NOT deploy anything: vncmail-prod's
|
||||
# ArgoCD Application has manual sync, so this only prepares what a human
|
||||
# would be syncing, it doesn't sync it.
|
||||
# ---------------------------------------------------------------------------
|
||||
bump-prod:
|
||||
stage: bump-prod
|
||||
image: alpine/git:2.47.2
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
||||
script:
|
||||
- TAG="sha-$CI_COMMIT_SHORT_SHA"
|
||||
- echo "main advanced to $CI_COMMIT_SHA (must be a dev commit, ff-only) - that image already exists as $IMAGE:$TAG"
|
||||
- |
|
||||
cat > deploy/k8s/overlays/prod/image-tag/kustomization.yaml <<EOF
|
||||
# Owned by CI (bump-prod job in .gitlab-ci.yml) - regenerated every
|
||||
# push to main. Do not hand-edit; edits here get overwritten. Bumping
|
||||
# this is NOT the same as deploying it - vncmail-prod's ArgoCD
|
||||
# Application has manual sync, see the note in the parent
|
||||
# kustomization.yaml.
|
||||
apiVersion: kustomize.config.k8s.io/v1alpha1
|
||||
kind: Component
|
||||
images:
|
||||
- name: vncmail-plus
|
||||
newName: $IMAGE
|
||||
newTag: $TAG
|
||||
EOF
|
||||
- git config user.name "vncmail-ci"
|
||||
- git config user.email "ci@vnc.biz"
|
||||
- git add deploy/k8s/overlays/prod/image-tag/kustomization.yaml
|
||||
- |
|
||||
if git diff --cached --quiet; then
|
||||
echo "No change (tag already pinned) - nothing to commit"
|
||||
else
|
||||
git commit -m "chore(deploy): point prod overlay at $TAG (not synced - manual gate in ArgoCD) [skip ci]"
|
||||
git push "https://gitlab-ci-token:${GITLAB_PUSH_TOKEN:-$CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:main
|
||||
fi
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.9 (2026-08-07)
|
||||
|
||||
### Bug Fixes (Phase 1 — VNCmailgraph audit)
|
||||
|
||||
- **Mail**: Network transport failures now throw `TransportError` instead of returning empty results, so offline/network-down is distinguishable from an empty folder (#C1)
|
||||
- **Mail**: Push handler now refreshes contacts and files on remote state changes (#H1)
|
||||
- **Calendar**: Recurrence expansion IDs use `::occurrence::` delimiter to prevent collision with shared-event prefixes (#C2)
|
||||
- **Calendar**: Cross-account event aggregation now deduplicates by UID + recurrenceId, preventing phantom duplicates (#C3)
|
||||
- **Calendar**: `calendarTasksEnabled` admin policy now enforced at runtime, not just in settings UI (#H13)
|
||||
- **Tasks**: All task mutations (update, delete, toggle) now have error handling with store error state (#H14)
|
||||
- **Settings**: `updateSetting()` now checks admin policy lock before writing; `force` opt-in for legitimate bypassers (#C7)
|
||||
- **Settings**: `autoSelectReplyIdentity` now defaults to `true` — auto-identity selection on by default (#H18)
|
||||
- **Templates**: HTML template bodies are now sanitized with DOMPurify on import to prevent stored XSS (#H7)
|
||||
- **Auth**: User authentication endpoints now rate-limited — 10 attempts per (IP + username) per 15 minutes (#H3)
|
||||
- **Auth**: Admin sessions now support token revocation via JTI blacklist on logout (#C4)
|
||||
- **Auth**: Secure cookie flag now derived from `x-forwarded-proto`, not `NODE_ENV` (#H8)
|
||||
- **Auth**: OAuth token exchange error logs no longer leak `access_token` (#H4)
|
||||
- **Auth**: `isHashed()` no longer accepts bcrypt prefixes — scrypt-only, preventing lockout from bcrypt passwords (#H9)
|
||||
- **Push**: WS→SSE fallback now awaits state snapshot before reconciliation to prevent missed deliveries (#H2)
|
||||
- **Push**: Offline event handler added — push transports pause when browser goes offline, reconnect on online (#C8)
|
||||
- **Index**: FTS5 schema-drop now logs a warning so operators know a rebuild is needed (#C6)
|
||||
|
||||
---
|
||||
|
||||
## 1.7.8 (2026-07-22)
|
||||
|
||||
### Features
|
||||
|
||||
+79
-33
@@ -10,13 +10,13 @@
|
||||
|
||||
# Contributing to Bulwark Webmail
|
||||
|
||||
We're writing the webmail we wanted in 2026 and didn't find. Modern protocol, modern tooling, modern UI. Not a SaaS. Not a startup. Not for sale.
|
||||
We're writing the webmail we wanted in 2026 and didn't find: a JMAP-native client with an interface built this decade. It's AGPL and self-hosted, run by the people who use it rather than sold to them.
|
||||
|
||||
If that resonates with you, we'd love your help. This guide covers how to get the project running, the conventions we follow, and how to land your first change.
|
||||
If that sounds like your kind of project, we'd love the help.
|
||||
|
||||
## Join the Community
|
||||
## Join the community
|
||||
|
||||
You don't need to be an expert to contribute. Whether you're setting up your dev environment for the first time, filing a bug, or translating a string, the Discord is the fastest way to get unstuck and meet the people working on this.
|
||||
You don't need to be an expert to contribute. A dev environment that won't start, a bug you're not sure how to report, a translation you're stuck on: Discord is the fastest way to get unstuck and to meet the people working on this.
|
||||
|
||||
- **Get support** - real-time help with development hurdles
|
||||
- **Share ideas** - feature suggestions, design feedback, doc improvements
|
||||
@@ -26,9 +26,9 @@ You don't need to be an expert to contribute. Whether you're setting up your dev
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
## Getting started
|
||||
|
||||
### Development Setup
|
||||
### Development setup
|
||||
|
||||
1. **Fork and clone** the repository:
|
||||
|
||||
@@ -46,16 +46,22 @@ You don't need to be an expert to contribute. Whether you're setting up your dev
|
||||
3. **Set up environment**:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local with your JMAP server URL
|
||||
cp .env.dev.example .env.local
|
||||
```
|
||||
|
||||
This enables the built-in mock JMAP server (`DEV_MOCK_JMAP=true`), so you can
|
||||
develop without a mail server. Log in with any username and password. To work
|
||||
against a real server instead, copy `.env.example` and set `JMAP_SERVER_URL`.
|
||||
|
||||
4. **Start development server**:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
Then open http://localhost:3000.
|
||||
|
||||
### Code quality
|
||||
|
||||
Before submitting a pull request, ensure your code passes all checks:
|
||||
|
||||
@@ -72,7 +78,20 @@ npm run lint:fix
|
||||
|
||||
These checks run automatically on commit via Husky pre-commit hooks.
|
||||
|
||||
## Code Style Guidelines
|
||||
### Testing
|
||||
|
||||
| Suite | Command | What it covers |
|
||||
| ---------------- | -------------------------- | ------------------------------------------------------------------ |
|
||||
| **Unit** | `npx vitest run` | Vitest + jsdom. Tests live in `__tests__/` folders next to the code |
|
||||
| **Translations** | `npm run test:translations` | Locale files checked for structural drift against English |
|
||||
| **Integration** | `npm run test:integration` | Playwright against a real Stalwart server in Docker |
|
||||
| **E2E smoke** | `npx playwright test` | UI smoke tests against `npm run dev` |
|
||||
|
||||
Run a single unit test file with `npx vitest run lib/__tests__/<name>.test.ts`, or `npx vitest` to watch.
|
||||
|
||||
The integration suite needs Docker and takes several minutes; it has its own setup notes and findings log in [integration/README.md](integration/README.md). New behavior that touches mail/folder synchronization or multi-account handling belongs there.
|
||||
|
||||
## Code style guidelines
|
||||
|
||||
### TypeScript
|
||||
|
||||
@@ -81,7 +100,7 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
||||
- Avoid `any` types when possible
|
||||
- Use meaningful variable and function names
|
||||
|
||||
### React Components
|
||||
### React components
|
||||
|
||||
- Use functional components with hooks
|
||||
- Keep components focused and single-purpose
|
||||
@@ -97,7 +116,9 @@ These checks run automatically on commit via Husky pre-commit hooks.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 15 additional locales (cs, de, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh).
|
||||
This project uses **next-intl**. English (`/locales/en/common.json`) is the source of truth; we ship 23 additional locales (ar, ca, cs, da, de, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, pt, ro, ru, sk, tr, uk, zh).
|
||||
|
||||
Arabic, Hebrew, and Persian render right-to-left (see `i18n/direction.ts`). Use Tailwind's **logical** utilities (`ms-*`/`me-*`, `ps-*`/`pe-*`, `start-*`/`end-*`) rather than physical ones (`ml-*`, `pl-*`, `left-*`) so layouts flip correctly. For popovers positioned in JS via `getBoundingClientRect()`, check `isDocumentRTL()`: inline `position: fixed` styles don't pick up logical utilities.
|
||||
|
||||
### Rules
|
||||
|
||||
@@ -126,9 +147,22 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
||||
router.push(`/${params.locale}/settings`);
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
### Adding a new locale
|
||||
|
||||
### Before Submitting
|
||||
Registering a new locale takes edits in four places:
|
||||
|
||||
1. `locales/<code>/common.json` - copy `locales/en/common.json` and translate
|
||||
2. `i18n/routing.ts` - add the code to `SUPPORTED_LOCALES`
|
||||
3. `i18n/request.ts` - add a `case` to the static-import switch
|
||||
4. `components/ui/language-switcher.tsx` - add `{ value, label }` with the **native** language name, plus a flag in `components/ui/flag-icons.tsx`
|
||||
|
||||
For a right-to-left language, also add the code to `rtlLocales` in `i18n/direction.ts`.
|
||||
|
||||
Run `npm run test:translations` afterwards - it checks the locale files for structural drift against English.
|
||||
|
||||
## Pull request process
|
||||
|
||||
### Before submitting
|
||||
|
||||
1. **Create a feature branch**:
|
||||
|
||||
@@ -138,13 +172,13 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
||||
|
||||
2. **Make your changes** following the code style guidelines
|
||||
|
||||
3. **Test your changes** thoroughly
|
||||
3. **Test your changes** thoroughly, and add unit tests for new logic
|
||||
|
||||
4. **Update translations** if you added user-facing text
|
||||
|
||||
5. **Run all checks**:
|
||||
```bash
|
||||
npm run typecheck && npm run lint
|
||||
npm run typecheck && npm run lint && npx vitest run
|
||||
```
|
||||
|
||||
### Submitting
|
||||
@@ -157,7 +191,7 @@ This project uses **next-intl**. English (`/locales/en/common.json`) is the sour
|
||||
- Screenshots for UI changes
|
||||
- Reference to any related issues
|
||||
|
||||
### Commit Message Convention
|
||||
### Commit message convention
|
||||
|
||||
Follow the conventional commits format:
|
||||
|
||||
@@ -177,25 +211,37 @@ fix: resolve attachment download issue
|
||||
docs: update README with keyboard shortcuts
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
## Project structure
|
||||
|
||||
```
|
||||
webmail/
|
||||
├── app/ # Next.js App Router pages
|
||||
│ └── [locale]/ # Locale-aware routing
|
||||
├── components/ # React components
|
||||
│ ├── email/ # Email-related components
|
||||
│ ├── layout/ # Layout components
|
||||
│ ├── settings/ # Settings components
|
||||
│ └── ui/ # Reusable UI components
|
||||
├── contexts/ # React contexts
|
||||
├── hooks/ # Custom React hooks
|
||||
├── lib/ # Utilities and libraries
|
||||
│ └── jmap/ # JMAP client implementation
|
||||
├── locales/ # Translation files
|
||||
│ ├── en/ # English translations
|
||||
│ └── fr/ # French translations
|
||||
└── stores/ # Zustand state stores
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── (main)/[locale]/ # Locale-aware app pages (mail, calendar, contacts, files, settings)
|
||||
│ ├── (main)/admin/ # Admin dashboard
|
||||
│ ├── (main)/setup/ # First-launch setup wizard
|
||||
│ ├── (sandbox)/ # Isolated plugin sandbox routes
|
||||
│ └── api/ # Route handlers (auth, admin, jmap, caldav, …)
|
||||
├── components/ # React components
|
||||
│ ├── email/ # Email list, viewer, composer
|
||||
│ ├── calendar/ contacts/ files/ filters/ templates/
|
||||
│ ├── layout/ # Sidebar, shell, navigation
|
||||
│ ├── settings/ # Settings panels
|
||||
│ ├── plugins/ # Plugin host UI
|
||||
│ └── ui/ # Reusable primitives
|
||||
├── contexts/ # React contexts
|
||||
├── hooks/ # Custom React hooks
|
||||
├── i18n/ # next-intl routing, locale detection, RTL direction
|
||||
├── lib/ # Utilities and libraries
|
||||
│ ├── jmap/ # JMAP client implementation
|
||||
│ ├── stalwart/ # Stalwart-specific admin/API helpers
|
||||
│ ├── admin/ auth/ oauth/ # Config, sessions, OAuth flows
|
||||
│ ├── plugin-sandbox/ # Plugin sandbox bridge and hardening
|
||||
│ └── __tests__/ # Vitest unit tests
|
||||
├── locales/ # Translation files, one directory per locale
|
||||
├── stores/ # Zustand state stores
|
||||
├── public/ # Static assets and branding
|
||||
├── e2e/ # Playwright smoke tests (against `npm run dev`)
|
||||
└── integration/ # Dockerized Stalwart + Playwright suite
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
+10
@@ -21,6 +21,12 @@ ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||
ARG GIT_COMMIT=unknown
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
# Build the first-party plugins (vnc/plugins/*) that ship with this fork -
|
||||
# currently the audited S/MIME plugin, which the server installs into its
|
||||
# plugin registry at startup (lib/admin/bundled-plugins.ts). Each plugin has
|
||||
# its own package.json + lockfile, so this does its own npm ci.
|
||||
# Runs BEFORE next build so a broken plugin fails the image build.
|
||||
RUN node scripts/build-plugins.mjs
|
||||
RUN npx next build --webpack
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
@@ -43,6 +49,10 @@ RUN apk upgrade --no-cache && \
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
# Staged first-party plugin bundles. Read by path at runtime, so Next's output
|
||||
# file tracing does not carry them into .next/standalone - copy explicitly or
|
||||
# the image boots with the S/MIME policy toggle on and no plugin installed.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/vnc/plugins/build ./vnc/plugins/build
|
||||
RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
+111
-107
@@ -2,145 +2,149 @@
|
||||
|
||||
## Mail
|
||||
|
||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||
- Unified Mailbox – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account
|
||||
- Aggregated All mail / Unread / Starred entries in the Unified Mailbox – scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message
|
||||
- Search inside the Unified Mailbox – text search across every unified view (the per-role mailboxes and the folder-selected All mail / Unread / Starred lists); advanced filters are additionally available in the per-role unified mailboxes
|
||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
||||
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
||||
- Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
|
||||
- Scheduled send and configurable send delay
|
||||
- Read, compose, reply, reply-all, and forward in a Tiptap rich-text editor that handles inline images, drag-and-drop embedding, and tables
|
||||
- Gmail-style threading, expanded inline, with a conversation toggle you can switch off
|
||||
- The Unified Mailbox combines Inbox, Sent, Drafts, Junk, Archive, and Trash. By default it stays inside the active account and its shared/group folders; an admin can unlock a cross-account mode that spans every connected account.
|
||||
- All mail, Unread, and Starred obey that same account boundary and can be narrowed to a per-account folder selection. Every row names the folder its message came from.
|
||||
- Search runs across all unified views; the per-role mailboxes add the full filter panel on top
|
||||
- Three mail layouts: split three-pane, focused list, or reading pane at the bottom
|
||||
- Drafts auto-save, keeping the chosen identity, the HTML body, and correct `In-Reply-To` / `References` headers on replies
|
||||
- Attachments upload, download, drag out to the file system, and preview inline. Images and PDFs render on desktop and mobile, composer attachments open on click, and `.eml` (`message/rfc822`) parts display as a nested email. There are list thumbnails, and a warning when you mention an attachment and forget it.
|
||||
- Scheduled send, plus a configurable delay before anything leaves the outbox
|
||||
- Read receipts (MDN, RFC 8098)
|
||||
- Editable, layout-preserving quote island when replying
|
||||
- Full-text search with JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||
- Batch operations – multi-select, archive, delete, move, tag
|
||||
- Archive modes – direct, by year, or by month
|
||||
- Multi-tag support with color labels, reordering, and drag-and-drop assignment
|
||||
- Star/unstar with configurable mark-as-read delay
|
||||
- Virtual scrolling for large mailboxes plus prefetching of initial email data on login
|
||||
- Quick reply, hover actions, sender avatars (favicon-based), and recipient popovers
|
||||
- Plain-text composer mode and Reply-To support
|
||||
- Configurable signature position (above or below quoted text) per identity
|
||||
- From-header override in the composer with optional catch-all auto-reply: replies to an alias on a domain you own auto-fill the alias as the sender even when it isn't a configured identity
|
||||
- `.eml` file import via folder right-click menu
|
||||
- Quoted text lands in an editable island that keeps the original layout
|
||||
- Full-text search with a JMAP filter panel, search chips, wildcards, OR conditions, and cross-mailbox queries
|
||||
- Multi-select for batch archive, delete, move, and tag
|
||||
- Archive directly, by year, or by month
|
||||
- Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them
|
||||
- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree
|
||||
- Each tag can be configured to show always, only when there are unread mails or always be hidden
|
||||
- Star or unstar, with a configurable mark-as-read delay
|
||||
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
|
||||
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
|
||||
- Plain-text composer mode and Reply-To
|
||||
- The signature sits above or below the quoted text, per identity
|
||||
- Override the From header in the composer. Reply to an alias on a domain you own and it auto-fills as the sender, even when no identity exists for it.
|
||||
- Import `.eml` files from the folder right-click menu
|
||||
- TNEF (`winmail.dat`) extraction and `message/rfc822` unwrapping
|
||||
- Folder management with icon picker, subfolders, and sidebar counts
|
||||
- Print directly from the viewer
|
||||
- Browser history sync for back/forward navigation
|
||||
- Folders take an icon, nest, and show counts in the sidebar
|
||||
- Print from the viewer
|
||||
- Browser back and forward move through mail history
|
||||
|
||||
## Calendar
|
||||
|
||||
- Month, week, day, and agenda views with a mini-calendar sidebar and task list
|
||||
- Drag-to-reschedule, click-drag creation, and edge-resize with 15-minute snap
|
||||
- Recurring events with scoped edit/delete (this / this and following / all)
|
||||
- iMIP invitations on create and update (RFC 5545 / 6047), organizer/attendee UI, and RSVP with trust assessment
|
||||
- Inline calendar invitations in the email viewer – auto-detect `.ics`, RSVP, import
|
||||
- iCalendar import with preview, bulk create, and UID deduplication
|
||||
- iCal / webcal subscriptions with editing and batch import
|
||||
- Auto-generated birthday calendar from contacts
|
||||
- Virtual locations (video conference URLs) as first-class event fields
|
||||
- Task management with due dates, priority, and completion status
|
||||
- Shared calendars with CalDAV discovery, multi-account home resolution, and per-viewer colors
|
||||
- Week numbers, event hover preview, notifications with sound picker
|
||||
- Real-time sync via JMAP push
|
||||
- Month, week, day, and agenda views, with a mini-calendar and task list in the sidebar
|
||||
- Drag an event to reschedule it, click-drag to create one, pull an edge to resize. Everything snaps to 15 minutes.
|
||||
- Recurring events edit and delete by scope: this occurrence, this and following, or all
|
||||
- iMIP invitations on create and update (RFC 5545 / 6047), an organizer/attendee panel, and RSVP with trust assessment
|
||||
- `.ics` attachments are detected in the email viewer, so you can RSVP or import without leaving the message
|
||||
- iCalendar import previews first, then bulk-creates, deduplicating on UID
|
||||
- iCal / webcal subscriptions, editable, with batch import
|
||||
- A birthday calendar generated from your contacts
|
||||
- Virtual locations (video-conference URLs) are first-class event fields
|
||||
- Tasks with due dates, priority, and completion status
|
||||
- Shared calendars through CalDAV discovery, resolving homes across accounts, colored per viewer
|
||||
- Week numbers, hover preview, notifications with a sound picker
|
||||
- JMAP push keeps everything in sync
|
||||
|
||||
## Contacts
|
||||
|
||||
- JMAP sync (RFC 9553 / 9610) with local fallback
|
||||
- Multiple address books with drag-and-drop between books
|
||||
- Contact groups with member management
|
||||
- vCard import/export (RFC 6350) with duplicate detection
|
||||
- Trusted senders stored in a dedicated JMAP address book
|
||||
- Autocomplete in the composer (To / Cc / Bcc)
|
||||
- JMAP sync (RFC 9553 / 9610), falling back to local storage
|
||||
- Several address books, with drag-and-drop between them
|
||||
- Groups with member management
|
||||
- vCard import/export (RFC 6350) that flags duplicates
|
||||
- Trusted senders live in their own JMAP address book
|
||||
- Autocomplete on To, Cc, and Bcc
|
||||
|
||||
## Filters & Templates
|
||||
## Filters & templates
|
||||
|
||||
- Server-side filters via JMAP Sieve Scripts (RFC 9661)
|
||||
- Visual rule builder with expanded view; conditions (From, To, Subject, Size, Body, Attachment…) with multi-value matching and actions (Move, Forward, Star, Discard…)
|
||||
- Preserves rules authored in other clients
|
||||
- Server-side filters as JMAP Sieve Scripts (RFC 9661)
|
||||
- A visual rule builder: conditions on From, To, Subject, Size, Body, Attachment and more, each matching multiple values, with actions to move, forward, star, or discard
|
||||
- Rules written in other clients survive the round-trip
|
||||
- Raw Sieve editor with syntax validation
|
||||
- Vacation responder with date range scheduling
|
||||
- Reusable email templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||
- A vacation responder you can schedule to a date range
|
||||
- Templates with placeholder auto-fill (`{{recipientName}}`, `{{date}}`, …)
|
||||
|
||||
## Files
|
||||
|
||||
- JMAP FileNode browser (Stalwart native cloud storage) with a real folder hierarchy; legacy flat-named files are migrated into nested `FileNode` folders automatically on load
|
||||
- Streamed WebDAV PUT upload and folder upload with progress tracking
|
||||
- Dynamic upload limits based on server configuration
|
||||
- Grid and list views with sorting by name, size, or date
|
||||
- Previews for images, text, audio, and video
|
||||
- Clipboard operations (cut, copy, paste, duplicate), favorites, and recent files
|
||||
- JMAP sharing (RFC 9670) for files and folders – share with users or groups at read, read/write, or manager levels via a principal picker, with share indicators and a "Shared with me" sidebar section for folders other principals have shared with you
|
||||
- Browse Stalwart's native JMAP FileNode storage as a real folder tree. Legacy flat-named files migrate into nested `FileNode` folders on first load.
|
||||
- Streamed WebDAV PUT upload, whole folders included, with progress
|
||||
- Upload limits follow the server's own configuration
|
||||
- Grid or list, sorted by name, size, or date
|
||||
- Preview images, text, audio, and video
|
||||
- Cut, copy, paste, duplicate; favorites; recent files
|
||||
- JMAP sharing (RFC 9670) for files and folders. Pick a user or group from the principal picker and grant read, read/write, or manager. Shared items get an indicator, and anything other principals share with you appears under "Shared with me".
|
||||
|
||||
## Security & Privacy
|
||||
## Security & privacy
|
||||
|
||||
- External content blocked by default, with a trusted senders list
|
||||
- HTML sanitization via DOMPurify
|
||||
- S/MIME – manage certificates, sign, encrypt, decrypt, and verify; legacy 3DES / PBE support; per-account key isolation
|
||||
- SPF / DKIM / DMARC status indicators – surfaces the most severe SPF result and hides the "via" badge on spoofed mail
|
||||
- OAuth2 / OIDC with PKCE (Keycloak, Authentik, or built-in), OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||
- External content stays blocked until you say otherwise, and trusted senders are remembered
|
||||
- HTML sanitized through DOMPurify
|
||||
- S/MIME: manage certificates, then sign, encrypt, decrypt, and verify. Legacy 3DES / PBE is supported, and keys stay isolated per account.
|
||||
- SPF / DKIM / DMARC indicators surface the most severe SPF result and drop the "via" badge on spoofed mail
|
||||
- OAuth2 / OIDC with PKCE against Keycloak, Authentik, or the built-in provider, plus OAuth-only mode, OAuth app passwords, and non-interactive SSO for embedded deployments
|
||||
- TOTP two-factor authentication
|
||||
- Account security panel for password and 2FA management via the Stalwart admin API
|
||||
- Optional "Remember me" via AES-256-GCM encrypted httpOnly cookie
|
||||
- Enforced CSP with per-request nonce, SSRF redirect validation, PDF iframe sandbox, and IP spoofing prevention
|
||||
- Plugin hardening with dangerous-pattern detection and admin approval
|
||||
- Password and 2FA management through the Stalwart admin API
|
||||
- "Remember me" is optional and rides an AES-256-GCM encrypted httpOnly cookie
|
||||
- CSP is enforced with a per-request nonce, alongside SSRF redirect validation, a sandboxed PDF iframe, and IP spoofing prevention
|
||||
- Plugins are scanned for dangerous patterns and need admin approval
|
||||
- Newsletter unsubscribe (RFC 2369)
|
||||
|
||||
## Interface
|
||||
|
||||
- Selectable mail layouts (split three-pane, focused list, reading pane at bottom) with resizable columns
|
||||
- Dark and light themes with intelligent email color transformation
|
||||
- Bundled color themes including Aurora Glass and Elastic; theme cards render as a mini mailbox mockup built from the theme's own colors, with light/dark variant chips
|
||||
- Responsive desktop, tablet, and mobile layouts
|
||||
- Split three-pane, focused list, or bottom reading pane, columns resizable
|
||||
- Dark and light themes. Email colors are remapped by luminance, so a mail hard-coded to dark-on-white stays readable on a dark background.
|
||||
- Bundled themes such as Aurora Glass and Elastic. Each theme card renders as a miniature mailbox built from that theme's own colors, with chips for the light and dark variants.
|
||||
- Layouts for desktop, tablet, and mobile
|
||||
- Full keyboard navigation
|
||||
- Drag-and-drop email organization and tag assignment
|
||||
- Interactive guided tour for new users
|
||||
- Right-click context menus, toast notifications with undo
|
||||
- Customizable toolbar position, favicon, and login branding
|
||||
- Pinnable sidebar apps with drag-and-drop reordering
|
||||
- Encrypted settings sync across devices
|
||||
- Drag and drop to organize mail and assign tags
|
||||
- A guided tour for first-time users
|
||||
- Right-click menus, and toasts that offer an undo
|
||||
- Toolbar position, favicon, and login branding are configurable
|
||||
- Sidebar apps pin and reorder by drag
|
||||
- Settings sync between devices, encrypted
|
||||
- Storage quota display
|
||||
- WCAG AA contrast, reduced-motion support, focus trap, and screen reader live regions
|
||||
- WCAG AA contrast, reduced-motion support, focus traps, and screen-reader live regions
|
||||
|
||||
## Internationalization
|
||||
|
||||
19 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文
|
||||
24 languages: Català · Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Slovenčina · Türkçe · Русский · Українська · עברית · العربية · فارسی · 한국어 · 日本語 · 简体中文
|
||||
|
||||
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
|
||||
- Arabic, Hebrew, and Persian render right-to-left; document direction and logical layout flip automatically
|
||||
- The browser's `Accept-Language` picks the first language, and the choice persists per user
|
||||
- `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback, `NEXT_PUBLIC_LOCALE_PREFIX` the URL prefix
|
||||
|
||||
## Identity & Multi-Account
|
||||
## Identity & multi-account
|
||||
|
||||
- Multiple simultaneous accounts with instant switching and per-account session persistence; the 5-account cap is lifted on HTTP/2 servers (limited by browser connection pooling on HTTP/1.1)
|
||||
- Account switcher with connection status and default account selection
|
||||
- Multiple sender identities with per-identity signatures, automatic sync, and badges in viewer/list
|
||||
- Configurable signature position (above or below quoted text)
|
||||
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
||||
- Run several accounts at once and switch instantly, each keeping its own session. The 5-account cap lifts on HTTP/2 servers; on HTTP/1.1, browser connection pooling still sets the limit.
|
||||
- An account switcher showing connection status, and a default account
|
||||
- Multiple sender identities, each with its own signature, synced automatically and badged in the viewer and list
|
||||
- Signature above or below the quoted text
|
||||
- Sub-addressing (`user+tag@domain.com`), delimiter configurable, with tag suggestions drawn from context
|
||||
- Shared folders across accounts
|
||||
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the Unified Mailbox ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync
|
||||
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
||||
- Shared and group (delegated) accounts put their folders next to your own, and "Include group inboxes" merges them into the Unified Mailbox. You can open, mark read, flag as spam or not-spam, move, delete, and archive their messages from there, and folder unread counts stay in step.
|
||||
- Several JMAP servers per deployment, optionally auto-picked by email domain
|
||||
- Custom JMAP endpoints on the login form, when `ALLOW_CUSTOM_JMAP_ENDPOINT` permits it
|
||||
|
||||
## Admin & Extensibility
|
||||
## Admin & extensibility
|
||||
|
||||
- Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
|
||||
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
|
||||
- Admin policy gates for the Unified Mailbox – enable or disable the All mail / Unread / Starred entries org-wide, plus a cross-account capability gate (off by default; auto-enabled on upgrade for instances that already used the cross-account views); each gated view still respects the user's own toggle
|
||||
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
|
||||
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
|
||||
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
||||
- Plugin system – schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (localizable sandboxed plugins via manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
|
||||
- Plugin hot-reload and dev-folder loading, on-demand `src/` bundling via esbuild, and `http:fetch` permission with `httpOrigins`
|
||||
- Themes – upload, enforce, and manage admin-controlled themes as ZIP bundles
|
||||
- Extension marketplace – browse and install plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`); install/uninstall restricted to the admin dashboard
|
||||
- Bundled plugins including Jitsi Meet calendar integration
|
||||
- A setup wizard runs on first launch and walks through JMAP servers, OAuth/OIDC, the session secret, logging, branding (uploads included), and the admin password. It writes to the admin config dir, so `.env.local` stays untouched.
|
||||
- The Stalwart admin dashboard, its policy sections collapsed into one tabbed page
|
||||
- Admin policy gates for the Unified Mailbox: turn All mail / Unread / Starred on or off org-wide, and gate cross-account capability separately (off by default, auto-enabled on upgrade for instances already using it). A gated view still respects the user's own toggle.
|
||||
- Admin storage splits in two. `ADMIN_CONFIG_DIR` is operator-authored and can be mounted read-only once setup finishes; `ADMIN_STATE_DIR` holds the runtime audit log and login timestamps.
|
||||
- JSON config can read secrets from files (`passwordHashFile`, `sessionSecretFile`, `oauthClientSecretFile`) for Docker and Kubernetes secret mounts
|
||||
- An admin toggle controls search-engine indexing (`robots.txt` / `noindex`)
|
||||
- Plugin system: a schema-driven config UI, render and intercept hooks, `onAvatarResolve`, `onBeforeEmailSend`, composer-sidebar and email-banner slots, calendar event slots, i18n APIs (sandboxed plugins localize through manifest locales and `api.i18n.t`), an `/api/translate` proxy, email-body access, and managed policy enforcement
|
||||
- Plugins hot-reload, load from a dev folder, bundle `src/` on demand through esbuild, and can request `http:fetch` scoped by `httpOrigins`
|
||||
- Themes upload as ZIP bundles, and admins can enforce one
|
||||
- An extension marketplace browses and installs plugins and themes from a configurable directory (`EXTENSION_DIRECTORY_URL`). Installing and uninstalling stay in the admin dashboard.
|
||||
- Bundled plugins, including Jitsi Meet for the calendar
|
||||
|
||||
## Operations
|
||||
|
||||
- Progressive Web App with service worker, install prompt, web push notifications for inbox mail, dynamic manifest, and configurable (per-domain) install screenshots
|
||||
- Automatic update check with server-side logging of new releases and a non-dismissible update notice
|
||||
- Structured logging (`text` or `json`) with category-based levels
|
||||
- Anonymous instance telemetry (opt-in via admin UI, the installer, or `BULWARK_TELEMETRY=on`; off by default) – version, platform, bucketed account counts, feature toggles only
|
||||
- Release (`main`) and development (`dev`) Docker images on GHCR
|
||||
- Subpath deployment via `NEXT_PUBLIC_BASE_PATH` for mounting behind a reverse proxy
|
||||
- Demo mode with fixture data – no mail server required
|
||||
- Progressive Web App: service worker, install prompt, web push for new inbox mail, a dynamic manifest, and install screenshots configurable per domain
|
||||
- Update checks run on their own, log new releases server-side, and raise a notice that can't be dismissed
|
||||
- Structured logging (`text` or `json`) with per-category levels
|
||||
- Anonymous instance telemetry, off unless you enable it through the admin UI, the installer, or `BULWARK_TELEMETRY=on`. It reports version, platform, bucketed account counts, and feature toggles.
|
||||
- Docker images on GHCR, for release (`main`) and development (`dev`)
|
||||
- `NEXT_PUBLIC_BASE_PATH` mounts the app at a subpath behind a reverse proxy
|
||||
- Demo mode runs on fixture data, no mail server required
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
# Bulwark Webmail
|
||||
|
||||
A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||
A self-hosted webmail client for [Stalwart Mail Server](https://stalw.art/), built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
@@ -20,12 +20,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
## Installer
|
||||
|
||||
New in **1.6.4**: a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container.
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="screenshots/installer-dark.png" />
|
||||
<img src="screenshots/installer.png" alt="Setup wizard" width="100%" />
|
||||
</picture>
|
||||
Since **1.6.4**, a web-based setup wizard runs on first launch – no `.env.local` editing, no shelling into the container.
|
||||
|
||||
Point a browser at the running container and the wizard guides you through:
|
||||
|
||||
@@ -70,27 +65,27 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM
|
||||
<td><img src="screenshots/settings.png" alt="Settings" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><sub><b>Light mode</b> – full theme support with intelligent color transformation for HTML emails.</sub></td>
|
||||
<td><sub><b>Light mode</b> – full theme support, remapping HTML email colors by luminance so dark-on-dark text stays readable.</sub></td>
|
||||
<td><sub><b>Settings</b> – appearance, identities, filters, templates, security, and more.</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
## What Bulwark includes
|
||||
|
||||
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
|
||||
Bulwark is a full webmail suite. It bundles the four apps most self-hosters end up wanting:
|
||||
|
||||
- **Mail** – threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
|
||||
- **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
|
||||
- **Contacts** – multiple address books, groups, vCard import/export
|
||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||
|
||||
Plus the infrastructure around them: a web setup wizard, OAuth2 / OIDC SSO, TOTP 2FA, multi-account with HTTP/2 connection pooling, 18 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and an admin dashboard.
|
||||
They share one login, one settings store, and one admin dashboard. SSO, 2FA, multi-account, 24 languages, PWA install, themes, and plugins apply across all four.
|
||||
|
||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## Quick start
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -104,9 +99,9 @@ Or with Docker Compose:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
On first launch, open `http://localhost:3000` – the **web setup wizard** walks you through JMAP server, OAuth, branding, and the admin password. No `.env.local` editing required. Existing installs that already define `JMAP_SERVER_URL` in their environment skip the wizard and keep the env-managed flow described under [Configuration](#configuration).
|
||||
On first launch, open `http://localhost:3000` and the setup wizard takes over. Installs that already define `JMAP_SERVER_URL` skip it and keep the env-managed flow under [Configuration](#configuration).
|
||||
|
||||
### From Source
|
||||
### From source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bulwarkmail/webmail.git
|
||||
@@ -119,16 +114,20 @@ npm run build && npm start
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev # Dev server with a mock JMAP server
|
||||
cp .env.dev.example .env.local # Built-in mock JMAP server, no mail server needed
|
||||
|
||||
npm run dev # Dev server
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npx vitest run # Unit tests
|
||||
npm run test:integration # Dockerized Stalwart + Playwright suite (see integration/README.md)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Most deployments are configured through the **setup wizard** (on first launch) and the **admin dashboard** thereafter; values are written to the admin config directory rather than `.env.local`. Environment variables remain supported for operators who prefer file-driven configuration or read-only / immutable infrastructure. When an environment variable is set, it takes precedence over the corresponding admin-managed value, so setting `JMAP_SERVER_URL` will hide that field from the wizard and lock it in the admin UI.
|
||||
Most deployments are configured through the setup wizard on first launch, then the admin dashboard; those values live in the admin config directory rather than `.env.local`. Environment variables still work, and they suit read-only or immutable infrastructure better. An environment variable always wins over the admin-managed value, so setting `JMAP_SERVER_URL` hides that field from the wizard and locks it in the admin UI.
|
||||
|
||||
All variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. Edit `.env.local`:
|
||||
Nearly all variables are evaluated at runtime, so Docker deployments can be reconfigured without rebuilding. The exceptions are the `NEXT_PUBLIC_*` ones noted below, which Next.js bakes in at build time. Edit `.env.local`:
|
||||
|
||||
```env
|
||||
# Optional – overrides whatever the wizard writes
|
||||
@@ -151,13 +150,28 @@ PORT=3000
|
||||
|
||||
```env
|
||||
OAUTH_ENABLED=true
|
||||
OAUTH_ONLY=true # hide the username/password form entirely
|
||||
OAUTH_CLIENT_ID=webmail
|
||||
OAUTH_CLIENT_SECRET= # optional, for confidential clients
|
||||
OAUTH_CLIENT_SECRET_FILE= # path to a file containing the secret
|
||||
OAUTH_ISSUER_URL= # optional, for external IdPs
|
||||
OAUTH_AUTHORIZE_URL= # override only the user-facing authorize endpoint
|
||||
OAUTH_ALLOW_PRIVATE_ENDPOINTS= # allow discovery to resolve to RFC-1918 addresses
|
||||
```
|
||||
|
||||
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`.
|
||||
Endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. `OAUTH_ALLOW_PRIVATE_ENDPOINTS` is off by default as an SSRF guard. Enable it only for split-DNS deployments where the issuer's public hostname resolves to an internal IP.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Anonymous telemetry</summary>
|
||||
|
||||
```env
|
||||
BULWARK_TELEMETRY=on # opt-in; off by default
|
||||
TELEMETRY_DATA_DIR=./data/telemetry # instance id and consent; mount a volume
|
||||
```
|
||||
|
||||
Off unless you turn it on, in the admin UI, the installer, or here. Heartbeats carry version, platform, bucketed account counts, and feature toggles. No email addresses, hostnames, or IPs. Setting the variable (to either value) locks the choice and disables the admin toggle.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -255,6 +269,25 @@ The split lets you mount the config volume read-only after the setup wizard comp
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Default UI locale</summary>
|
||||
|
||||
The UI language follows each visitor's `Accept-Language` header and their stored preference. `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback used when neither matches a supported locale (default `en`):
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_DEFAULT_LOCALE=de
|
||||
```
|
||||
|
||||
Supported: `ar`, `ca`, `cs`, `da`, `de`, `en`, `es`, `fa`, `fr`, `he`, `hu`, `it`, `ja`, `ko`, `lv`, `nl`, `pl`, `pt`, `ro`, `ru`, `sk`, `tr`, `uk`, `zh`. An unsupported value falls back to `en`.
|
||||
|
||||
Like `NEXT_PUBLIC_BASE_PATH`, this is read at **build time**. To use it with the published Docker image, build your own:
|
||||
|
||||
```bash
|
||||
docker build --build-arg NEXT_PUBLIC_DEFAULT_LOCALE=de -t bulwark-webmail .
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Subpath / reverse proxy mount</summary>
|
||||
|
||||
@@ -271,37 +304,46 @@ Unlike most other variables, `NEXT_PUBLIC_BASE_PATH` is read at **build time** b
|
||||
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/webmail -t bulwark-webmail .
|
||||
```
|
||||
|
||||
Then point your reverse proxy at the container without stripping the prefix - the app expects to receive requests under `/webmail/...` and serves all routes (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, etc.) accordingly.
|
||||
Then point your reverse proxy at the container without stripping the prefix. The app expects requests under `/webmail/...` and serves every route (`/webmail/api/...`, `/webmail/_next/static/...`, `/webmail/sw.js`, and so on) accordingly.
|
||||
|
||||
</details>
|
||||
|
||||
## Keyboard Shortcuts
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| ------------- | ----------------------- |
|
||||
| `j` / `k` | Navigate between emails |
|
||||
| `Enter` / `o` | Open email |
|
||||
| `Esc` | Close / deselect |
|
||||
| `c` | Compose |
|
||||
| `r` / `R` | Reply / Reply all |
|
||||
| `f` | Forward |
|
||||
| `s` | Star |
|
||||
| `e` | Archive |
|
||||
| `#` | Delete |
|
||||
| `/` | Search |
|
||||
| `?` | Show all shortcuts |
|
||||
| Key | Action |
|
||||
| -------------------- | ----------------------- |
|
||||
| `j` `↓` / `k` `↑` | Navigate between emails |
|
||||
| `Enter` / `o` | Open email |
|
||||
| `Esc` | Close / deselect |
|
||||
| `x` | Expand / collapse thread |
|
||||
| `c` | Compose |
|
||||
| `r` / `R` `a` | Reply / Reply all |
|
||||
| `f` | Forward |
|
||||
| `s` | Star |
|
||||
| `e` | Archive |
|
||||
| `#` / `Del` | Delete |
|
||||
| `u` / `Shift`+`I` | Mark unread / read |
|
||||
| `!` | Toggle spam |
|
||||
| `Ctrl`+`A` | Select all |
|
||||
| `Shift`+`G` | Refresh |
|
||||
| `/` | Search |
|
||||
| `?` | Show all shortcuts |
|
||||
|
||||
## Tech Stack
|
||||
In the composer: `Ctrl/Cmd`+`Enter` sends, `Ctrl/Cmd`+`Shift`+`Enter` opens scheduled send, and `t` opens the template picker.
|
||||
|
||||
## Tech stack
|
||||
|
||||
| | |
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| **Framework** | [Next.js 16](https://nextjs.org/) with App Router |
|
||||
| **Framework** | [Next.js 16](https://nextjs.org/) with App Router, React 19 |
|
||||
| **Language** | TypeScript |
|
||||
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/) |
|
||||
| **State** | [Zustand](https://zustand-demo.pmnd.rs/) |
|
||||
| **Protocol** | Custom JMAP client (RFC 8620) |
|
||||
| **Editor** | [Tiptap](https://tiptap.dev/) |
|
||||
| **i18n** | [next-intl](https://next-intl-docs.vercel.app/) |
|
||||
| **Icons** | [Lucide React](https://lucide.dev/) |
|
||||
| **Testing** | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) |
|
||||
|
||||
## Why Stalwart?
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# VNCmail+ — setup & deploy runbook
|
||||
|
||||
VNCmail+ is VNC's fork of [Bulwark](https://github.com/bulwarkmail/webmail), a
|
||||
Next.js (App Router) JMAP webmail client for **Stalwart**. Stalwart is the source
|
||||
of truth; VNCmail+ is the UI. It deploys as a **container on Kubernetes
|
||||
(microk8s)** at `vncmail.sandbox.vnc.de` — see **[deploy/k8s/](deploy/k8s/README.md)**.
|
||||
|
||||
> **License:** AGPL-3.0. Serving a modified VNCmail+ to users over the network
|
||||
> obligates VNC to offer those users the corresponding source. Keeping this fork
|
||||
> public (with a "Source" link in the imprint/UI) satisfies that. Loop in legal
|
||||
> before a public/customer-facing launch if a closed fork is ever desired.
|
||||
|
||||
## Architecture — why a container, not Vercel
|
||||
|
||||
- Bulwark is a **stateful, long-lived server**: it persists settings-sync, admin
|
||||
config/state, and telemetry to a **local data directory** (`/app/data/*`).
|
||||
- **Vercel serverless was tried and dropped** — its filesystem is read-only
|
||||
except `/tmp`, so Bulwark's `mkdir ./data` crashes (`ENOENT /var/task/data`).
|
||||
You cannot point its data dirs at a remote host either (they're POSIX paths,
|
||||
not URLs). Bulwark's native model is a container + persistent volumes.
|
||||
- So VNCmail+ runs as a Docker image with **4 persistent volumes**, exactly
|
||||
like the existing `bulwark.sandbox.vnc.de`.
|
||||
- JMAP calls go through **server-side `/api/*` routes** (`proxy.ts`) → server-to-
|
||||
server to Stalwart, **no browser CORS**. Config is **runtime-read**.
|
||||
|
||||
## Branches (dev-first)
|
||||
|
||||
| Branch | Role |
|
||||
|--------|------|
|
||||
| `main` | **Production.** Only updated by `git merge --ff-only dev`, then an explicit manual promote in CI. No prod environment exists yet — see "CI/CD" below. |
|
||||
| `dev` | Integration + QA — default working branch. Every push auto-builds and auto-deploys to the sandbox (`vncmail.sandbox.vnc.de`). |
|
||||
| `vnc/*`| Feature branches for UI work (branch off `dev`, MR into `dev` — required, gated by CI). |
|
||||
|
||||
All VNC customization lives under `vnc/` (see `vnc/VNC-CHANGES.md`).
|
||||
|
||||
## CI/CD — GitLab (canonical) + ArgoCD GitOps, Vercel-style dev→prod
|
||||
|
||||
Multiple developers work on this repo now. `.gitlab-ci.yml` on
|
||||
[gitlab.vnc.biz](https://gitlab.vnc.biz/gitlab-instance-b9b5cf2f/vncmail-plus)
|
||||
(the canonical remote — GitHub `origin` is a passive mirror, not where CI or
|
||||
deploys happen) builds images and bumps a tag in git; **ArgoCD does the
|
||||
actual deploying** — already installed and idle on the `dev-k8s-1/2/3`
|
||||
cluster, discovered when standing this up. GitLab CI needs zero cluster
|
||||
credentials as a result.
|
||||
|
||||
Two real clusters, confirmed by direct inspection:
|
||||
|
||||
| Cluster | Role | Notes |
|
||||
|---|---|---|
|
||||
| `dev-k8s-1/2/3` | dev/sandbox | ~hours old when set up here. Traefik, metallb, cert-manager (`letsencrypt-staging` issuer only), **ArgoCD already running**. |
|
||||
| `node1/node2/node3` | prod (HA) | Older, rook-ceph+traefik+metallb+cert-manager, but **zero apps and zero ClusterIssuers** — genuinely a clean slate. |
|
||||
|
||||
Neither cluster had a `vncmail` namespace, `vnc-ca` namespace, or `bulwark`
|
||||
ingress — the "live sandbox at vncmail.sandbox.vnc.de" referenced earlier in
|
||||
this doc's history was aspirational (manifests + docs existed, nothing was
|
||||
ever actually applied). The ingress manifests also assumed nginx (`class:
|
||||
public`, an nginx body-size annotation) — fixed to Traefik's real
|
||||
`ingressClassName: traefik` (Traefik has no default body-size cap, so no
|
||||
replacement annotation is needed).
|
||||
|
||||
Flow:
|
||||
|
||||
1. **MR into `dev`** → `verify` stage (typecheck/lint/unit test/build).
|
||||
Required check — no push, no deploy.
|
||||
2. **Merge to `dev`** → `build` pushes one image,
|
||||
`registry.gitlab.vnc.biz/.../vncmail-plus:sha-<sha>`, then `bump-dev`
|
||||
commits that tag into `deploy/k8s/overlays/dev/image-tag/kustomization.yaml`
|
||||
(`[skip ci]`). ArgoCD's `vncmail-dev` Application picks up the git change.
|
||||
3. **Merge to `main`** (fast-forward only, see below) → `bump-prod` points
|
||||
`overlays/prod/image-tag/` at that same tag — **no rebuild**. The actual
|
||||
promotion gate is a **human clicking Sync** on the `vncmail-prod` ArgoCD
|
||||
Application, which is permanently manual-sync (never automated) — that's
|
||||
the Vercel-style "Promote to Production" button, just living in ArgoCD's
|
||||
UI instead of GitLab's.
|
||||
|
||||
### What's left to wire up (one-time, human steps)
|
||||
|
||||
1. **Add the ArgoCD deploy key to GitLab** — Project → Settings → Repository
|
||||
→ Deploy keys → add (read-only is enough):
|
||||
```
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOURjX/Y9zfB785DyLEF1GUq4HhWujrqeXag8oxdMciq argocd@dev-k8s (vncmail-plus read-only)
|
||||
```
|
||||
Until this is added, `vncmail-dev`'s ArgoCD Application (already created,
|
||||
`kubectl -n argocd get application vncmail-dev`) shows a benign
|
||||
`ComparisonError` (SSH handshake failing) — expected, not a bug.
|
||||
2. **Let CI push tag-bumps back to this repo** — either enable "this project
|
||||
can be accessed by CI/CD job tokens from other projects" → actually
|
||||
simpler: Settings → CI/CD → Job token permissions → allow this project's
|
||||
own job token to push to itself, OR create a Project Access Token
|
||||
(`write_repository` scope) and add it as a masked CI/CD variable
|
||||
`GITLAB_PUSH_TOKEN` (the pipeline tries that first, falls back to
|
||||
`CI_JOB_TOKEN`).
|
||||
3. **One-time namespace bootstrap** (CI/ArgoCD deliberately never manage
|
||||
secret contents — see `deploy/k8s/README.md` §3):
|
||||
```bash
|
||||
# against dev-k8s (ArgoCD's CreateNamespace=true will make `vncmail` on
|
||||
# first sync, or create it yourself first — either order works)
|
||||
kubectl create secret docker-registry ghcr-pull -n vncmail ... # or make the GHCR package public
|
||||
cp deploy/k8s/overlays/dev/secret.example.yaml secret.yaml # edit SESSION_SECRET
|
||||
kubectl apply -f secret.yaml
|
||||
```
|
||||
4. **First sync** — ArgoCD UI at `https://argo.devcluster.vnc.de`
|
||||
(username `admin`, password: `kubectl -n argocd get secret
|
||||
argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d`
|
||||
— rotate it after logging in once) → `vncmail-dev` → Sync. Once that's
|
||||
clean, flip `deploy/argocd/vncmail-dev-app.yaml`'s commented-out
|
||||
`automated:` block on and re-apply, so dev auto-syncs on every push from
|
||||
then on.
|
||||
5. **Production** (later, deliberately not wired yet): decide a real
|
||||
hostname, stand up prod Stalwart, register `node1-3` as an ArgoCD-managed
|
||||
cluster, apply `deploy/argocd/vncmail-prod-app.yaml`, fill in real
|
||||
`overlays/prod` values, create a real ClusterIssuer on `node1-3` (there
|
||||
isn't one today), then click Sync once — deliberately not before.
|
||||
|
||||
Historical note: the old `-dev`/`-beta` GHCR image-name split
|
||||
(`.github/workflows/docker-publish.yml`) is retired by this — one image name
|
||||
now, environment lives only in the tag.
|
||||
|
||||
## Deploy (Kubernetes / microk8s)
|
||||
|
||||
Full runbook: **[deploy/k8s/README.md](deploy/k8s/README.md)**. In short:
|
||||
|
||||
1. CI (above) builds and pushes the image, one name/many tags, to GitLab's
|
||||
registry.
|
||||
2. `kubectl apply -k deploy/k8s/overlays/dev` (or `overlays/prod`, once real)
|
||||
— base manifests (namespace, 4 PVCs, deployment, service, ingress) live in
|
||||
`deploy/k8s/base/`, environment differences (namespace, hostname, replica
|
||||
count) are overlay patches.
|
||||
3. DNS + a `secret.yaml` (from the overlay's `secret.example.yaml`, gitignored,
|
||||
created once by hand — CI never manages secret contents) + an image-pull
|
||||
secret are the remaining manual, human, one-time steps per environment.
|
||||
|
||||
Runs alongside the existing `bulwark.sandbox.vnc.de`. Match your cluster's
|
||||
StorageClass / IngressClass / cert issuer to bulwark's (see the runbook).
|
||||
|
||||
## Deploy workflow (dev-first — ALWAYS)
|
||||
|
||||
Same flow as every other VNC/SRC repo, now enforced structurally by CI rather
|
||||
than by convention:
|
||||
|
||||
1. Work on `dev` (or `vnc/*` → MR into `dev`, CI-gated). Merge → auto-builds
|
||||
and auto-deploys to `vncmail.sandbox.vnc.de`. QA there.
|
||||
2. **Promote to production only on explicit go-live** — merge `dev` → `main`:
|
||||
```bash
|
||||
git log dev..main # MUST be empty — main must have nothing dev lacks (else prod would revert)
|
||||
git checkout main && git merge --ff-only dev
|
||||
git push gitlab main # never GitHub — opens the manual `promote` job, does not run it
|
||||
git checkout dev
|
||||
```
|
||||
Then click `promote` in the GitLab pipeline UI (protected `production`
|
||||
environment — requires the right role) once prod actually exists (see
|
||||
"CI/CD" above). Never push straight to `main`. Never let a dev→main merge
|
||||
silently revert prod.
|
||||
|
||||
## Syncing upstream (Bulwark releases)
|
||||
|
||||
Bring upstream into `dev` (NOT main), integrate + QA on the dev image, then promote as above:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git checkout dev && git merge upstream/main # resolve conflicts via vnc/VNC-CHANGES.md; QA on preview
|
||||
```
|
||||
|
||||
## Auth
|
||||
|
||||
Basic auth via Stalwart is the default — users sign in with their
|
||||
`@sandbox.vnc.de` address + password; VNCmail+ authenticates them over JMAP. No
|
||||
extra config. (SSO via vncdirectory/OIDC is a later option — see
|
||||
`vnc/vercel.env.template`.)
|
||||
@@ -97,9 +97,12 @@ export default function CalendarPage() {
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||
removeCalendar, clearCalendarEvents,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
newEventPrefill, setNewEventPrefill,
|
||||
} = useCalendarStore();
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
const calendarTasksEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarTasksEnabled'));
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks: userTasksEnabled, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
const enableCalendarTasks = userTasksEnabled && calendarTasksEnabled;
|
||||
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
|
||||
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
|
||||
const removeSharedCalendarColor = useSettingsStore((s) => s.removeSharedCalendarColor);
|
||||
@@ -462,6 +465,19 @@ export default function CalendarPage() {
|
||||
setShowEventModal(true);
|
||||
}, [selectedDate, setSelectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!newEventPrefill) return;
|
||||
setEditEvent(null);
|
||||
if (newEventPrefill.date) {
|
||||
const d = new Date(newEventPrefill.date);
|
||||
if (!isNaN(d.getTime())) {
|
||||
setDefaultModalDate(d);
|
||||
setSelectedDate(d);
|
||||
}
|
||||
}
|
||||
setShowEventModal(true);
|
||||
}, [newEventPrefill, setSelectedDate]);
|
||||
|
||||
const openEditModal = useCallback((event: CalendarEvent) => {
|
||||
setEditEvent(event);
|
||||
setDefaultModalDate(undefined);
|
||||
@@ -1196,6 +1212,9 @@ export default function CalendarPage() {
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onContextMenuEmpty={handleContextMenuEmpty}
|
||||
onCreateAtTime={openCreateModal}
|
||||
onEditEvent={openEditModal}
|
||||
onDeleteEvent={handleDeleteContextMenu}
|
||||
onDuplicateEvent={handleDuplicateContextMenu}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
isMobile={isMobile}
|
||||
pendingPreview={pendingPreview}
|
||||
@@ -1488,10 +1507,14 @@ export default function CalendarPage() {
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }}
|
||||
onPreviewChange={setPendingPreview}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={false}
|
||||
prefillTitle={editEvent ? undefined : newEventPrefill?.title}
|
||||
prefillDescription={editEvent ? undefined : newEventPrefill?.description}
|
||||
prefillParticipants={editEvent ? undefined : newEventPrefill?.participants}
|
||||
prefillDate={editEvent ? undefined : newEventPrefill?.date}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1618,9 +1641,13 @@ export default function CalendarPage() {
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={true}
|
||||
prefillTitle={editEvent ? undefined : newEventPrefill?.title}
|
||||
prefillDescription={editEvent ? undefined : newEventPrefill?.description}
|
||||
prefillParticipants={editEvent ? undefined : newEventPrefill?.participants}
|
||||
prefillDate={editEvent ? undefined : newEventPrefill?.date}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
@@ -411,6 +412,31 @@ export default function FilesPage() {
|
||||
await shareResource(id, principalId, rights);
|
||||
}, [shareResource]);
|
||||
|
||||
const handleSendAsAttachment = useCallback((names: string[]) => {
|
||||
const store = useFileStore.getState();
|
||||
const fileAtts = names
|
||||
.map((name) => {
|
||||
const r = store.resources.find((res) => res.name === name);
|
||||
if (!r || r.isDirectory || !r.blobId) return null;
|
||||
return {
|
||||
blobId: r.blobId,
|
||||
name: r.name,
|
||||
type: r.contentType || "application/octet-stream",
|
||||
size: r.contentLength,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{ blobId: string; name: string; type: string; size: number }>;
|
||||
|
||||
if (fileAtts.length === 0) return;
|
||||
|
||||
useProTabStore.getState().openComposeTab({
|
||||
sessionId: Date.now(),
|
||||
mode: "compose",
|
||||
replyTo: { attachments: fileAtts },
|
||||
title: fileAtts.length === 1 ? fileAtts[0].name : `${fileAtts.length} attachments`,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Pro shell only: all connected accounts are equal top-level entries at
|
||||
// the root. The root path "/" itself is a cross-account picker - no
|
||||
// account's files are shown until the user enters one.
|
||||
@@ -554,6 +580,7 @@ export default function FilesPage() {
|
||||
ownAccountId={filesAccountId}
|
||||
sharingEnabled={sharingEnabled}
|
||||
onShare={handleShare}
|
||||
onSendAsAttachment={handleSendAsAttachment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RateLimitToastProvider } from "@/components/providers/rate-limit-toast-
|
||||
import { TourProvider } from "@/components/tour/tour-provider";
|
||||
import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-launch-handler-provider";
|
||||
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
|
||||
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
|
||||
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
|
||||
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
@@ -39,6 +40,7 @@ export default async function LocaleLayout({
|
||||
<TourProvider>
|
||||
<ProtocolLaunchHandlerProvider>
|
||||
<ProInterfaceRedirect />
|
||||
<ImpersonationReconciler />
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<PluginConsentDialog />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { resolveThemeLogo } from "@/lib/theme-logo";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { apiFetch, getPathPrefix, toRouterPath, withBasePath } from "@/lib/browser-navigation";
|
||||
@@ -133,8 +134,20 @@ export default function LoginPage() {
|
||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginLogoLightUrlIsCustom, loginLogoDarkUrlIsCustom, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const { activeThemeId, installedThemes } = useThemeStore(useShallow((s) => ({ activeThemeId: s.activeThemeId, installedThemes: s.installedThemes })));
|
||||
// Active theme may carry its own brand logo (VNClagoon wordmark, SRC mark);
|
||||
// an explicitly-configured logo (Branding tab / LOGIN_LOGO_*_URL) wins
|
||||
// over that, falling back to the theme's logo only when nothing was set.
|
||||
const effLoginLogo = resolveThemeLogo(
|
||||
installedThemes,
|
||||
activeThemeId,
|
||||
resolvedTheme === 'dark',
|
||||
loginLogoLightUrl,
|
||||
loginLogoDarkUrl,
|
||||
loginLogoLightUrlIsCustom || loginLogoDarkUrlIsCustom,
|
||||
);
|
||||
|
||||
// Login logo sizing: when a max height/width is configured, drop the fixed
|
||||
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
|
||||
@@ -740,7 +753,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-12 pb-4 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||
<img
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
src={withBasePath(effLoginLogo)}
|
||||
alt={appName}
|
||||
className="max-w-20 max-h-20 object-contain"
|
||||
/>
|
||||
@@ -890,7 +903,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-10 pb-6 text-center">
|
||||
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
|
||||
<img
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
src={withBasePath(effLoginLogo)}
|
||||
alt={appName}
|
||||
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
|
||||
style={loginLogoStyle}
|
||||
|
||||
+245
-36
@@ -32,8 +32,10 @@ import { usePromptDialog } from "@/hooks/use-prompt-dialog";
|
||||
import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
import { isElectronShell, showElectronNotification } from "@/lib/electron-bridge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
SidebarErrorFallback,
|
||||
@@ -61,8 +63,11 @@ import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity";
|
||||
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
|
||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
|
||||
import { AiAskButton } from "@/components/ai/ai-ask-button";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
@@ -76,6 +81,7 @@ import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
||||
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||
|
||||
@@ -119,6 +125,7 @@ export default function Home() {
|
||||
const initialMailLoadClientRef = useRef<object | null>(null);
|
||||
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
|
||||
const { identities } = useIdentityStore();
|
||||
const multiAccountIdentities = useProMultiAccountIdentities();
|
||||
useIdentitySync();
|
||||
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
|
||||
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
|
||||
@@ -280,6 +287,7 @@ export default function Home() {
|
||||
toggleStar,
|
||||
setEmailKeywordsLocal,
|
||||
moveToMailbox,
|
||||
moveToMailboxCrossAware,
|
||||
moveThreadToMailbox,
|
||||
searchEmails,
|
||||
searchQuery,
|
||||
@@ -781,7 +789,15 @@ export default function Home() {
|
||||
// This makes the Pro composer behave like Thunderbird's pop-out window.
|
||||
useEffect(() => {
|
||||
if (!isEmbedded || !showComposer) return;
|
||||
const replyTo = selectedEmail ? {
|
||||
// pendingDraft.replyTo, when set, was built by the opener (e.g.
|
||||
// handleForwardAsAttachment) with intent that must survive the hop into
|
||||
// the Pro tab - mirrors the same precedence the non-embedded render path
|
||||
// uses just below (`replyTo={pendingDraft !== null ? pendingDraft.replyTo
|
||||
// : ...}`). Building fresh from selectedEmail unconditionally here would
|
||||
// silently drop that intent (e.g. the synthetic message/rfc822
|
||||
// attachment "Forward as attachment" stages), falling back to a normal
|
||||
// quoted forward instead.
|
||||
const replyTo = pendingDraft?.replyTo ?? (selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
to: selectedEmail.to,
|
||||
@@ -797,7 +813,7 @@ export default function Home() {
|
||||
quoteHeaderHtml: composerQuoteHeader?.html,
|
||||
quoteHeaderText: composerQuoteHeader?.text,
|
||||
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
||||
} : undefined;
|
||||
} : undefined);
|
||||
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
const baseSubject = (pendingDraft?.subject?.trim() || selectedEmail?.subject?.trim()) ?? '';
|
||||
@@ -1048,7 +1064,67 @@ export default function Home() {
|
||||
debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`);
|
||||
}
|
||||
|
||||
// CATCH-UP for the desktop shell's local search index. The index's normal
|
||||
// trigger is a push StateChange (stores/email-store.ts's handleStateChange),
|
||||
// but nothing was pushed while the app was closed - and the polling
|
||||
// transport has no signal for contacts or files at all (client.ts's
|
||||
// buildStatePollingRequest covers Mailbox/Email/Calendar/CalendarEvent/
|
||||
// SieveScript only). So backfill a bounded recent window once per session,
|
||||
// after push is wired. Fire-and-forget; a no-op outside Electron.
|
||||
//
|
||||
// RETRIED, not one-shot. The first attempt races the login flow's own
|
||||
// POST /api/auth/stalwart-context (stores/auth-store.ts's
|
||||
// syncStalwartAuthContext) - if the index route is hit before that cookie
|
||||
// is minted it 401s, and a single silent attempt would leave the index
|
||||
// empty until the next app restart with nothing telling anyone why (this
|
||||
// exact silence hid the packaged app's missing-SESSION_SECRET bug against
|
||||
// a real mailbox). requestIndex() already distinguishes the permanent
|
||||
// cases (404/503 -> unavailable) from the retryable ones, so retrying is
|
||||
// cheap and self-limiting.
|
||||
const catchUpRetryDelaysMs = [4000, 20000, 60000];
|
||||
let catchUpCancelled = false;
|
||||
let catchUpTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const runCatchUp = async (attempt: number) => {
|
||||
if (catchUpCancelled) return;
|
||||
try {
|
||||
const { catchUpIndex, getRetentionDays } = await import('@/lib/mail-index-client');
|
||||
const result = await catchUpIndex(
|
||||
useAccountStore.getState().getActiveAccount()?.cookieSlot,
|
||||
getRetentionDays(),
|
||||
);
|
||||
if (!result.ok && !result.unavailable && attempt + 1 < catchUpRetryDelaysMs.length) {
|
||||
catchUpTimer = setTimeout(() => void runCatchUp(attempt + 1), catchUpRetryDelaysMs[attempt + 1]);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* the index is optional */
|
||||
}
|
||||
// The offline REPLICA's launch catch-up. Same reasoning as the index's,
|
||||
// plus one of its own: a `/changes` cursor cannot tell us about anything
|
||||
// that happened while the process was dead, so a cycle at launch is what
|
||||
// drains the backlog. One cycle is bounded, so a first sync of a large
|
||||
// mailbox needs several - `chainSync` runs them with a hard cap.
|
||||
//
|
||||
// Sequenced AFTER the index (including its retries) rather than in
|
||||
// parallel: both write the same SQLite file, and although `busy_timeout`
|
||||
// makes concurrent writers safe, there is no reason to spend the
|
||||
// contention during first paint.
|
||||
if (catchUpCancelled) return;
|
||||
try {
|
||||
const { chainSync } = await import('@/lib/offline-replica-client');
|
||||
await chainSync({ slot: useAccountStore.getState().getActiveAccount()?.cookieSlot });
|
||||
} catch {
|
||||
/* the replica is optional */
|
||||
}
|
||||
};
|
||||
// Deliberately after the initial mailbox fetch settles: the catch-up is a
|
||||
// background nicety and must not compete with first paint.
|
||||
catchUpTimer = setTimeout(() => void runCatchUp(0), catchUpRetryDelaysMs[0]);
|
||||
|
||||
return () => {
|
||||
catchUpCancelled = true;
|
||||
clearTimeout(catchUpTimer);
|
||||
cleanups.forEach((fn) => fn());
|
||||
};
|
||||
}, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]);
|
||||
@@ -1172,13 +1248,34 @@ export default function Home() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedEmail?.id, isScheduledView]);
|
||||
|
||||
// Handle new email notifications - play sound
|
||||
// Handle new email notifications - play sound, and (in the Electron shell)
|
||||
// fire a native OS notification. This effect is the transport-agnostic
|
||||
// "genuinely new unread mail arrived" signal - stores/email-store.ts's
|
||||
// refreshCurrentMailbox() already filters out sends/moves/drafts and only
|
||||
// sets newEmailNotification for a real new top-of-inbox message, and it
|
||||
// fires identically whether the underlying JMAP StateChange arrived over
|
||||
// the WebSocket push connection (lib/jmap/client.ts's connectWebSocket),
|
||||
// SSE, or the polling fallback - no need to duplicate this per transport.
|
||||
useEffect(() => {
|
||||
if (newEmailNotification) {
|
||||
const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState();
|
||||
if (emailNotificationsEnabled && emailNotificationSound) {
|
||||
playNotificationSound(notificationSoundChoice);
|
||||
}
|
||||
if (emailNotificationsEnabled && isElectronShell()) {
|
||||
// Same fallback text public/sw.js's push handler already uses for
|
||||
// its (also un-translated) system notifications - a native OS
|
||||
// notification body isn't run through next-intl either way, so
|
||||
// matching that existing precedent instead of introducing new
|
||||
// translation keys for a rarely-hit fallback.
|
||||
const sender = newEmailNotification.from?.[0];
|
||||
const senderName = sender?.name || sender?.email || 'New mail';
|
||||
const body = newEmailNotification.subject || newEmailNotification.preview || '(no subject)';
|
||||
void showElectronNotification(senderName, {
|
||||
body,
|
||||
tag: `bulwark-mail:${newEmailNotification.id}`,
|
||||
});
|
||||
}
|
||||
debug.log('email', 'New email received:', newEmailNotification.subject);
|
||||
clearNewEmailNotification();
|
||||
}
|
||||
@@ -1222,6 +1319,14 @@ export default function Home() {
|
||||
|
||||
const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt });
|
||||
setShowComposer(false);
|
||||
if (result.filingError) {
|
||||
// The mail went out, but a post-send step (filing to Sent /
|
||||
// removing the old draft) was rejected - warn instead of staying
|
||||
// silent, so a stale draft row is not mistaken for a failed send
|
||||
// and re-sent (#592).
|
||||
const toastInstance = (await import('sonner')).toast;
|
||||
toastInstance.warning(t('email_composer.send_filing_warning'));
|
||||
}
|
||||
if (result.scheduled) {
|
||||
await refreshScheduledMetadata(client);
|
||||
if (isScheduledView) await fetchScheduledEmails(client);
|
||||
@@ -1401,11 +1506,15 @@ export default function Home() {
|
||||
? draft.bodyValues[draftHtmlPart.partId].value
|
||||
: undefined;
|
||||
|
||||
// Try to find the identity that matches the draft's from address to preserve it
|
||||
const draftFromEmail = draft.from?.[0]?.email;
|
||||
const matchedIdentity = draftFromEmail
|
||||
? identities.find(id => id.email === draftFromEmail)
|
||||
: null;
|
||||
// Restore the identity the draft was composed with. Match the saved From
|
||||
// (name + address) against the same list the composer renders — the flat
|
||||
// cross-account list when multi-account is on — so a draft from a non-active
|
||||
// account, or one of two identities sharing an address, is restored rather
|
||||
// than reset to the default.
|
||||
const composerIdentities = multiAccountIdentities.enabled
|
||||
? multiAccountIdentities.allIdentities
|
||||
: identities;
|
||||
const matchedIdentityId = findDraftIdentityId(composerIdentities, draft.from?.[0]);
|
||||
|
||||
// Increment session ID to force the composer to remount with fresh state,
|
||||
// even if it was already open (e.g. right-clicking a draft while composing).
|
||||
@@ -1418,7 +1527,7 @@ export default function Home() {
|
||||
body: htmlBody || bodyText,
|
||||
showCc: (draft.cc?.length || 0) > 0,
|
||||
showBcc: (draft.bcc?.length || 0) > 0,
|
||||
selectedIdentityId: matchedIdentity?.id ?? null,
|
||||
selectedIdentityId: matchedIdentityId,
|
||||
subAddressTag: '',
|
||||
mode: 'compose',
|
||||
draftId: draft.id,
|
||||
@@ -1524,6 +1633,77 @@ export default function Home() {
|
||||
if (isMobile) setActiveView('viewer');
|
||||
};
|
||||
|
||||
// Forward the original message as a message/rfc822 attachment instead of
|
||||
// inline-quoted text - e.g. for reporting spam to an upstream gateway
|
||||
// that expects the raw original as an attachment, or preserving exact
|
||||
// formatting/headers the recipient needs to see untouched. Reuses the
|
||||
// same attachment-carry-forward mechanism native Forward already uses
|
||||
// for a forwarded message's own attachments (see the `attachments`
|
||||
// useState initializer in email-composer.tsx) - we just add one more
|
||||
// synthetic entry representing the whole original message, referenced
|
||||
// by its existing blobId (no re-fetch/re-upload needed - JMAP blobs are
|
||||
// account-scoped, not per-email). Skips prepareComposerQuoteHeader
|
||||
// entirely, so the body starts blank instead of quoting the original.
|
||||
// Takes an explicit `email` (defaulting to selectedEmail), same pattern
|
||||
// handleDelete uses just below, rather than always reading selectedEmail
|
||||
// from this closure - callers that just called selectEmail(email) and
|
||||
// invoke this synchronously in the same tick would otherwise see the
|
||||
// PRE-update value (the Zustand store updates immediately, but this
|
||||
// render's selectedEmail closure doesn't until the next render),
|
||||
// forwarding the previously selected message or no-op'ing on an
|
||||
// unselected row. See the list context-menu wiring below.
|
||||
const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => {
|
||||
if (!email) return;
|
||||
// Same filename options "Export as .eml" uses (see emailFilenameOptions
|
||||
// in email-viewer.tsx), so the two actions produce consistent filenames
|
||||
// for the same message rather than the synthetic attachment silently
|
||||
// ignoring the user's configured naming template.
|
||||
const {
|
||||
emailDownloadTemplate,
|
||||
filenameSpaceReplacement,
|
||||
filenameLowercase,
|
||||
filenameStripDiacritics,
|
||||
filenameCollapseSeparators,
|
||||
} = useSettingsStore.getState();
|
||||
const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), {
|
||||
template: emailDownloadTemplate,
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
});
|
||||
if (!payload) return;
|
||||
|
||||
const ok = await emailHooks.onBeforeForward.intercept({
|
||||
originalEmailId: email.id,
|
||||
originalEmail: emailToReadView(email),
|
||||
mode: 'forward' as const,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
startFreshComposerSession();
|
||||
setPendingDraft({
|
||||
to: "",
|
||||
cc: "",
|
||||
bcc: "",
|
||||
subject: payload.subject,
|
||||
body: "",
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: null,
|
||||
subAddressTag: "",
|
||||
mode: "forward",
|
||||
draftId: null,
|
||||
replyTo: {
|
||||
subject: email.subject,
|
||||
attachments: [payload.attachment],
|
||||
},
|
||||
});
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
};
|
||||
|
||||
const handleDelete = async (emailToDelete: Email | null = selectedEmail) => {
|
||||
if (!client || !emailToDelete) return;
|
||||
|
||||
@@ -1738,7 +1918,7 @@ export default function Home() {
|
||||
keywords['$pinned'] = true;
|
||||
}
|
||||
|
||||
// Same unified-view routing as color tags: write to the email's own
|
||||
// Same unified-view routing as tags: write to the email's own
|
||||
// account via the login it is reachable through. (#281)
|
||||
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
|
||||
const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined;
|
||||
@@ -1762,31 +1942,35 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetColorTag = async (emailId: string, color: string | null) => {
|
||||
const handleSetTag = async (emailId: string, tagId: string | null) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
// Remove any existing label/color tags
|
||||
// Remove any existing tag keywords
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const keywords = { ...email.keywords };
|
||||
|
||||
if (color === null) {
|
||||
// Remove all label/color tags
|
||||
if (tagId === null) {
|
||||
// Remove all tag keywords
|
||||
Object.keys(keywords).forEach(key => {
|
||||
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
||||
if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const jmapKey = `$label:${color}`;
|
||||
if (keywords[jmapKey]) {
|
||||
// Toggle off if already active
|
||||
keywords[jmapKey] = false;
|
||||
// Both prefixes name the same tag when read, so taking one off has to
|
||||
// clear whichever spellings are actually set.
|
||||
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
|
||||
.filter(key => keywords[key]);
|
||||
if (activeKeys.length > 0) {
|
||||
activeKeys.forEach(key => {
|
||||
keywords[key] = false;
|
||||
});
|
||||
} else {
|
||||
// Add the tag without disturbing others
|
||||
keywords[jmapKey] = true;
|
||||
keywords[KEYWORD_PREFIX + tagId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1812,7 +1996,7 @@ export default function Home() {
|
||||
// Refresh tag counts
|
||||
fetchTagCounts(client);
|
||||
} catch (error) {
|
||||
console.error("Failed to set color tag:", error);
|
||||
console.error("Failed to set tag:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2385,8 +2569,20 @@ export default function Home() {
|
||||
const handleQuickReply = async (body: string) => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
const sender = selectedEmail.from?.[0];
|
||||
if (!sender?.email) {
|
||||
// Quick reply follows the same addressing rules as the composer: Reply-To
|
||||
// over From, and for our own messages in a thread the original recipients
|
||||
// instead of ourselves (#703).
|
||||
const ownIdentityEmails = identities.map(i => i.email).filter(Boolean);
|
||||
const replySource = {
|
||||
from: selectedEmail.from,
|
||||
replyToAddresses: selectedEmail.replyTo,
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
};
|
||||
const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to
|
||||
.map(r => r.email)
|
||||
.filter((email): email is string => Boolean(email));
|
||||
if (recipients.length === 0) {
|
||||
throw new Error("No sender email found");
|
||||
}
|
||||
|
||||
@@ -2395,14 +2591,21 @@ export default function Home() {
|
||||
|
||||
// Decide the sending identity and (for domain-catch-all) an optional
|
||||
// header From override that matches the address the message was sent to.
|
||||
// Our own message keeps the identity it was sent from - the recipients are
|
||||
// the other party, so resolving from them would send as their address.
|
||||
// When the setting is off, fall through to primary-identity behavior.
|
||||
const resolved = autoSelectReplyIdentity
|
||||
? resolveReplyFrom(identities, {
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
})
|
||||
const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails)
|
||||
? findDraftIdentityId(identities, selectedEmail.from?.[0])
|
||||
: null;
|
||||
const resolved: ReplyFromResolution | null = !autoSelectReplyIdentity
|
||||
? null
|
||||
: selfSentIdentityId
|
||||
? { identityId: selfSentIdentityId }
|
||||
: resolveReplyFrom(identities, {
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
bcc: selectedEmail.bcc,
|
||||
});
|
||||
const sendingIdentity = resolved
|
||||
? (identities.find((i) => i.id === resolved.identityId) || primaryIdentity)
|
||||
: primaryIdentity;
|
||||
@@ -2449,7 +2652,7 @@ export default function Home() {
|
||||
// Send reply with just the body text
|
||||
const result = await sendEmail(
|
||||
client,
|
||||
[sender.email],
|
||||
recipients,
|
||||
buildReplySubject(selectedEmail.subject || "(no subject)", t('email_composer.prefix.reply')),
|
||||
finalBody,
|
||||
undefined,
|
||||
@@ -2984,6 +3187,7 @@ export default function Home() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<AiAskButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3191,6 +3395,10 @@ export default function Home() {
|
||||
selectEmail(email);
|
||||
handleForward();
|
||||
}}
|
||||
onForwardAsAttachment={(email) => {
|
||||
selectEmail(email);
|
||||
handleForwardAsAttachment(email);
|
||||
}}
|
||||
onMarkAsRead={async (email, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, email.id, read);
|
||||
@@ -3210,12 +3418,12 @@ export default function Home() {
|
||||
onArchive={async (email) => {
|
||||
await handleArchive(email);
|
||||
}}
|
||||
onSetColorTag={(emailId, color) => {
|
||||
handleSetColorTag(emailId, color);
|
||||
onSetTag={(emailId, color) => {
|
||||
handleSetTag(emailId, color);
|
||||
}}
|
||||
onMoveToMailbox={async (emailId, mailboxId) => {
|
||||
if (client) {
|
||||
await moveToMailbox(client, emailId, mailboxId);
|
||||
await moveToMailboxCrossAware(client, emailId, mailboxId);
|
||||
}
|
||||
}}
|
||||
onMarkAsSpam={async (email) => {
|
||||
@@ -3421,6 +3629,7 @@ export default function Home() {
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onForwardAsAttachment={handleForwardAsAttachment}
|
||||
onDelete={() => {
|
||||
// Deleting the open message returns to the list (Gmail-style),
|
||||
// not the next email — unless the user turned the setting off.
|
||||
@@ -3434,7 +3643,7 @@ export default function Home() {
|
||||
}}
|
||||
onArchive={() => handleArchive()}
|
||||
onToggleStar={handleToggleStar}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onSetTag={handleSetTag}
|
||||
onMarkAsSpam={() => handleMarkAsSpam()}
|
||||
onUndoSpam={() => handleUndoSpam()}
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
@@ -3485,7 +3694,7 @@ export default function Home() {
|
||||
selectedMailbox={selectedMailbox}
|
||||
onMoveToMailbox={async (mailboxId) => {
|
||||
if (client && selectedEmail) {
|
||||
await moveToMailbox(client, selectedEmail.id, mailboxId);
|
||||
await moveToMailboxCrossAware(client, selectedEmail.id, mailboxId);
|
||||
}
|
||||
}}
|
||||
className={isMobile ? "flex-1" : undefined}
|
||||
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
Bug,
|
||||
SwatchBook,
|
||||
Download,
|
||||
Sparkles,
|
||||
Upload,
|
||||
Share2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -45,6 +48,7 @@ import { LayoutSettings } from '@/components/settings/layout-settings';
|
||||
import { LanguageSettings } from '@/components/settings/language-settings';
|
||||
import { ReadingSettings } from '@/components/settings/reading-settings';
|
||||
import { ComposingSettings } from '@/components/settings/composing-settings';
|
||||
import { SignatureSettings } from '@/components/settings/signature-settings';
|
||||
import { ContentSendersSettings } from '@/components/settings/content-senders-settings';
|
||||
import { AccountSettings } from '@/components/settings/account-settings';
|
||||
import { IdentitySettings } from '@/components/settings/identity-settings';
|
||||
@@ -66,8 +70,11 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings
|
||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||
import { PluginsSettings } from '@/components/settings/plugins-settings';
|
||||
import { AiAssistantSettings } from '@/components/settings/ai-assistant-settings';
|
||||
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
|
||||
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { ImportSettings } from '@/components/settings/import-settings';
|
||||
import { SharingSettings } from '@/components/settings/sharing-settings';
|
||||
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
@@ -96,6 +103,7 @@ type Tab =
|
||||
| 'composing'
|
||||
| 'downloads'
|
||||
| 'identities'
|
||||
| 'signatures'
|
||||
| 'vacation'
|
||||
| 'filters'
|
||||
| 'templates'
|
||||
@@ -111,6 +119,9 @@ type Tab =
|
||||
| 'about_data'
|
||||
| 'themes'
|
||||
| 'plugins'
|
||||
| 'import'
|
||||
| 'sharing'
|
||||
| 'ai_assistant'
|
||||
| 'debug';
|
||||
|
||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
||||
@@ -138,6 +149,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
composing: PenLine,
|
||||
downloads: Download,
|
||||
identities: UserPen,
|
||||
signatures: PenLine,
|
||||
vacation: PalmtreeIcon,
|
||||
filters: Filter,
|
||||
templates: FileText,
|
||||
@@ -153,6 +165,9 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
about_data: Info,
|
||||
themes: SwatchBook,
|
||||
plugins: Puzzle,
|
||||
import: Upload,
|
||||
sharing: Share2,
|
||||
ai_assistant: Sparkles,
|
||||
debug: Bug,
|
||||
};
|
||||
|
||||
@@ -215,6 +230,7 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
],
|
||||
downloads: ['settings.downloads'],
|
||||
identities: ['settings.identities'],
|
||||
signatures: ['signatures'],
|
||||
vacation: ['settings.vacation'],
|
||||
filters: ['settings.filters'],
|
||||
templates: ['settings.templates'],
|
||||
@@ -234,6 +250,9 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
about_data: ['settings.advanced'],
|
||||
themes: [],
|
||||
plugins: [],
|
||||
ai_assistant: [],
|
||||
import: ['settings.importer'],
|
||||
sharing: ['sharing'],
|
||||
debug: ['settings.advanced'],
|
||||
};
|
||||
|
||||
@@ -249,6 +268,7 @@ const tabKeywords: Record<Tab, string> = {
|
||||
composing: 'editor signature plain text reply forward draft compose',
|
||||
downloads: 'download filename template eml attachment save export',
|
||||
identities: 'from address signature email',
|
||||
signatures: 'signature rich text html editor',
|
||||
vacation: 'auto reply away out of office holiday responder',
|
||||
filters: 'sieve rules block junk forward',
|
||||
templates: 'snippet quick reply',
|
||||
@@ -264,6 +284,9 @@ const tabKeywords: Record<Tab, string> = {
|
||||
about_data: 'export import storage quota privacy backup',
|
||||
themes: 'custom theme css skin appearance',
|
||||
plugins: 'extensions addons',
|
||||
ai_assistant: 'assistant ask model llm ollama chatbot',
|
||||
import: 'import email eml zip tgz mbox csv vcard contacts',
|
||||
sharing: 'share shared folder calendar address book permission',
|
||||
debug: 'logs developer console diagnostic',
|
||||
};
|
||||
|
||||
@@ -613,6 +636,7 @@ export default function SettingsPage() {
|
||||
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' },
|
||||
{ id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' },
|
||||
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
|
||||
{ id: 'sharing', label: t('tabs.sharing'), icon: tabIcons.sharing, group: 'general' },
|
||||
{ id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' },
|
||||
|
||||
// Appearance
|
||||
@@ -625,10 +649,12 @@ export default function SettingsPage() {
|
||||
{ id: 'composing', label: t('tabs.composing'), icon: tabIcons.composing, group: 'mail' },
|
||||
{ id: 'downloads', label: t('tabs.downloads'), icon: tabIcons.downloads, group: 'mail' },
|
||||
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'mail' },
|
||||
{ id: 'signatures', label: t('tabs.signatures'), icon: tabIcons.signatures, group: 'mail' },
|
||||
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'mail' as TabGroup }] : []),
|
||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'mail' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('templatesEnabled') ? [{ id: 'templates' as Tab, label: t('tabs.templates'), icon: tabIcons.templates, group: 'mail' as TabGroup }] : []),
|
||||
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'mail' },
|
||||
{ id: 'import', label: t('tabs.import'), icon: tabIcons.import, group: 'mail' },
|
||||
...(isFeatureEnabled('customKeywordsEnabled') ? [{ id: 'keywords' as Tab, label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'mail' as TabGroup }] : []),
|
||||
|
||||
// Privacy & Security
|
||||
@@ -652,6 +678,7 @@ export default function SettingsPage() {
|
||||
// Advanced
|
||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
|
||||
...(isFeatureEnabled('pluginsEnabled') ? [{ id: 'plugins' as Tab, label: 'Plugins', icon: tabIcons.plugins, group: 'advanced' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('aiAssistantEnabled') ? [{ id: 'ai_assistant' as Tab, label: 'AI Assistant', icon: tabIcons.ai_assistant, group: 'advanced' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('debugModeEnabled') ? [{ id: 'debug' as Tab, label: t('tabs.debug'), icon: tabIcons.debug, group: 'advanced' as TabGroup }] : []),
|
||||
];
|
||||
|
||||
@@ -754,10 +781,13 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'composing' && <ComposingSettings />}
|
||||
{effectiveActiveTab === 'downloads' && <DownloadsSettings />}
|
||||
{effectiveActiveTab === 'identities' && <IdentitySettings />}
|
||||
{effectiveActiveTab === 'signatures' && <SignatureSettings />}
|
||||
{effectiveActiveTab === 'vacation' && <VacationSettings />}
|
||||
{effectiveActiveTab === 'filters' && <FilterSettings />}
|
||||
{effectiveActiveTab === 'templates' && <TemplateSettings />}
|
||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||
{effectiveActiveTab === 'import' && <ImportSettings />}
|
||||
{effectiveActiveTab === 'sharing' && <SharingSettings />}
|
||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||
@@ -777,6 +807,7 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'about_data' && <AboutDataSettings />}
|
||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||
{effectiveActiveTab === 'ai_assistant' && <AiAssistantSettings />}
|
||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||
{effectiveActiveTab.startsWith('plugin:') && (
|
||||
<PluginIframeSlot
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, X, ArrowRight } from 'lucide-react';
|
||||
import type { AiConsoleConfig, AiClass } from '@/lib/ai/types';
|
||||
import { DEFAULT_AI_CONSOLE_CONFIG } from '@/lib/ai/types';
|
||||
import type { AiEntitlementState, MeteringEntry } from '@/lib/ai/entitlement';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAdminTabStore } from '@/stores/admin-tab-store';
|
||||
|
||||
type EntitlementResponse = AiEntitlementState & { recentUsage: MeteringEntry[] };
|
||||
|
||||
const CLASS_INFO: Record<AiClass, { name: string; desc: string }> = {
|
||||
local: { name: 'Local', desc: "Ollama on the user's own machine. Free, unmetered, never reaches this server." },
|
||||
server: { name: 'Server', desc: 'VNC-hosted. Entitlement-enforced, seat + usage tracked below.' },
|
||||
opencode: { name: 'OpenCode', desc: 'A locally-running OpenCode agent server. Holds its own provider credentials; nothing metered here.' },
|
||||
public: { name: 'Public (BYOK)', desc: "User's own API key, direct from their browser to the provider." },
|
||||
};
|
||||
|
||||
function AllowlistEditor({
|
||||
values, onChange, placeholder,
|
||||
}: { values: string[] | null; onChange: (next: string[] | null) => void; placeholder: string }) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const restricted = values !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-3.5 px-4 pt-2.5 pb-0.5 text-xs">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-muted-foreground">
|
||||
<input type="radio" checked={!restricted} onChange={() => onChange(null)} />
|
||||
Unrestricted (current)
|
||||
</label>
|
||||
<label className={`flex items-center gap-1.5 cursor-pointer ${restricted ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>
|
||||
<input type="radio" checked={restricted} onChange={() => onChange(values ?? [])} />
|
||||
Restrict to selected
|
||||
</label>
|
||||
</div>
|
||||
{restricted && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5 px-4 pt-2.5">
|
||||
{(values ?? []).map((v) => (
|
||||
<span key={v} className="inline-flex items-center gap-1.5 bg-muted border border-border rounded-full py-1 pl-3 pr-1.5 text-xs">
|
||||
{v}
|
||||
<button onClick={() => onChange((values ?? []).filter((x) => x !== v))} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 px-4 py-3">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 h-8 rounded border border-input bg-background px-2.5 text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && draft.trim()) {
|
||||
onChange([...(values ?? []), draft.trim()]);
|
||||
setDraft('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => { if (draft.trim()) { onChange([...(values ?? []), draft.trim()]); setDraft(''); } }}
|
||||
className="h-8 px-3 rounded border border-border bg-muted text-xs font-medium hover:bg-muted/70"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiPolicyTab() {
|
||||
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
|
||||
const [config, setConfig] = useState<AiConsoleConfig>({ ...DEFAULT_AI_CONSOLE_CONFIG });
|
||||
const [entitlement, setEntitlement] = useState<EntitlementResponse | null>(null);
|
||||
const [serverModels, setServerModels] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [policyRes, entitlementRes, modelsRes] = await Promise.all([
|
||||
apiFetch('/api/admin/ai/policy'),
|
||||
apiFetch('/api/admin/ai/entitlement'),
|
||||
apiFetch('/api/ai/server/models').catch(() => null),
|
||||
]);
|
||||
if (policyRes.ok) setConfig(await policyRes.json());
|
||||
if (entitlementRes.ok) setEntitlement(await entitlementRes.json());
|
||||
if (modelsRes?.ok) {
|
||||
const data = await modelsRes.json();
|
||||
setServerModels(data.models ?? []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function update(patch: Partial<AiConsoleConfig>) {
|
||||
setConfig((prev) => ({ ...prev, ...patch }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleClass(cls: AiClass) {
|
||||
const current = config.classesEnabled[cls] !== false;
|
||||
update({ classesEnabled: { ...config.classesEnabled, [cls]: !current } });
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
const res = await apiFetch('/api/admin/ai/policy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
if (res.ok) {
|
||||
setConfig(await res.json());
|
||||
setDirty(false);
|
||||
setMessage({ type: 'success', text: 'Saved.' });
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function setSeatTotal(total: number) {
|
||||
const res = await apiFetch('/api/admin/ai/entitlement', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seatsTotal: total }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEntitlement((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSeat(username: string) {
|
||||
const res = await apiFetch('/api/admin/ai/entitlement', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ revokeUsername: username }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEntitlement((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
const serverInfraAvailable = serverModels.length > 0 || entitlement !== null;
|
||||
const usageToday = (entitlement?.recentUsage ?? []).filter((u) => u.timestamp.slice(0, 10) === new Date().toISOString().slice(0, 10));
|
||||
const tokensToday = usageToday.reduce((sum, u) => sum + u.promptTokens + u.completionTokens, 0);
|
||||
const avgLatency = usageToday.length ? Math.round(usageToday.reduce((sum, u) => sum + u.latencyMs, 0) / usageToday.length) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">AI</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Provider classes, allow-lists, seats, usage, and BYOK consent for the AI Assistant.</p>
|
||||
</div>
|
||||
{dirty && (
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm">
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => setActiveTab('policy')}
|
||||
className="w-full flex items-center gap-2 text-xs text-muted-foreground bg-muted border border-border rounded-md px-3.5 py-2.5 hover:bg-muted/70 transition-colors text-left">
|
||||
<span>The master AI Assistant on/off switch lives in</span>
|
||||
<span className="text-primary font-medium inline-flex items-center gap-1">Policy → Feature Gates <ArrowRight className="w-3 h-3" /></span>
|
||||
</button>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Provider classes</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Which of the three AI classes users can reach at all.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 p-4">
|
||||
{(['local', 'server', 'opencode', 'public'] as AiClass[]).map((cls) => {
|
||||
const enabled = config.classesEnabled[cls] !== false;
|
||||
const disabledByInfra = cls === 'server' && !serverInfraAvailable;
|
||||
return (
|
||||
<div key={cls} className={`border border-border rounded-md p-3.5 ${disabledByInfra ? 'opacity-55' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-sm font-semibold">{CLASS_INFO[cls].name}</span>
|
||||
<button
|
||||
onClick={() => !disabledByInfra && toggleClass(cls)}
|
||||
disabled={disabledByInfra}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled && !disabledByInfra ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'} ${disabledByInfra ? 'cursor-not-allowed' : ''}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled && !disabledByInfra ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{CLASS_INFO[cls].desc}</p>
|
||||
{disabledByInfra && <p className="text-xs text-amber-600 dark:text-amber-400 mt-1.5">Not configured (AI_SERVER_BASE_URL unset)</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Server — model allow-list</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Restrict which Ollama models users may select for the Server class. Also enforced on every chat call, not just the picker.</p>
|
||||
</div>
|
||||
<AllowlistEditor
|
||||
values={config.serverModelAllowlist}
|
||||
onChange={(v) => update({ serverModelAllowlist: v })}
|
||||
placeholder={serverModels.length ? `e.g. ${serverModels[0]}` : 'e.g. qwen2.5:32b'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Public (BYOK) — provider allow-list</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Restrict which base URLs users may point a bring-your-own-key profile at. Checked client-side at save time — advisory, not a network boundary.</p>
|
||||
</div>
|
||||
<AllowlistEditor
|
||||
values={config.publicProviderAllowlist}
|
||||
onChange={(v) => update({ publicProviderAllowlist: v })}
|
||||
placeholder="e.g. https://api.openai.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Entitlement & seats</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Server class only. First successful use auto-assigns a seat.</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center gap-3 border-b border-border">
|
||||
<span className="text-sm flex-1">Seats licensed</span>
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={entitlement?.seatsTotal ?? 0}
|
||||
onChange={(e) => setSeatTotal(Math.max(0, Number.parseInt(e.target.value, 10) || 0))}
|
||||
className="w-20 h-8 rounded border border-input bg-background px-2 text-sm text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{entitlement?.assignedTo.length ?? 0} of {entitlement?.seatsTotal ?? 0} assigned</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{(entitlement?.assignedTo ?? []).length === 0 && (
|
||||
<div className="px-4 py-3 text-xs text-muted-foreground">No seats assigned yet.</div>
|
||||
)}
|
||||
{(entitlement?.assignedTo ?? []).map((username) => (
|
||||
<div key={username} className="px-4 py-2.5 flex items-center justify-between gap-3">
|
||||
<span className="text-sm">{username}</span>
|
||||
<button onClick={() => revokeSeat(username)}
|
||||
className="text-xs font-medium text-destructive border border-border rounded px-2.5 py-1 hover:bg-destructive/10">
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Usage</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Last 200 metered calls. Read-only.</p>
|
||||
</div>
|
||||
<div className="flex gap-6 px-4 py-3 border-b border-border flex-wrap">
|
||||
<div><span className="text-lg font-semibold tabular-nums block">{usageToday.length}</span><span className="text-[11px] uppercase tracking-wide text-muted-foreground">Calls today</span></div>
|
||||
<div><span className="text-lg font-semibold tabular-nums block">{tokensToday.toLocaleString()}</span><span className="text-[11px] uppercase tracking-wide text-muted-foreground">Tokens today</span></div>
|
||||
<div><span className="text-lg font-semibold tabular-nums block">{avgLatency}ms</span><span className="text-[11px] uppercase tracking-wide text-muted-foreground">Avg latency</span></div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground uppercase text-[10px] tracking-wide">
|
||||
<th className="text-left px-4 py-2 font-medium">Time</th>
|
||||
<th className="text-left px-4 py-2 font-medium">User</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Model</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Prompt tok</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Compl. tok</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Latency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{(entitlement?.recentUsage ?? []).length === 0 && (
|
||||
<tr><td colSpan={6} className="px-4 py-3 text-muted-foreground">No usage recorded yet.</td></tr>
|
||||
)}
|
||||
{[...(entitlement?.recentUsage ?? [])].reverse().slice(0, 50).map((u, i) => (
|
||||
<tr key={i} className="tabular-nums">
|
||||
<td className="px-4 py-2">{new Date(u.timestamp).toLocaleTimeString()}</td>
|
||||
<td className="px-4 py-2">{u.username}</td>
|
||||
<td className="px-4 py-2">{u.model}</td>
|
||||
<td className="px-4 py-2">{u.promptTokens}</td>
|
||||
<td className="px-4 py-2">{u.completionTokens}</td>
|
||||
<td className="px-4 py-2">{u.latencyMs}ms</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Retrieval & consent</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Mail-content augmentation and the BYOK consent prompt.</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center justify-between gap-4 border-b border-border">
|
||||
<div>
|
||||
<div className="text-sm">Retrieval leg</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Send recent mail content to the Server class's embedding model to answer questions grounded in the user's own mail.</p>
|
||||
</div>
|
||||
<button onClick={() => update({ retrievalEnabled: !config.retrievalEnabled })}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${config.retrievalEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${config.retrievalEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-3.5 space-y-2">
|
||||
<label className="text-sm block">Consent text (shown once per version, before first BYOK/Public use)</label>
|
||||
<textarea
|
||||
value={config.consent?.text ?? ''}
|
||||
onChange={(e) => update({ consent: { version: config.consent?.version ?? '1', text: e.target.value } })}
|
||||
className="w-full min-h-20 rounded border border-input bg-background px-2.5 py-2 text-xs"
|
||||
placeholder="Using a bring-your-own-key provider sends your question — and, if retrieval is on, related excerpts from your mail — to that provider's servers, outside this organisation. Continue?"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-center gap-2.5 flex-wrap">
|
||||
<span className="text-sm">Version</span>
|
||||
<input
|
||||
value={config.consent?.version ?? ''}
|
||||
onChange={(e) => update({ consent: { version: e.target.value, text: config.consent?.text ?? '' } })}
|
||||
className="w-20 h-8 rounded border border-input bg-background px-2 text-xs text-center"
|
||||
/>
|
||||
<button
|
||||
onClick={() => update({ consent: { version: String(Number.parseInt(config.consent?.version || '0', 10) + 1), text: config.consent?.text ?? '' } })}
|
||||
className="h-8 px-3 rounded border border-border bg-muted text-xs font-medium hover:bg-muted/70">
|
||||
Bump version (re-prompt everyone)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,10 @@ export function PluginsTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
// Bundle held back by the pattern scanner, awaiting an explicit admin decision.
|
||||
const [pendingScan, setPendingScan] = useState<
|
||||
{ file: File; findings: Array<{ file: string; patterns: string[] }> } | null
|
||||
>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [policyDirty, setPolicyDirty] = useState(false);
|
||||
@@ -104,15 +108,17 @@ export function PluginsTab() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Upload a bundle. The scanner may refuse it for containing patterns that are
|
||||
// expected in a vendored crypto library (openpgp.js, pkijs); in that case the
|
||||
// server returns `canOverride` and we hold the file so the admin can review
|
||||
// the findings and decide. `override` re-posts the same file with consent.
|
||||
async function uploadPlugin(file: File, override: boolean) {
|
||||
setUploading(true);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (override) formData.append('overrideWarnings', 'true');
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
@@ -122,13 +128,22 @@ export function PluginsTab() {
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
|
||||
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
|
||||
setPendingScan(null);
|
||||
const accepted = data.findings?.length
|
||||
? ` — ${data.findings.length} scanner finding(s) accepted and logged`
|
||||
: '';
|
||||
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${accepted}` });
|
||||
await fetchPlugins();
|
||||
} else if (data.canOverride && Array.isArray(data.findings) && !override) {
|
||||
// Hold the file rather than the error: the admin needs to see WHAT
|
||||
// tripped, in WHICH file, before deciding.
|
||||
setPendingScan({ file, findings: data.findings });
|
||||
} else {
|
||||
setPendingScan(null);
|
||||
setMessage({ type: 'error', text: data.error || 'Upload failed' });
|
||||
}
|
||||
} catch {
|
||||
setPendingScan(null);
|
||||
setMessage({ type: 'error', text: 'Upload failed' });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -136,6 +151,13 @@ export function PluginsTab() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setPendingScan(null);
|
||||
await uploadPlugin(file, false);
|
||||
}
|
||||
|
||||
async function togglePlugin(id: string, enabled: boolean) {
|
||||
setMessage(null);
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
@@ -302,6 +324,51 @@ export function PluginsTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingScan && (
|
||||
<div className="border border-warning/40 bg-warning/5 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-warning mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Scanner flagged <span className="font-mono">{pendingScan.file.name}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These patterns can indicate malicious code, but they also appear in legitimate
|
||||
minified crypto libraries such as openpgp.js and pkijs. Review the findings before
|
||||
proceeding — installing anyway is recorded in the audit log.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{pendingScan.findings.map(f => (
|
||||
<li key={f.file} className="text-xs font-mono bg-background/60 border border-border rounded px-2 py-1">
|
||||
<span className="text-foreground">{f.file}</span>
|
||||
<span className="text-muted-foreground"> — {f.patterns.join(', ')}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => uploadPlugin(pendingScan.file, true)}
|
||||
disabled={uploading}
|
||||
className="inline-flex items-center gap-2 h-8 px-3 rounded-md bg-destructive text-destructive-foreground text-xs font-medium hover:bg-destructive/90 disabled:opacity-50 transition-all"
|
||||
>
|
||||
{uploading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <AlertTriangle className="w-3.5 h-3.5" />}
|
||||
Install anyway
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPendingScan(null); setMessage(null); }}
|
||||
disabled={uploading}
|
||||
className="inline-flex items-center h-8 px-3 rounded-md border border-border text-xs font-medium text-foreground hover:bg-muted disabled:opacity-50 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -28,6 +28,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
|
||||
aiAssistantEnabled: { label: 'AI Assistant (preview)', description: 'Show the AI Assistant settings tab. Local (Ollama on the user\'s own machine or this desktop app) is free and unmetered; public (bring-your-own-key) is available too but not yet monitored or metered — see docs/AI-ASSISTANT-CONCEPT.md.' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Loader2, Plus, X } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
interface VncDirectoryFormData {
|
||||
enabled: boolean;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
samlEnabled: boolean;
|
||||
samlIdpUrl: string;
|
||||
samlSpCert: string;
|
||||
samlIssuer: string;
|
||||
ldapEnabled: boolean;
|
||||
ldapUri: string;
|
||||
ldapBindDn: string;
|
||||
ldapBindPassword: string;
|
||||
ldapSearchBase: string;
|
||||
ldapType: 'openldap' | 'ms-ad';
|
||||
tfaEnabled: boolean;
|
||||
oidcEnabled: boolean;
|
||||
oidcClientId: string;
|
||||
oidcDiscoveryUrl: string;
|
||||
sessionTtl: number;
|
||||
federatedApps: Record<string, string>;
|
||||
}
|
||||
|
||||
const BLANK_FORM: VncDirectoryFormData = {
|
||||
enabled: false,
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
samlEnabled: false,
|
||||
samlIdpUrl: '',
|
||||
samlSpCert: '',
|
||||
samlIssuer: '',
|
||||
ldapEnabled: false,
|
||||
ldapUri: '',
|
||||
ldapBindDn: '',
|
||||
ldapBindPassword: '',
|
||||
ldapSearchBase: '',
|
||||
ldapType: 'openldap',
|
||||
tfaEnabled: false,
|
||||
oidcEnabled: false,
|
||||
oidcClientId: '',
|
||||
oidcDiscoveryUrl: '',
|
||||
sessionTtl: 28800,
|
||||
federatedApps: {},
|
||||
};
|
||||
|
||||
export function VncDirectoryTab() {
|
||||
const [config, setConfig] = useState<VncDirectoryFormData>({ ...BLANK_FORM });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => { fetchConfig(); }, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/vncdirectory');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setConfig(data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateField<K extends keyof VncDirectoryFormData>(key: K, value: VncDirectoryFormData[K]) {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function toggleBool(key: keyof VncDirectoryFormData) {
|
||||
setConfig((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function setFederatedApp(name: string, url: string) {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
federatedApps: { ...prev.federatedApps, [name]: url },
|
||||
}));
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function removeFederatedApp(name: string) {
|
||||
setConfig((prev) => {
|
||||
const next = { ...prev.federatedApps };
|
||||
delete next[name];
|
||||
return { ...prev, federatedApps: next };
|
||||
});
|
||||
setDirty(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/vncdirectory', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'VNCdirectory configuration saved.' });
|
||||
setDirty(false);
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const federatedAppsList = Object.entries(config.federatedApps);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-foreground">VNCdirectory</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Centralized identity and directory integration (SAML, LDAP, 2FA)
|
||||
</p>
|
||||
</div>
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save configuration
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`text-sm rounded-md px-3 py-2 ${
|
||||
message.type === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Section title="Enable VNCdirectory Integration">
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Enabled</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Turn on VNCdirectory integration for identity management, SSO, and directory services
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleBool('enabled')}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
config.enabled
|
||||
? 'bg-primary'
|
||||
: 'bg-muted-foreground/25 dark:bg-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${
|
||||
config.enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<Section title="Connection">
|
||||
<div className="divide-y divide-border">
|
||||
<TextRow
|
||||
label="VNCdirectory URL"
|
||||
value={config.apiUrl}
|
||||
onChange={(v) => updateField('apiUrl', v)}
|
||||
placeholder="https://vncdirectory.example.com"
|
||||
/>
|
||||
<PasswordRow
|
||||
label="API Key"
|
||||
value={config.apiKey}
|
||||
onChange={(v) => updateField('apiKey', v)}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="SAML / Identity Provider">
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label="SAML Enabled"
|
||||
description="Enable SAML single sign-on via VNCdirectory"
|
||||
value={config.samlEnabled}
|
||||
onChange={() => toggleBool('samlEnabled')}
|
||||
/>
|
||||
{config.samlEnabled && (
|
||||
<>
|
||||
<TextRow
|
||||
label="Identity Provider URL"
|
||||
value={config.samlIdpUrl}
|
||||
onChange={(v) => updateField('samlIdpUrl', v)}
|
||||
placeholder="https://idp.example.com/saml2/idp"
|
||||
/>
|
||||
<TextRow
|
||||
label="Issuer Name (Entity ID)"
|
||||
value={config.samlIssuer}
|
||||
onChange={(v) => updateField('samlIssuer', v)}
|
||||
placeholder="urn:example:vncmail"
|
||||
/>
|
||||
<div className="px-4 py-3 flex flex-col gap-2">
|
||||
<label className="text-sm text-foreground">
|
||||
Service Provider Certificate (X.509)
|
||||
</label>
|
||||
<textarea
|
||||
value={config.samlSpCert}
|
||||
onChange={(e) => updateField('samlSpCert', e.target.value)}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-vertical"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="LDAP Directory">
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label="LDAP Enabled"
|
||||
description="Query user directory via LDAP for contact lookups and authentication"
|
||||
value={config.ldapEnabled}
|
||||
onChange={() => toggleBool('ldapEnabled')}
|
||||
/>
|
||||
{config.ldapEnabled && (
|
||||
<>
|
||||
<TextRow
|
||||
label="LDAP Server URI"
|
||||
value={config.ldapUri}
|
||||
onChange={(v) => updateField('ldapUri', v)}
|
||||
placeholder="ldaps://ldap.example.com:636"
|
||||
/>
|
||||
<TextRow
|
||||
label="Bind DN"
|
||||
value={config.ldapBindDn}
|
||||
onChange={(v) => updateField('ldapBindDn', v)}
|
||||
placeholder="cn=readonly,dc=example,dc=com"
|
||||
/>
|
||||
<PasswordRow
|
||||
label="Bind Password"
|
||||
value={config.ldapBindPassword}
|
||||
onChange={(v) => updateField('ldapBindPassword', v)}
|
||||
placeholder="Enter LDAP bind password"
|
||||
/>
|
||||
<TextRow
|
||||
label="Search Base"
|
||||
value={config.ldapSearchBase}
|
||||
onChange={(v) => updateField('ldapSearchBase', v)}
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
/>
|
||||
<SelectRow
|
||||
label="LDAP Type"
|
||||
value={config.ldapType}
|
||||
options={[
|
||||
{ value: 'openldap', label: 'OpenLDAP' },
|
||||
{ value: 'ms-ad', label: 'Microsoft Active Directory' },
|
||||
]}
|
||||
onChange={(v) => updateField('ldapType', v as 'openldap' | 'ms-ad')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Authentication">
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label="Enforce 2FA/TOTP"
|
||||
description="Require two-factor authentication for all users"
|
||||
value={config.tfaEnabled}
|
||||
onChange={() => toggleBool('tfaEnabled')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="OpenID Connect (OIDC)"
|
||||
description="Enable OIDC login alongside or instead of SAML"
|
||||
value={config.oidcEnabled}
|
||||
onChange={() => toggleBool('oidcEnabled')}
|
||||
/>
|
||||
{config.oidcEnabled && (
|
||||
<>
|
||||
<TextRow
|
||||
label="OIDC Client ID"
|
||||
value={config.oidcClientId}
|
||||
onChange={(v) => updateField('oidcClientId', v)}
|
||||
placeholder="vncmail-client"
|
||||
/>
|
||||
<TextRow
|
||||
label="OIDC Discovery URL"
|
||||
value={config.oidcDiscoveryUrl}
|
||||
onChange={(v) => updateField('oidcDiscoveryUrl', v)}
|
||||
placeholder="https://idp.example.com/.well-known/openid-configuration"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">Session TTL (seconds)</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
How long SSO sessions remain valid. Default: 8 hours (28800)
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={config.sessionTtl}
|
||||
onChange={(e) => updateField('sessionTtl', Number(e.target.value))}
|
||||
className="h-8 w-full sm:w-32 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Federated Applications">
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Configure SSO redirect URLs for other VNC applications. Users signed into one
|
||||
app will be transparently authenticated when navigating to another.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{federatedAppsList.map(([appName, url]) => (
|
||||
<div
|
||||
key={appName}
|
||||
className="flex flex-col sm:flex-row items-start sm:items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={appName}
|
||||
readOnly
|
||||
className="h-8 w-full sm:w-36 rounded-md border border-input bg-muted/50 px-2.5 text-sm text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setFederatedApp(appName, e.target.value)}
|
||||
placeholder="https://vnc.example.com/auth/sso"
|
||||
className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeFederatedApp(appName)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={`Remove ${appName}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<AddFederatedApp
|
||||
existingKeys={new Set(Object.keys(config.federatedApps))}
|
||||
onAdd={(name, url) => setFederatedApp(name, url)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddFederatedApp({
|
||||
existingKeys,
|
||||
onAdd,
|
||||
}: {
|
||||
existingKeys: Set<string>;
|
||||
onAdd: (name: string, url: string) => void;
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!adding) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add federated app
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setError('Enter an application name');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
setError('Name must contain only letters, numbers, hyphens, and underscores');
|
||||
return;
|
||||
}
|
||||
if (existingKeys.has(trimmed)) {
|
||||
setError('An app with this name already exists');
|
||||
return;
|
||||
}
|
||||
if (!url.trim()) {
|
||||
setError('Enter an SSO URL');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onAdd(trimmed, url.trim());
|
||||
setName('');
|
||||
setUrl('');
|
||||
setAdding(false);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setAdding(false);
|
||||
setName('');
|
||||
setUrl('');
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
||||
placeholder="App name (e.g. vnctalk)"
|
||||
className="h-8 w-full sm:w-36 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => { setUrl(e.target.value); setError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
||||
placeholder="https://vnctalk.example.com/auth/sso"
|
||||
className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="inline-flex items-center h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-8 w-full sm:w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const isMasked = value === '••••••';
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<input
|
||||
type={isMasked ? 'text' : 'password'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || (isMasked ? 'Saved - type to replace' : undefined)}
|
||||
className="h-8 w-full sm:w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: boolean;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onChange}
|
||||
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${
|
||||
value ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectRow({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
Scale,
|
||||
ScrollText,
|
||||
LogOut,
|
||||
Key,
|
||||
KeyRound,
|
||||
Bot,
|
||||
Puzzle,
|
||||
SwatchBook,
|
||||
Activity,
|
||||
@@ -54,7 +56,9 @@ const NAV_GROUPS: ReadonlyArray<{
|
||||
{ tab: 'settings', label: 'Settings', icon: Settings },
|
||||
{ tab: 'branding', label: 'Branding', icon: Palette },
|
||||
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
||||
{ tab: 'vncdirectory', label: 'VNCdirectory', icon: Key },
|
||||
{ tab: 'policy', label: 'Policy', icon: Scale },
|
||||
{ tab: 'ai-policy', label: 'AI', icon: Bot },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,12 +7,14 @@ import { SettingsTab } from './_tabs/settings';
|
||||
import { BrandingTab } from './_tabs/branding';
|
||||
import { AuthTab } from './_tabs/auth';
|
||||
import { PolicyTab } from './_tabs/policy';
|
||||
import { AiPolicyTab } from './_tabs/ai-policy';
|
||||
import { PluginsTab } from './_tabs/plugins';
|
||||
import { ThemesTab } from './_tabs/themes';
|
||||
import { MarketplaceTab } from './_tabs/marketplace';
|
||||
import { VersionTab } from './_tabs/version';
|
||||
import { TelemetryTab } from './_tabs/telemetry';
|
||||
import { LogsTab } from './_tabs/logs';
|
||||
import { VncDirectoryTab } from './_tabs/vncdirectory';
|
||||
|
||||
export default function AdminPage() {
|
||||
const activeTab = useAdminTabStore((s) => s.activeTab);
|
||||
@@ -39,11 +41,13 @@ export default function AdminPage() {
|
||||
case 'branding': return <BrandingTab />;
|
||||
case 'auth': return <AuthTab />;
|
||||
case 'policy': return <PolicyTab />;
|
||||
case 'ai-policy': return <AiPolicyTab />;
|
||||
case 'plugins': return <PluginsTab />;
|
||||
case 'themes': return <ThemesTab />;
|
||||
case 'marketplace': return <MarketplaceTab />;
|
||||
case 'version': return <VersionTab />;
|
||||
case 'telemetry': return <TelemetryTab />;
|
||||
case 'logs': return <LogsTab />;
|
||||
case 'vncdirectory': return <VncDirectoryTab />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/admin?tab=vncdirectory');
|
||||
}
|
||||
@@ -55,9 +55,23 @@ export async function POST(request: NextRequest) {
|
||||
logger.error('Stalwart JMAP passthrough redirect error', { error: error.message });
|
||||
return NextResponse.json({ error: error.message }, { status: 502 });
|
||||
}
|
||||
// `fetch failed` from undici is too generic to debug — the real reason
|
||||
// (ENOTFOUND, ECONNREFUSED, self-signed TLS, …) lives on `error.cause`.
|
||||
const err = error as Error & { cause?: { code?: string; message?: string } };
|
||||
logger.error('Stalwart JMAP passthrough error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown',
|
||||
error: err?.message ?? 'Unknown',
|
||||
causeCode: err?.cause?.code,
|
||||
causeMessage: err?.cause?.message,
|
||||
});
|
||||
// The server this process failed to reach is the user's own mail server,
|
||||
// so the reason is worth surfacing: an opaque 500 leaves operators with
|
||||
// nothing to act on.
|
||||
if (err?.cause?.code) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot reach the JMAP server (${err.cause.code})` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getEntitlementState, setSeatTotal, revokeSeat, readMeteringLedger } from '@/lib/ai/entitlement';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Admin-only data endpoints for the `server` AI class's real entitlement
|
||||
* enforcement (lib/ai/entitlement.ts). This is the data plumbing only — the
|
||||
* visual admin console (docs/AI-ASSISTANT-CONCEPT.md §6) is a separate,
|
||||
* not-yet-built UI on top of these same endpoints.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
try {
|
||||
const [state, ledger] = await Promise.all([getEntitlementState(), readMeteringLedger()]);
|
||||
return NextResponse.json({ ...state, recentUsage: ledger }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (error) {
|
||||
logger.error('ai entitlement read error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
let body: { seatsTotal?: unknown; revokeUsername?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof body.seatsTotal === 'number') {
|
||||
const state = await setSeatTotal(body.seatsTotal);
|
||||
await auditLog('ai.entitlement.seats_total', { seatsTotal: state.seatsTotal }, ip);
|
||||
return NextResponse.json(state);
|
||||
}
|
||||
if (typeof body.revokeUsername === 'string' && body.revokeUsername) {
|
||||
const state = await revokeSeat(body.revokeUsername);
|
||||
await auditLog('ai.entitlement.revoke_seat', { username: body.revokeUsername }, ip);
|
||||
return NextResponse.json(state);
|
||||
}
|
||||
return NextResponse.json({ error: 'seatsTotal or revokeUsername is required' }, { status: 400 });
|
||||
} catch (error) {
|
||||
logger.error('ai entitlement update error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { AiConsoleConfig, AiClass } from '@/lib/ai/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const VALID_CLASSES: AiClass[] = ['local', 'server', 'public'];
|
||||
|
||||
/**
|
||||
* GET/PUT /api/admin/ai/policy - the admin console's writable config
|
||||
* (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6): per-class enable, model/
|
||||
* provider allow-lists, retrieval on/off, BYOK consent text. Separate from
|
||||
* /api/admin/ai/entitlement (seats/ledger - runtime state) and from the
|
||||
* generic /api/admin/policy (FeatureGates - the master aiAssistantEnabled
|
||||
* toggle stays there, this console only links to it, per spec §6 open
|
||||
* question 3).
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
return NextResponse.json(configManager.getAiConsoleConfig(), { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (error) {
|
||||
logger.error('ai console policy read error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function validate(body: Partial<AiConsoleConfig>): string | null {
|
||||
if (body.classesEnabled !== undefined) {
|
||||
if (typeof body.classesEnabled !== 'object' || body.classesEnabled === null) return 'classesEnabled must be an object';
|
||||
for (const key of Object.keys(body.classesEnabled)) {
|
||||
if (!VALID_CLASSES.includes(key as AiClass)) return `classesEnabled has an unknown class "${key}"`;
|
||||
}
|
||||
}
|
||||
if (body.serverModelAllowlist !== undefined && body.serverModelAllowlist !== null) {
|
||||
if (!Array.isArray(body.serverModelAllowlist) || !body.serverModelAllowlist.every((m) => typeof m === 'string')) {
|
||||
return 'serverModelAllowlist must be an array of strings or null';
|
||||
}
|
||||
}
|
||||
if (body.publicProviderAllowlist !== undefined && body.publicProviderAllowlist !== null) {
|
||||
if (!Array.isArray(body.publicProviderAllowlist) || !body.publicProviderAllowlist.every((m) => typeof m === 'string')) {
|
||||
return 'publicProviderAllowlist must be an array of strings or null';
|
||||
}
|
||||
}
|
||||
if (body.retrievalEnabled !== undefined && typeof body.retrievalEnabled !== 'boolean') {
|
||||
return 'retrievalEnabled must be a boolean';
|
||||
}
|
||||
if (body.consent !== undefined && body.consent !== null) {
|
||||
if (typeof body.consent !== 'object' || typeof body.consent.version !== 'string' || typeof body.consent.text !== 'string') {
|
||||
return 'consent must be { version: string, text: string } or null';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
const ip = getClientIP(request);
|
||||
|
||||
let body: Partial<AiConsoleConfig>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const validationError = validate(body);
|
||||
if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
|
||||
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
const next = await configManager.setAiConsoleConfig(body);
|
||||
await auditLog('ai.console_policy.update', {
|
||||
classesEnabled: next.classesEnabled,
|
||||
retrievalEnabled: next.retrievalEnabled,
|
||||
consentVersion: next.consent?.version ?? null,
|
||||
serverModelAllowlistCount: next.serverModelAllowlist?.length ?? null,
|
||||
publicProviderAllowlistCount: next.publicProviderAllowlist?.length ?? null,
|
||||
}, ip);
|
||||
return NextResponse.json(next);
|
||||
} catch (error) {
|
||||
logger.error('ai console policy update error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -158,15 +158,47 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
const code = await entryFile.async('string');
|
||||
|
||||
// Security: block plugins containing dangerous JS patterns
|
||||
const warnings: string[] = [];
|
||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||
pattern.lastIndex = 0;
|
||||
// Security: scan for dangerous JS patterns across EVERY script in the
|
||||
// bundle, not just the entrypoint - a second .js file was previously never
|
||||
// looked at.
|
||||
//
|
||||
// The result is a reviewable finding rather than an unconditional reject.
|
||||
// Minified crypto libraries (openpgp.js, pkijs) legitimately contain these
|
||||
// patterns, so a hard block makes S/MIME and PGP plugins uninstallable.
|
||||
// This route is already admin-authenticated, so the scan is defence in
|
||||
// depth against an accidental or compromised upload, not a trust boundary:
|
||||
// an admin may proceed with `overrideWarnings`, and the override is
|
||||
// recorded in the audit log with the exact findings.
|
||||
const findings: Array<{ file: string; patterns: string[] }> = [];
|
||||
for (const [filePath, entry] of Object.entries(zip.files)) {
|
||||
if (entry.dir) continue;
|
||||
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
|
||||
if (ext !== '.js' && ext !== '.mjs') continue;
|
||||
const source = filePath === root + (manifest.entrypoint as string)
|
||||
? code
|
||||
: await entry.async('string');
|
||||
const hits: string[] = [];
|
||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||
if (pattern.test(source)) hits.push(label);
|
||||
pattern.lastIndex = 0;
|
||||
}
|
||||
if (hits.length > 0) {
|
||||
findings.push({ file: filePath.slice(root.length), patterns: hits });
|
||||
}
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
|
||||
const overrideWarnings = formData.get('overrideWarnings') === 'true';
|
||||
if (findings.length > 0 && !overrideWarnings) {
|
||||
const summary = findings
|
||||
.map(f => `${f.file}: ${f.patterns.join(', ')}`)
|
||||
.join('; ');
|
||||
return NextResponse.json(
|
||||
{ error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
|
||||
{
|
||||
error: `Plugin rejected: ${summary}. Review the bundle; if these are expected `
|
||||
+ `(e.g. a vendored crypto library), re-upload with "overrideWarnings" to proceed.`,
|
||||
findings,
|
||||
canOverride: true,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -212,8 +244,20 @@ export async function POST(request: NextRequest) {
|
||||
await savePlugin(plugin, code);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
|
||||
if (findings.length > 0) {
|
||||
// Record WHAT was waved through, not merely that an override happened -
|
||||
// otherwise the audit trail can't answer "which patterns did we accept?".
|
||||
await auditLog(
|
||||
'plugin.install.scan_override',
|
||||
{ id: plugin.id, version: plugin.version, findings },
|
||||
ip,
|
||||
);
|
||||
logger.warn('Plugin installed with scanner override', { id: plugin.id, findings });
|
||||
}
|
||||
|
||||
return NextResponse.json({ plugin });
|
||||
// Echo accepted findings back so the admin UI can confirm exactly what was
|
||||
// waved through, rather than reporting a bare success.
|
||||
return NextResponse.json(findings.length > 0 ? { plugin, findings } : { plugin });
|
||||
} catch (error) {
|
||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
getVncDirectoryConfig,
|
||||
saveVncDirectoryConfig,
|
||||
DEFAULT_VNCDIRECTORY_CONFIG,
|
||||
VNCDIRECTORY_SENSITIVE_KEYS,
|
||||
type VncDirectoryConfig,
|
||||
} from '@/lib/admin/vncdirectory-config';
|
||||
|
||||
const VALID_LDAP_TYPES = new Set(['openldap', 'ms-ad']);
|
||||
const KNOWN_KEYS = new Set(Object.keys(DEFAULT_VNCDIRECTORY_CONFIG));
|
||||
|
||||
function maskConfigForClient(config: VncDirectoryConfig): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (VNCDIRECTORY_SENSITIVE_KEYS.has(key)) {
|
||||
result[key] = typeof value === 'string' && value.length > 0 ? '••••••' : '';
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const result = await requireAdminAuth(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const config = await getVncDirectoryConfig();
|
||||
return NextResponse.json(maskConfigForClient(config), {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('VNCdirectory config read error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authResult = await requireAdminAuth(request);
|
||||
if ('error' in authResult) return authResult.error;
|
||||
|
||||
const ip = getClientIP(request);
|
||||
const body = await request.json();
|
||||
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate known keys only
|
||||
const unknownKeys = Object.keys(body).filter((k) => !KNOWN_KEYS.has(k));
|
||||
if (unknownKeys.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown config keys: ${unknownKeys.join(', ')}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate boolean fields
|
||||
const boolFields = ['enabled', 'samlEnabled', 'ldapEnabled', 'tfaEnabled', 'oidcEnabled'];
|
||||
for (const key of boolFields) {
|
||||
if (key in body && typeof body[key] !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: `${key} must be a boolean` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate sessionTtl
|
||||
if ('sessionTtl' in body) {
|
||||
const ttl = Number(body.sessionTtl);
|
||||
if (!Number.isFinite(ttl) || ttl < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sessionTtl must be a non-negative number' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
body.sessionTtl = ttl;
|
||||
}
|
||||
|
||||
// Validate ldapType
|
||||
if ('ldapType' in body && !VALID_LDAP_TYPES.has(body.ldapType)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid ldapType: ${body.ldapType}. Must be 'openldap' or 'ms-ad'.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate federatedApps
|
||||
if ('federatedApps' in body) {
|
||||
if (!body.federatedApps || typeof body.federatedApps !== 'object' || Array.isArray(body.federatedApps)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'federatedApps must be an object mapping app names to URLs' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
for (const [appName, url] of Object.entries(body.federatedApps as Record<string, unknown>)) {
|
||||
if (typeof url !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: `federatedApps.${appName} must be a string URL` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If apiKey or ldapBindPassword are "••••••", preserve existing value
|
||||
const currentConfig = await getVncDirectoryConfig();
|
||||
if (body.apiKey === '••••••') {
|
||||
body.apiKey = currentConfig.apiKey;
|
||||
}
|
||||
if (body.ldapBindPassword === '••••••') {
|
||||
body.ldapBindPassword = currentConfig.ldapBindPassword;
|
||||
}
|
||||
|
||||
const changedKeys = Object.keys(body).filter((k) => {
|
||||
const currentVal = currentConfig[k as keyof VncDirectoryConfig];
|
||||
const newVal = body[k];
|
||||
if (k === 'federatedApps') {
|
||||
return JSON.stringify(currentVal) !== JSON.stringify(newVal);
|
||||
}
|
||||
return String(currentVal ?? '') !== String(newVal ?? '');
|
||||
});
|
||||
|
||||
await saveVncDirectoryConfig(body as Partial<VncDirectoryConfig>);
|
||||
|
||||
if (changedKeys.length > 0) {
|
||||
await auditLog('vncdirectory.update', { changedKeys }, ip);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('VNCdirectory config update error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { findOpencodeServer, parseModelRef, opencodePrompt } from '@/lib/ai/opencode';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_BODY_BYTES = 200 * 1024;
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/opencode/chat — one-shot chat against a locally-running
|
||||
* `opencode serve`.
|
||||
*
|
||||
* Deliberately NOT entitlement-metered, unlike /api/ai/server/chat: this runs
|
||||
* on the user's own machine against provider credentials opencode itself
|
||||
* holds, so there is no centrally-borne cost for this app to bill — the same
|
||||
* reasoning that leaves `local` unmetered (lib/ai/entitlement.ts's header).
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await getStalwartCredentials(request);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) {
|
||||
return NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 });
|
||||
}
|
||||
|
||||
const rawBody = await request.text();
|
||||
if (rawBody.length > MAX_BODY_BYTES) {
|
||||
return NextResponse.json({ error: 'request too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
let body: { model?: unknown; messages?: unknown };
|
||||
try {
|
||||
body = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const model = typeof body.model === 'string' ? body.model : '';
|
||||
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
|
||||
if (!model || !messages || messages.length === 0) {
|
||||
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const found = await findOpencodeServer();
|
||||
if (!found) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No local OpenCode server is running. The desktop app starts one automatically when the opencode CLI is installed \u2014 install it from opencode.ai, then restart VNCmail+.' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!found.models.some((m) => m.ref === model)) {
|
||||
// The picker is populated from this same list, so a mismatch means the
|
||||
// saved model was removed/renamed in opencode since it was chosen -
|
||||
// clearer to say so than to forward it and surface opencode's own error.
|
||||
return NextResponse.json(
|
||||
{ error: `OpenCode no longer offers the model "${model}" \u2014 pick another in Settings.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const parsed = parseModelRef(model);
|
||||
if (!parsed) {
|
||||
return NextResponse.json({ error: `Malformed model reference "${model}"` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Flatten our chat-messages shape onto opencode's (system field + text
|
||||
// parts). Every non-system message is already just the built prompt.
|
||||
const system = messages.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n') || undefined;
|
||||
const userText = messages.filter((m) => m.role !== 'system').map((m) => m.content).join('\n\n');
|
||||
if (!userText.trim()) {
|
||||
return NextResponse.json({ error: 'no user content to send' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await opencodePrompt(found.baseUrl, parsed, system, userText);
|
||||
if (!result.ok) {
|
||||
logger.error('opencode prompt failed', { error: result.error });
|
||||
return NextResponse.json({ error: result.error }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json({ answer: result.answer });
|
||||
} catch (cause) {
|
||||
logger.error('opencode chat failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return NextResponse.json({ error: 'OpenCode server unreachable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { findOpencodeServer } from '@/lib/ai/opencode';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/ai/opencode/models — models a locally-running `opencode serve`
|
||||
* exposes. Proxied rather than fetched directly by the renderer: the desktop
|
||||
* shell's origin is a random localhost port that changes every launch, so a
|
||||
* direct call would need opencode's CORS allowlist updated each time.
|
||||
*
|
||||
* Listing is not a billable action, so a valid session is enough — no seat
|
||||
* check (matching /api/ai/server/models).
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await getStalwartCredentials(request);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) {
|
||||
return NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 });
|
||||
}
|
||||
|
||||
const found = await findOpencodeServer();
|
||||
if (!found) {
|
||||
// 503 not 500: "nothing is listening" is a normal state (opencode simply
|
||||
// isn't running), and the client turns it into setup guidance rather than
|
||||
// an error banner.
|
||||
return NextResponse.json(
|
||||
{ error: 'No local OpenCode server is running. The desktop app starts one automatically when the opencode CLI is installed \u2014 install it from opencode.ai, then restart VNCmail+.' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ models: found.models.map((m) => ({ ref: m.ref, label: m.label })) },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import {
|
||||
findOpencodeServer, listOpencodeProviders, setOpencodeProviderKey, removeOpencodeProvider,
|
||||
} from '@/lib/ai/opencode';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const SETUP_ERROR =
|
||||
'No local OpenCode server is running. The desktop app starts one automatically when the opencode CLI is installed — install it from opencode.ai, then restart VNCmail+.';
|
||||
|
||||
async function requireOpencode(request: NextRequest) {
|
||||
const auth = await getStalwartCredentials(request);
|
||||
if (!auth) return { error: NextResponse.json({ error: 'not authenticated' }, { status: 401 }) } as const;
|
||||
|
||||
await configManager.ensureLoaded();
|
||||
if (configManager.getAiConsoleConfig().classesEnabled.opencode === false) {
|
||||
return { error: NextResponse.json({ error: 'the OpenCode class is disabled by admin policy' }, { status: 403 }) } as const;
|
||||
}
|
||||
|
||||
const found = await findOpencodeServer();
|
||||
if (!found) return { error: NextResponse.json({ error: SETUP_ERROR }, { status: 503 }) } as const;
|
||||
return { baseUrl: found.baseUrl } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET/PUT/DELETE /api/ai/opencode/providers — lets a user add "any LLM
|
||||
* OpenCode supports" from inside this app, rather than only whatever was
|
||||
* already authenticated via its own CLI. See lib/ai/opencode.ts's module
|
||||
* note on why this only covers API-key providers for now, not OAuth ones.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const result = await requireOpencode(request);
|
||||
if ('error' in result) return result.error;
|
||||
try {
|
||||
const providers = await listOpencodeProviders(result.baseUrl);
|
||||
return NextResponse.json({ providers }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (cause) {
|
||||
logger.error('opencode providers list failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return NextResponse.json({ error: 'Could not list OpenCode providers' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const result = await requireOpencode(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
let body: { providerID?: unknown; key?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
const providerID = typeof body.providerID === 'string' ? body.providerID.trim() : '';
|
||||
const key = typeof body.key === 'string' ? body.key.trim() : '';
|
||||
if (!providerID || !key) {
|
||||
return NextResponse.json({ error: 'providerID and key are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await setOpencodeProviderKey(result.baseUrl, providerID, key);
|
||||
// VERIFY rather than trust the 200: OpenCode accepts a bare API key for
|
||||
// every provider (confirmed live), but does not consider every provider
|
||||
// "connected" from that alone - Snowflake Cortex, for one real example,
|
||||
// needs SNOWFLAKE_ACCOUNT alongside its token, and a single key field
|
||||
// silently leaves it unconnected with no error from the PUT itself. The
|
||||
// provider's own `env` array length does NOT predict this reliably either
|
||||
// (Azure needs two env vars and DOES connect from one key) - the only
|
||||
// honest source of truth is asking OpenCode again.
|
||||
const after = await listOpencodeProviders(result.baseUrl);
|
||||
const nowConnected = after.find((p) => p.id === providerID)?.connected === true;
|
||||
if (!nowConnected) {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
error: `OpenCode stored the key but does not show ${providerID} as connected — it likely needs more than one credential field (check its requirements with the opencode CLI: opencode auth login ${providerID}).`,
|
||||
}, { status: 200 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (cause) {
|
||||
logger.error('opencode provider auth failed', { providerID, error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return NextResponse.json({ error: cause instanceof Error ? cause.message : 'Could not add the provider' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const result = await requireOpencode(request);
|
||||
if ('error' in result) return result.error;
|
||||
|
||||
const providerID = request.nextUrl.searchParams.get('providerID')?.trim();
|
||||
if (!providerID) {
|
||||
return NextResponse.json({ error: 'providerID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await removeOpencodeProvider(result.baseUrl, providerID);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (cause) {
|
||||
logger.error('opencode provider removal failed', { providerID, error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return NextResponse.json({ error: cause instanceof Error ? cause.message : 'Could not remove the provider' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { DEFAULT_AI_ENTITLEMENT, type AiPolicy } from '@/lib/ai/types';
|
||||
|
||||
/**
|
||||
* GET /api/ai/policy - AI Assistant policy (NOT admin-protected - users read this)
|
||||
*
|
||||
* `enabled` mirrors the admin FeatureGates toggle. `entitlement.classes`
|
||||
* reflects real configuration, not a hardcoded guess: `server` only appears
|
||||
* when AI_SERVER_BASE_URL is actually set (app/api/ai/server/* would 503
|
||||
* otherwise) - this is enforcement point 1 (docs §10), cosmetic-only, the
|
||||
* client hiding what it can't use; the real gate is checkAndAssignSeat() on
|
||||
* every /api/ai/server/chat call, not this list.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
const policy = configManager.getPolicy();
|
||||
const consoleConfig = configManager.getAiConsoleConfig();
|
||||
|
||||
// A class must be BOTH infra-available AND not explicitly disabled by
|
||||
// the admin console (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6) to reach
|
||||
// users. Missing classesEnabled entries default to allowed, so this
|
||||
// changes nothing until an admin actually touches the console.
|
||||
const classAllowed = (cls: (typeof DEFAULT_AI_ENTITLEMENT.classes)[number]) => consoleConfig.classesEnabled[cls] !== false;
|
||||
const classes: typeof DEFAULT_AI_ENTITLEMENT.classes = [];
|
||||
if (classAllowed('local')) classes.push('local');
|
||||
if (classAllowed('public')) classes.push('public');
|
||||
if (process.env.AI_SERVER_BASE_URL && classAllowed('server')) classes.push('server');
|
||||
// `opencode` is offered whenever the admin hasn't disabled it — unlike
|
||||
// `server` there is no env var to gate on, because availability is "is a
|
||||
// local `opencode serve` listening right now", which changes minute to
|
||||
// minute and is answered by /api/ai/opencode/models (503 when absent).
|
||||
// Advertising the class and letting that probe report the truth beats
|
||||
// hiding it based on a stale check at policy-fetch time.
|
||||
if (classAllowed('opencode')) classes.push('opencode');
|
||||
|
||||
const aiPolicy: AiPolicy = {
|
||||
enabled: policy.features.aiAssistantEnabled,
|
||||
entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes },
|
||||
publicConsentVersion: consoleConfig.consent?.version ?? null,
|
||||
retrievalEnabled: consoleConfig.retrievalEnabled,
|
||||
consent: consoleConfig.consent,
|
||||
publicProviderAllowlist: consoleConfig.publicProviderAllowlist,
|
||||
};
|
||||
|
||||
return NextResponse.json(aiPolicy, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('AI policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { serverSearchMail, hydrateMailRefs } from '@/lib/ai/retrieval/mail-embeddings';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_QUERY_CHARS = 512;
|
||||
const DEFAULT_LIMIT = 6;
|
||||
|
||||
/**
|
||||
* POST /api/ai/retrieve — the server embedding leg (docs/AI-ASSISTANT-CONCEPT.md
|
||||
* §7 step 2). Real JMAP fetch + real Ollama embeddings + real cosine ranking
|
||||
* (lib/ai/retrieval/mail-embeddings.ts), not a mock.
|
||||
*
|
||||
* ACL note (§7 step 2b): this only ever embeds/searches the *authenticated
|
||||
* session's own* JMAP account — there is no shared-mailbox fan-out to
|
||||
* pre-filter yet, since group accounts are still deferred entirely (matches
|
||||
* the doc's own "shared-mailbox retrieval ships server-only" decision, which
|
||||
* itself hasn't been reached because there's no group account to retrieve
|
||||
* from). Nothing here can leak across accounts because nothing crosses the
|
||||
* account boundary in the first place.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await getStalwartCredentials(request);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!process.env.AI_SERVER_BASE_URL) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
|
||||
// §6): an admin can disable mail-content-to-embeddings augmentation
|
||||
// independent of disabling the `server` chat class outright.
|
||||
await configManager.ensureLoaded();
|
||||
if (!configManager.getAiConsoleConfig().retrievalEnabled) {
|
||||
return NextResponse.json({ error: 'retrieval is disabled by admin policy' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: { query?: unknown; limit?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const query = typeof body.query === 'string' ? body.query.trim() : '';
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: 'query is required' }, { status: 400 });
|
||||
}
|
||||
if (query.length > MAX_QUERY_CHARS) {
|
||||
return NextResponse.json({ error: 'query too long' }, { status: 400 });
|
||||
}
|
||||
const limit = typeof body.limit === 'number' ? Math.min(Math.max(Math.trunc(body.limit), 1), 20) : DEFAULT_LIMIT;
|
||||
|
||||
try {
|
||||
const scored = await serverSearchMail(auth.serverUrl, auth.authHeader, query, limit);
|
||||
const chunks = await hydrateMailRefs(auth.serverUrl, auth.authHeader, scored.map((s) => s.ref));
|
||||
|
||||
const contextBlock = chunks
|
||||
.map((c, i) => `[${i + 1}] Subject: ${c.title}\n${c.text}`)
|
||||
.join('\n\n');
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
hits: chunks.map((c, i) => ({ ref: c.ref, title: c.title, snippet: c.text.slice(0, 200), rank: i + 1 })),
|
||||
contextBlock,
|
||||
}, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (cause) {
|
||||
logger.error('ai retrieve failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return NextResponse.json({ error: 'retrieval unavailable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { checkAndAssignSeat, recordUsage } from '@/lib/ai/entitlement';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_BODY_BYTES = 200 * 1024;
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface OllamaChatResponse {
|
||||
message?: { content?: string };
|
||||
prompt_eval_count?: number;
|
||||
eval_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/server/chat — the one real enforcement chokepoint for the
|
||||
* `server` AI class (docs/AI-ASSISTANT-CONCEPT.md §10 point 2: "re-validates
|
||||
* ... entitlement against live state; rejects on mismatch ... never trusts
|
||||
* the client"). Every call re-checks the seat; nothing here is cosmetic.
|
||||
*
|
||||
* Retrieval already happened client-side (the same /api/offline/search leg
|
||||
* `local`/`public` use) — this route receives the already-built prompt
|
||||
* messages and only proxies the model call + records the metering entry
|
||||
* that IS the billing record (lib/ai/entitlement.ts).
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const auth = await getStalwartCredentials(request);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Real enforcement, not cosmetic client hiding (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md
|
||||
// §6): the admin console can disable the whole `server` class even when
|
||||
// AI_SERVER_BASE_URL stays configured (e.g. keeping infra up for staging
|
||||
// while turning it off for users).
|
||||
await configManager.ensureLoaded();
|
||||
if (configManager.getAiConsoleConfig().classesEnabled.server === false) {
|
||||
return NextResponse.json({ error: 'the server-hosted AI class is disabled by admin policy' }, { status: 403 });
|
||||
}
|
||||
|
||||
const seat = await checkAndAssignSeat(auth.username);
|
||||
if (!seat.allowed) {
|
||||
return NextResponse.json({ error: seat.reason ?? 'not entitled' }, { status: 402 });
|
||||
}
|
||||
|
||||
const rawBody = await request.text();
|
||||
if (rawBody.length > MAX_BODY_BYTES) {
|
||||
return NextResponse.json({ error: 'request too large' }, { status: 413 });
|
||||
}
|
||||
|
||||
let body: { model?: unknown; messages?: unknown };
|
||||
try {
|
||||
body = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const model = typeof body.model === 'string' ? body.model : '';
|
||||
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
|
||||
if (!model || !messages || messages.length === 0) {
|
||||
return NextResponse.json({ error: 'model and messages are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
|
||||
if (allowlist && !allowlist.includes(model)) {
|
||||
return NextResponse.json({ error: `model "${model}" is not on the admin allow-list` }, { status: 403 });
|
||||
}
|
||||
|
||||
const baseUrl = process.env.AI_SERVER_BASE_URL;
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, messages, stream: false }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: `AI server returned ${res.status}` }, { status: 502 });
|
||||
}
|
||||
const data = (await res.json()) as OllamaChatResponse;
|
||||
const content = data.message?.content;
|
||||
if (!content) {
|
||||
return NextResponse.json({ error: 'AI server returned no message content' }, { status: 502 });
|
||||
}
|
||||
|
||||
await recordUsage({
|
||||
timestamp: new Date().toISOString(),
|
||||
username: auth.username,
|
||||
model,
|
||||
promptTokens: data.prompt_eval_count ?? 0,
|
||||
completionTokens: data.eval_count ?? 0,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
});
|
||||
|
||||
return NextResponse.json({ answer: content, seatJustAssigned: seat.seatJustAssigned === true });
|
||||
} catch (cause) {
|
||||
logger.error('ai server chat failed', { error: cause instanceof Error ? cause.message : String(cause) });
|
||||
return NextResponse.json({ error: 'AI server unreachable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/ai/server/models — list models on the centrally-hosted `server`
|
||||
* class runtime (docs/AI-ASSISTANT-CONCEPT.md §2.1: "the same self-hosted
|
||||
* open-weight model stack as `local`... running on VNC's own infrastructure
|
||||
* instead of the user's laptop"). Tonight, `AI_SERVER_BASE_URL` stands in for
|
||||
* that infra with the Ollama already running on this developer's Mac — see
|
||||
* the module comment in lib/ai/entitlement.ts. Swapping to the real
|
||||
* EU/CH-hosted instance tomorrow is a config change, not a rewrite.
|
||||
*
|
||||
* Listing models is not a billable action (doc §10 point 1 — cosmetic), so
|
||||
* this only requires a valid session, not a seat.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await getStalwartCredentials(request);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const baseUrl = process.env.AI_SERVER_BASE_URL;
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json({ error: 'AI server class is not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/tags`);
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: `upstream returned ${res.status}` }, { status: 502 });
|
||||
}
|
||||
const body = (await res.json()) as { models?: Array<{ name: string; capabilities?: string[] }> };
|
||||
// Excludes embedding-only models (e.g. nomic-embed-text, used by
|
||||
// lib/ai/retrieval/mail-embeddings.ts) from the *chat* picker — Ollama
|
||||
// lists them in the same /api/tags response, but calling /api/chat with
|
||||
// one fails outright. `capabilities` absent (older Ollama) fails open
|
||||
// rather than hiding every model on an upgrade.
|
||||
let chatModels = (body.models ?? []).filter((m) => !m.capabilities || m.capabilities.includes('completion'));
|
||||
|
||||
// Admin allow-list (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6). null = every
|
||||
// completion-capable model (today's behavior, unchanged).
|
||||
await configManager.ensureLoaded();
|
||||
const allowlist = configManager.getAiConsoleConfig().serverModelAllowlist;
|
||||
if (allowlist) {
|
||||
const allowed = new Set(allowlist);
|
||||
chatModels = chatModels.filter((m) => allowed.has(m.name));
|
||||
}
|
||||
|
||||
return NextResponse.json({ models: chatModels.map((m) => m.name).filter(Boolean) });
|
||||
} catch (cause) {
|
||||
return NextResponse.json(
|
||||
{ error: cause instanceof Error ? cause.message : 'AI server unreachable' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,8 @@ function impersonationCookieOptions() {
|
||||
* Master-user impersonation via signed JWT. The token carries the target
|
||||
* mailbox; Bulwark verifies the signature, resolves the configured Stalwart
|
||||
* master credentials from env, then mints the same session cookies the
|
||||
* password-login path produces. The browser is redirected to "/" and the
|
||||
* password-login path produces. The browser is redirected to "/?impersonated=1" (see
|
||||
* ImpersonationReconciler, GH #646) and the
|
||||
* SPA hydrates as if the user had just logged in with master@target%master.
|
||||
*
|
||||
* Returns 404 when the feature is not configured so an unconfigured
|
||||
@@ -136,6 +137,6 @@ export async function GET(request: NextRequest) {
|
||||
// when running behind a reverse proxy that doesn't set X-Forwarded-Host.
|
||||
return new NextResponse(null, {
|
||||
status: 303,
|
||||
headers: { Location: '/' },
|
||||
headers: { Location: '/?impersonated=1' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { checkUserAuthRateLimit } from '@/lib/admin/rate-limit';
|
||||
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
@@ -48,6 +49,15 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
const rateLimit = checkUserAuthRateLimit(ip, username);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many login attempts', retryAfterMs: rateLimit.retryAfterMs },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Pin the upstream URL to a configured JMAP server so an unauthenticated
|
||||
// caller cannot point this route at internal hosts. We accept the global
|
||||
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
|
||||
|
||||
@@ -10,6 +10,17 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
|
||||
// TODO(P2.13): Wire SAML IDP integration once VNCdirectory is configured.
|
||||
// When VNCdirectory is enabled and SAML is configured (see
|
||||
// lib/admin/vncdirectory-config.ts), the SSO start flow should:
|
||||
// 1. Check isVncDirectoryEnabled() — if false, fall through to existing OAuth flow.
|
||||
// 2. Read getVncDirectoryConfig() for samlIdpUrl, samlIssuer, samlSpCert.
|
||||
// 3. Build a SAML AuthnRequest and redirect to the IdP instead of OAuth.
|
||||
// 4. The /sso/complete handler should process the SAML Response assertion,
|
||||
// validate the signature against the SP certificate, extract the subject,
|
||||
// and create a session.
|
||||
// Reference: docs/admin/VNCDIRECTORY.md in the VNCmail+ plan (P2.13).
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oaut
|
||||
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { checkUserAuthRateLimit } from '@/lib/admin/rate-limit';
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
@@ -16,6 +17,15 @@ function getSlot(request: NextRequest): number {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
const rateLimit = checkUserAuthRateLimit(ip, 'oauth-token');
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many token requests', retryAfterMs: rateLimit.retryAfterMs },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!code || !code_verifier || !redirect_uri) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCollaboraEditUrl } from "@/lib/collabora/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.fileId || !body.fileName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: fileId, fileName" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = await getCollaboraEditUrl(
|
||||
String(body.fileId),
|
||||
String(body.fileName)
|
||||
);
|
||||
|
||||
return NextResponse.json({ url });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error("Collabora edit URL failed", { error: message });
|
||||
|
||||
if (message.includes("not configured")) {
|
||||
return NextResponse.json({ error: message }, { status: 503 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,17 @@ export async function GET(request: NextRequest) {
|
||||
return configManager.get<T>(key, fallback);
|
||||
};
|
||||
|
||||
// Whether a logo field was actually set by an operator (Branding tab,
|
||||
// an env var, or a per-domain override) rather than left at its default -
|
||||
// consumed by resolveThemeLogo() so an explicit choice here wins over the
|
||||
// active theme's own built-in logo, instead of being silently shadowed by
|
||||
// it. See lib/theme-logo.ts.
|
||||
const configSources = configManager.getAllWithSources();
|
||||
const isLogoOverridden = (key: BrandingOverrideKey): boolean =>
|
||||
typeof domainOverrides[key] === 'string' && domainOverrides[key]!.length > 0
|
||||
? true
|
||||
: configSources[key]?.source !== 'default';
|
||||
|
||||
const appName =
|
||||
branded<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||
@@ -68,8 +79,12 @@ export async function GET(request: NextRequest) {
|
||||
faviconUrl: branded<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
|
||||
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
|
||||
appLogoLightUrlIsCustom: isLogoOverridden('appLogoLightUrl'),
|
||||
appLogoDarkUrlIsCustom: isLogoOverridden('appLogoDarkUrl'),
|
||||
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
|
||||
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
|
||||
loginLogoLightUrlIsCustom: isLogoOverridden('loginLogoLightUrl'),
|
||||
loginLogoDarkUrlIsCustom: isLogoOverridden('loginLogoDarkUrl'),
|
||||
loginCompanyName: branded<string>('loginCompanyName', ''),
|
||||
loginImprintUrl: branded<string>('loginImprintUrl', ''),
|
||||
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
|
||||
|
||||
+561
-308
File diff suppressed because one or more lines are too long
@@ -0,0 +1,87 @@
|
||||
// GET /api/offline/mail?kind=mailboxes|list|message - the OFFLINE READ SURFACE.
|
||||
//
|
||||
// THIS ROUTE MUST NEVER MAKE A NETWORK CALL. That is the whole feature: it is
|
||||
// consulted precisely when the backend is unreachable, so a JMAP session fetch to
|
||||
// learn the account id would fail for the exact reason the route was called. The
|
||||
// account is resolved from the request's own encrypted `jmap_stalwart_ctx` cookie
|
||||
// (a local decrypt) and from the account ids the store already holds rows for.
|
||||
//
|
||||
// It is a FALLBACK, not a cache in front of the server - see `read.ts`'s header
|
||||
// for the coherence rules that depend on that, and `lib/offline-fallback-client.ts`
|
||||
// for the one place that decides a read has genuinely failed.
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
readEnvelopePage, readMailboxes, readMessage,
|
||||
} from '@/lib/offline-replica/read';
|
||||
import {
|
||||
resolveIndexSession, resolveReadAccountId, withReplica,
|
||||
} from '@/lib/offline-replica/engine';
|
||||
import { gateReplicaRoute, NO_STORE, replicaErrorResponse } from '@/lib/offline-replica/route-gate';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const gated = gateReplicaRoute();
|
||||
if (gated) return gated;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const kind = params.get('kind') ?? 'mailboxes';
|
||||
if (kind !== 'mailboxes' && kind !== 'list' && kind !== 'message') {
|
||||
return NextResponse.json({ error: 'kind must be mailboxes, list or message' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
const payload = await withReplica(session.accountId, (store) => {
|
||||
const jmapAccountId = resolveReadAccountId(store, params.get('jmapAccountId'));
|
||||
if (!jmapAccountId) {
|
||||
// Nothing synced yet for this account. Not an error - the caller falls
|
||||
// back to whatever it would have shown without a replica.
|
||||
return { empty: true as const };
|
||||
}
|
||||
|
||||
if (kind === 'mailboxes') {
|
||||
return { empty: false as const, jmapAccountId, mailboxes: readMailboxes(store, jmapAccountId) };
|
||||
}
|
||||
|
||||
if (kind === 'list') {
|
||||
const rawLimit = Number(params.get('limit') ?? '50');
|
||||
const limit = Number.isFinite(rawLimit)
|
||||
? Math.min(Math.max(Math.trunc(rawLimit), 1), MAX_LIMIT)
|
||||
: 50;
|
||||
const rawOffset = Number(params.get('offset') ?? '0');
|
||||
const offset = Number.isFinite(rawOffset) ? Math.max(Math.trunc(rawOffset), 0) : 0;
|
||||
// An absent mailboxId means "everything", which is what the unified views
|
||||
// ask for; an empty string is a caller bug and must not silently widen.
|
||||
const mailboxParam = params.get('mailboxId');
|
||||
const mailboxId = mailboxParam === null ? null : mailboxParam;
|
||||
if (mailboxId === '') {
|
||||
return { empty: true as const };
|
||||
}
|
||||
const page = readEnvelopePage(store, jmapAccountId, mailboxId, limit, offset);
|
||||
return { empty: false as const, jmapAccountId, ...page };
|
||||
}
|
||||
|
||||
const id = params.get('id');
|
||||
if (!id || id.length > 256) return { empty: true as const };
|
||||
const message = readMessage(store, jmapAccountId, id);
|
||||
if (!message) return { empty: false as const, jmapAccountId, email: null, hasBody: false };
|
||||
return {
|
||||
empty: false as const,
|
||||
jmapAccountId,
|
||||
email: message.email,
|
||||
hasBody: message.hasBody,
|
||||
};
|
||||
});
|
||||
|
||||
if (payload.empty) {
|
||||
return NextResponse.json({ ok: true, available: false }, { headers: NO_STORE });
|
||||
}
|
||||
return NextResponse.json({ ok: true, available: true, ...payload }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return replicaErrorResponse(error, 'offline read');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// POST /api/offline/reindex - write mail/calendar/contacts/files into the
|
||||
// encrypted local search index for the calling session's account.
|
||||
//
|
||||
// The PRIMARY caller is the renderer's live JMAP push handler: when a
|
||||
// StateChange arrives it posts the ids that changed, so indexing is reactive to
|
||||
// each delivery rather than periodic. `{ catchUp: true }` (no ids) is the
|
||||
// fallback used at app launch to backfill whatever changed while the app was
|
||||
// closed.
|
||||
//
|
||||
// GATED: returns 404 unless VNCMAIL_DESKTOP_STORE_DIR is set, which only
|
||||
// electron/main.ts does. The same standalone server artifact runs in the
|
||||
// multi-tenant production Docker image, where this feature must not exist at
|
||||
// all - 404 rather than 403 so nothing learns the route is there.
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { isSqlcipherAvailable } from '@/lib/mail-index/binding';
|
||||
import { hasKeyChannel, IndexKeyError } from '@/lib/mail-index/key';
|
||||
import { getStoreDir } from '@/lib/mail-index/paths';
|
||||
import {
|
||||
IndexSessionError, MAX_IDS_PER_CALL, normalizeWindowDays, resolveIndexSession, runIndex,
|
||||
type IndexRequest,
|
||||
} from '@/lib/mail-index/reindex';
|
||||
import { CONTENT_TYPES, isContentType, type ContentType } from '@/lib/mail-index/store';
|
||||
import { JmapIndexError } from '@/lib/mail-index/jmap';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function parseIdMap(raw: unknown): Partial<Record<ContentType, string[]>> | undefined {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
||||
const out: Partial<Record<ContentType, string[]>> = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!isContentType(key) || !Array.isArray(value)) continue;
|
||||
const ids = value
|
||||
.filter((v): v is string => typeof v === 'string' && v.length > 0 && v.length <= 256)
|
||||
.slice(0, MAX_IDS_PER_CALL);
|
||||
if (ids.length > 0) out[key] = ids;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!getStoreDir()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
if (!hasKeyChannel()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'The local index has no key channel in this process.', code: 'no-key-channel' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!isSqlcipherAvailable()) {
|
||||
// The native binding is an optionalDependency, so "not installed" is a
|
||||
// normal state on platforms without a prebuild - not an error to log loudly.
|
||||
return NextResponse.json(
|
||||
{ error: 'Encrypted local index is unavailable on this platform.', code: 'no-binding' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (text.trim()) body = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Malformed JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rawTypes = Array.isArray(body.types) ? body.types.filter(isContentType) : [];
|
||||
const req: IndexRequest = {
|
||||
types: rawTypes.length > 0 ? rawTypes : undefined,
|
||||
ids: parseIdMap(body.ids),
|
||||
removed: parseIdMap(body.removed),
|
||||
// Pruning is a catch-up concern; a single-delivery call shouldn't scan.
|
||||
prune: body.catchUp === true,
|
||||
// `undefined` (absent) means "use the default"; an explicit null means
|
||||
// keep everything. normalizeWindowDays() in runIndex clamps anything
|
||||
// unexpected, since this value drives deletion.
|
||||
windowDays: body.windowDays === undefined ? undefined : normalizeWindowDays(body.windowDays),
|
||||
};
|
||||
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
const result = await runIndex(session, req);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
written: result.written,
|
||||
skipped: result.skipped,
|
||||
errors: result.errors,
|
||||
durationMs: result.durationMs,
|
||||
types: CONTENT_TYPES,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof IndexSessionError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof JmapIndexError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof IndexKeyError) {
|
||||
// no-secure-storage is the Linux-without-a-keyring refusal: a real,
|
||||
// expected outcome with a user-facing explanation, not a server fault.
|
||||
const status = error.code === 'no-secure-storage' ? 503 : 500;
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status });
|
||||
}
|
||||
logger.error('mail-index reindex failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Reindex failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// GET /api/offline/search?q=...&types=mail,calendar&limit=20
|
||||
//
|
||||
// THE RETRIEVAL SURFACE. This is what an AI/RAG feature calls to gather
|
||||
// relevant context from the user's own mail, calendar, contacts and files
|
||||
// before prompting a model - hence the `snippet` on every hit and the
|
||||
// `contextBlock` convenience field, which is the same information already
|
||||
// flattened into text a prompt can carry directly.
|
||||
//
|
||||
// Read-only: it never touches the network and never writes. Gated identically
|
||||
// to the reindex route.
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { isSqlcipherAvailable } from '@/lib/mail-index/binding';
|
||||
import { hasKeyChannel, IndexKeyError, withIndexKey } from '@/lib/mail-index/key';
|
||||
import { getStoreDir } from '@/lib/mail-index/paths';
|
||||
import { IndexSessionError, resolveIndexSession } from '@/lib/mail-index/reindex';
|
||||
import {
|
||||
isContentType, MailIndex, MailIndexUnavailableError, type ContentType, type SearchHit,
|
||||
} from '@/lib/mail-index/store';
|
||||
import { detectRecencyIntent } from '@/lib/mail-index/recency';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* One hit as a plain text block, ready to be concatenated into a prompt.
|
||||
* Kept server-side so every caller (a chat feature, a future agent, a test)
|
||||
* formats context the same way rather than each inventing its own.
|
||||
*/
|
||||
function toContextBlock(hit: SearchHit): string {
|
||||
const label: Record<ContentType, string> = {
|
||||
mail: 'EMAIL', calendar: 'CALENDAR EVENT', contact: 'CONTACT', file: 'FILE',
|
||||
};
|
||||
const lines = [`[${label[hit.contentType]}] ${hit.title}`];
|
||||
if (hit.occurredAt) lines.push(`Date: ${hit.occurredAt}`);
|
||||
if (hit.people) lines.push(`People: ${hit.people}`);
|
||||
const path = hit.metadata?.path;
|
||||
if (typeof path === 'string' && path) lines.push(`Path: ${path}`);
|
||||
if (hit.snippet) lines.push(`Excerpt: ${hit.snippet}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!getStoreDir()) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
if (!hasKeyChannel() || !isSqlcipherAvailable()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Encrypted local index is unavailable in this process.', code: 'unavailable' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const query = (params.get('q') ?? '').trim();
|
||||
const wantStats = params.get('stats') === 'true';
|
||||
|
||||
if (!query && !wantStats) {
|
||||
return NextResponse.json({ error: 'Missing q parameter' }, { status: 400 });
|
||||
}
|
||||
if (query.length > 512) {
|
||||
return NextResponse.json({ error: 'Query too long' }, { status: 400 });
|
||||
}
|
||||
|
||||
const types = (params.get('types') ?? '')
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(isContentType);
|
||||
|
||||
const limitRaw = Number(params.get('limit') ?? '20');
|
||||
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(Math.trunc(limitRaw), 1), 100) : 20;
|
||||
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
const storeDir = getStoreDir();
|
||||
if (!storeDir) return new NextResponse(null, { status: 404 });
|
||||
|
||||
const payload = await withIndexKey(session.accountId, (key) => {
|
||||
const index = MailIndex.open({ storeDir, accountId: session.accountId, key });
|
||||
try {
|
||||
const stats = index.stats();
|
||||
if (!query) return { hits: [] as SearchHit[], stats };
|
||||
// 'any': this route is the AI/RAG retrieval surface (see module
|
||||
// header) - its one real caller sends natural-language questions,
|
||||
// not deliberate search-box keywords, so strict AND-every-token
|
||||
// matching (the default) drops nearly all of them. See
|
||||
// toFtsMatchQueryAny's docstring for the confirmed-live failure.
|
||||
const keywordHits = index.search({ query, types, limit, mode: 'any' });
|
||||
|
||||
// RECENCY leg. Keyword search structurally cannot answer "the last
|
||||
// mail" or "everything from July" (see lib/mail-index/recency.ts), so
|
||||
// when the question is really about time, add a date-ordered slice.
|
||||
// ADDED to the keyword hits rather than replacing them: "what did the
|
||||
// last mail from Anna say" is both a time question and a content one.
|
||||
const intent = detectRecencyIntent(query);
|
||||
if (!intent) {
|
||||
return { hits: keywordHits, stats: wantStats ? stats : undefined };
|
||||
}
|
||||
const recentHits = index.recent({
|
||||
types, limit: Math.min(intent.limit, limit * 3), since: intent.since, until: intent.until,
|
||||
});
|
||||
const seen = new Set(keywordHits.map((h) => `${h.contentType}:${h.id}`));
|
||||
const merged = [...keywordHits];
|
||||
for (const hit of recentHits) {
|
||||
const key = `${hit.contentType}:${hit.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(hit);
|
||||
}
|
||||
return { hits: merged, stats: wantStats ? stats : undefined, recency: intent };
|
||||
} finally {
|
||||
index.close();
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
query,
|
||||
types: types.length > 0 ? types : 'all',
|
||||
count: payload.hits.length,
|
||||
hits: payload.hits,
|
||||
// Everything a prompt needs, pre-joined in rank order.
|
||||
contextBlock: payload.hits.map(toContextBlock).join('\n\n---\n\n'),
|
||||
...(payload.stats ? { stats: payload.stats } : {}),
|
||||
// Present when the question was read as a time question — lets the
|
||||
// client say "these are the newest N" instead of implying relevance
|
||||
// ranking it did not do.
|
||||
...(payload.recency ? { recency: payload.recency } : {}),
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof IndexSessionError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof IndexKeyError) {
|
||||
const status = error.code === 'no-secure-storage' ? 503 : 500;
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status });
|
||||
}
|
||||
if (error instanceof MailIndexUnavailableError) {
|
||||
return NextResponse.json({ error: error.message, code: 'unavailable' }, { status: 503 });
|
||||
}
|
||||
logger.error('mail-index search failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Search failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// GET /api/offline/status - size, freshness and retention policy, for Settings.
|
||||
// PUT /api/offline/status - update the retention policy.
|
||||
// DELETE /api/offline/status - purge the replica.
|
||||
//
|
||||
// The POLICY LIVES IN THE ENCRYPTED STORE, not in renderer localStorage. The
|
||||
// design review's H1 was that a server-side engine cannot read a renderer-only
|
||||
// setting; keeping the policy server-side means the retention pass always has the
|
||||
// value it needs, while the DECISION TO SYNC AT ALL stays with the renderer, so
|
||||
// nothing is ever materialised for an account that never opted in.
|
||||
//
|
||||
// Like every read here, GET makes no network call: an offline user must still be
|
||||
// able to see what they have and free the space.
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { clampPolicy, POLICY_LIMITS, type RetentionPolicy } from '@/lib/offline-replica/store';
|
||||
import { resolveIndexSession, resolveReadAccountId, withReplica } from '@/lib/offline-replica/engine';
|
||||
import { gateReplicaRoute, NO_STORE, replicaErrorResponse } from '@/lib/offline-replica/route-gate';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const gated = gateReplicaRoute();
|
||||
if (gated) return gated;
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
const payload = await withReplica(session.accountId, (store) => {
|
||||
const jmapAccountId = resolveReadAccountId(store, null);
|
||||
const policy = store.getPolicy();
|
||||
const flags = store.getFlags(Date.now());
|
||||
if (!jmapAccountId) {
|
||||
return {
|
||||
policy,
|
||||
limits: POLICY_LIMITS,
|
||||
synced: false,
|
||||
stats: null,
|
||||
coveragePhase: 'never-run',
|
||||
resyncRequired: flags.resyncRequired,
|
||||
lastCycleAt: flags.lastCycleAt ?? null,
|
||||
lastCycleOk: flags.lastCycleOk ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
policy,
|
||||
limits: POLICY_LIMITS,
|
||||
synced: true,
|
||||
stats: store.stats(jmapAccountId),
|
||||
coveragePhase: store.getCoverage(jmapAccountId)?.phase ?? 'never-run',
|
||||
coveredFrom: store.getCoverage(jmapAccountId)?.coveredFrom ?? null,
|
||||
resyncRequired: flags.resyncRequired,
|
||||
lastCycleAt: flags.lastCycleAt ?? null,
|
||||
lastCycleOk: flags.lastCycleOk ?? null,
|
||||
lastCycleError: flags.lastCycleError ?? null,
|
||||
};
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...payload }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return replicaErrorResponse(error, 'offline status');
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const gated = gateReplicaRoute();
|
||||
if (gated) return gated;
|
||||
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (text.trim()) body = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Malformed JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const policy = clampPolicy(body as Partial<RetentionPolicy>);
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
await withReplica(session.accountId, (store) => {
|
||||
store.transaction(() => { store.setPolicy(policy); });
|
||||
});
|
||||
// The cycle applies it: a widen re-enters coverage scanning, a narrow evicts,
|
||||
// and the clock guard is told this was INTENT rather than a glitch by the
|
||||
// `lastEnvelopeDays` it compares against.
|
||||
return NextResponse.json({ ok: true, policy }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return replicaErrorResponse(error, 'offline policy update');
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const gated = gateReplicaRoute();
|
||||
if (gated) return gated;
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
await withReplica(session.accountId, (store) => {
|
||||
// ALL OF IT, cursors included. A record wipe that leaves cursors behind is
|
||||
// the one state no amount of syncing repairs: `/changes` structurally cannot
|
||||
// re-deliver mail that already existed when the cursor was captured, so the
|
||||
// next cycle would advance a live cursor over an empty store forever.
|
||||
store.transaction(() => { store.purgeAll(); });
|
||||
});
|
||||
return NextResponse.json({ ok: true, purged: true }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return replicaErrorResponse(error, 'offline purge');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// POST /api/offline/sync - run ONE bounded delta-sync cycle for the calling
|
||||
// session's account.
|
||||
//
|
||||
// The renderer drives this: once at launch (catch-up for whatever changed while
|
||||
// the app was closed, for which no push event was ever delivered) and on each
|
||||
// JMAP `StateChange` from the live push connection. There is no background worker
|
||||
// and no resident credential - see `lib/offline-replica/sync.ts`'s header for why
|
||||
// that architecture choice keeps most of the original design review's critical
|
||||
// findings out of scope entirely.
|
||||
//
|
||||
// A cycle is BOUNDED. `unfinishedWork: true` means "call again", and the renderer
|
||||
// chains with a cap; it never means an error.
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { clampPolicy, type RetentionPolicy } from '@/lib/offline-replica/store';
|
||||
import { resolveIndexSession, syncAccount } from '@/lib/offline-replica/engine';
|
||||
import { gateReplicaRoute, NO_STORE, replicaErrorResponse } from '@/lib/offline-replica/route-gate';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const gated = gateReplicaRoute();
|
||||
if (gated) return gated;
|
||||
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (text.trim()) body = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Malformed JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rawPolicy = body.policy;
|
||||
const policy: RetentionPolicy | undefined =
|
||||
rawPolicy && typeof rawPolicy === 'object' && !Array.isArray(rawPolicy)
|
||||
? clampPolicy(rawPolicy as Partial<RetentionPolicy>)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const session = await resolveIndexSession(request);
|
||||
const report = await syncAccount(session, {
|
||||
policy,
|
||||
forceResync: body.forceResync === true,
|
||||
});
|
||||
return NextResponse.json({ ok: report.ok, report }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return replicaErrorResponse(error, 'sync');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { checkAvailability } from '@/lib/resources/client';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const start = searchParams.get('start');
|
||||
const end = searchParams.get('end');
|
||||
|
||||
if (!start || !end) {
|
||||
return NextResponse.json({ error: 'start and end query parameters are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await checkAvailability(id, start, end);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Resource availability error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { cancelBooking } from '@/lib/resources/client';
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; bookingId: string }> },
|
||||
) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { bookingId } = await params;
|
||||
await cancelBooking(bookingId);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Resource booking cancel error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { bookResource, checkAvailability, getResource } from '@/lib/resources/client';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { start, end, eventId } = body;
|
||||
|
||||
if (!start || !end) {
|
||||
return NextResponse.json({ error: 'start and end are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const resource = await getResource(id);
|
||||
if (!resource) {
|
||||
return NextResponse.json({ error: 'Resource not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { available, conflicts } = await checkAvailability(id, start, end);
|
||||
if (!available) {
|
||||
return NextResponse.json({ error: 'Resource is not available for the requested time', conflicts }, { status: 409 });
|
||||
}
|
||||
|
||||
const booking = await bookResource(id, start, end, creds.username, eventId);
|
||||
return NextResponse.json({ booking }, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Resource booking error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { getResource } from '@/lib/resources/client';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const resource = await getResource(id);
|
||||
if (!resource) {
|
||||
return NextResponse.json({ error: 'Resource not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ resource });
|
||||
} catch (error) {
|
||||
logger.error('Resource get error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { listResources, createResource, getBookingsForEvent } from '@/lib/resources/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type') || undefined;
|
||||
const eventId = searchParams.get('eventId') || undefined;
|
||||
|
||||
if (eventId) {
|
||||
const bookings = await getBookingsForEvent(eventId);
|
||||
return NextResponse.json({ bookings });
|
||||
}
|
||||
|
||||
const resources = await listResources(creds.username, type);
|
||||
return NextResponse.json({ resources });
|
||||
} catch (error) {
|
||||
logger.error('Resources list error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
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 { name, type, location, capacity, description, contactEmail, metadata } = body;
|
||||
|
||||
if (!name || !type || !['room', 'vehicle', 'equipment', 'other'].includes(type)) {
|
||||
return NextResponse.json({ error: 'Name and valid type are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const resource = await createResource(creds.username, {
|
||||
name,
|
||||
type,
|
||||
location,
|
||||
capacity: capacity ? Number(capacity) : undefined,
|
||||
description,
|
||||
contactEmail,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return NextResponse.json({ resource }, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Resource create error', { error: error instanceof Error ? error.message : 'Unknown' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
type JmapMethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
async function jmapRequest(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
methodCalls: JmapMethodCall[],
|
||||
using?: string[],
|
||||
) {
|
||||
const sessionResp = await fetch(`${serverUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: authHeader },
|
||||
});
|
||||
if (!sessionResp.ok) {
|
||||
return { error: `Session fetch failed: ${sessionResp.status}` };
|
||||
}
|
||||
const session = await sessionResp.json();
|
||||
const apiUrl = session.apiUrl;
|
||||
if (!apiUrl) {
|
||||
return { error: "No API URL in JMAP session" };
|
||||
}
|
||||
|
||||
const body = {
|
||||
using: using || [
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:principals",
|
||||
],
|
||||
methodCalls,
|
||||
};
|
||||
|
||||
const resp = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return { error: `JMAP request failed: ${resp.status}` };
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get("action");
|
||||
const serverUrl = request.headers.get("X-JMAP-Server-Url");
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
|
||||
if (!serverUrl || !authHeader) {
|
||||
return Response.json(
|
||||
{ error: "Missing server URL or auth header" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action !== "principals") {
|
||||
return Response.json(
|
||||
{ error: "Invalid action" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await jmapRequest(serverUrl, authHeader, [
|
||||
["Principal/query", { accountId: "" }, "0"],
|
||||
["Principal/get", {
|
||||
accountId: "",
|
||||
"#ids": {
|
||||
resultOf: "0",
|
||||
name: "Principal/query",
|
||||
path: "/ids",
|
||||
},
|
||||
}, "1"],
|
||||
]);
|
||||
|
||||
if ("error" in result) {
|
||||
return Response.json(result, { status: 502 });
|
||||
}
|
||||
|
||||
const getResp = (result as Record<string, unknown>).methodResponses as Array<[string, Record<string, unknown>, string]> | undefined;
|
||||
const principals = getResp?.find((r) => r[0] === "Principal/get")?.[1]
|
||||
?.list ?? [];
|
||||
|
||||
return Response.json({ principals });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const serverUrl = request.headers.get("X-JMAP-Server-Url");
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
|
||||
if (!serverUrl || !authHeader) {
|
||||
return Response.json(
|
||||
{ error: "Missing server URL or auth header" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { kind, resourceId, principalId, role } = body;
|
||||
|
||||
if (!kind || !resourceId || !principalId) {
|
||||
return Response.json(
|
||||
{ error: "Missing required fields: kind, resourceId, principalId" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let method: string;
|
||||
let shareProperty: string;
|
||||
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
method = "Mailbox/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
case "calendar":
|
||||
method = "Calendar/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
case "addressBook":
|
||||
method = "AddressBook/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
case "file":
|
||||
method = "FileNode/set";
|
||||
shareProperty = "shareWith";
|
||||
break;
|
||||
default:
|
||||
return Response.json(
|
||||
{ error: `Invalid kind: ${kind}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const patchValue = role === null ? null : buildRights(kind as string, role as string);
|
||||
|
||||
const methodCalls: JmapMethodCall[] = [
|
||||
[
|
||||
method,
|
||||
{
|
||||
accountId: "",
|
||||
update: {
|
||||
[resourceId as string]: {
|
||||
[`${shareProperty}/${principalId}`]: patchValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
"0",
|
||||
],
|
||||
];
|
||||
|
||||
const result = await jmapRequest(
|
||||
serverUrl,
|
||||
authHeader,
|
||||
methodCalls,
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
return Response.json(result, { status: 502 });
|
||||
}
|
||||
|
||||
const responses = (result as Record<string, unknown>).methodResponses as Array<[string, Record<string, unknown>, string]> | undefined;
|
||||
const setResult = responses?.[0]?.[1];
|
||||
|
||||
if (
|
||||
setResult &&
|
||||
typeof setResult === "object" &&
|
||||
"notUpdated" in setResult &&
|
||||
setResult.notUpdated &&
|
||||
typeof setResult.notUpdated === "object" &&
|
||||
(resourceId as string) in setResult.notUpdated
|
||||
) {
|
||||
const err = (setResult.notUpdated as Record<string, Record<string, unknown>>)[resourceId as string];
|
||||
return Response.json(
|
||||
{ error: err.description || "Failed to update share" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
function buildRights(
|
||||
kind: string,
|
||||
role: string,
|
||||
): Record<string, boolean> | null {
|
||||
if (role === null) return null;
|
||||
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return mailboxRights(role);
|
||||
case "calendar":
|
||||
return calendarRights(role);
|
||||
case "addressBook":
|
||||
return addressBookRights(role);
|
||||
case "file":
|
||||
return fileRights(role);
|
||||
default:
|
||||
return readRights();
|
||||
}
|
||||
}
|
||||
|
||||
function mailboxRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayReadItems: true,
|
||||
mayAddItems: false,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: false,
|
||||
maySetKeywords: false,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: true,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
mayShare: true,
|
||||
};
|
||||
default:
|
||||
return mailboxRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function calendarRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: false,
|
||||
mayWriteOwn: false,
|
||||
mayUpdatePrivate: false,
|
||||
mayRSVP: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayShare: true,
|
||||
mayDelete: true,
|
||||
};
|
||||
default:
|
||||
return calendarRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function addressBookRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayWrite: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayWrite: true,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayWrite: true,
|
||||
mayShare: true,
|
||||
mayDelete: true,
|
||||
};
|
||||
default:
|
||||
return addressBookRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function fileRights(role: string): Record<string, boolean> {
|
||||
switch (role) {
|
||||
case "read":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayAddChildren: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
mayModifyContent: false,
|
||||
mayShare: false,
|
||||
};
|
||||
case "readWrite":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: false,
|
||||
};
|
||||
case "manager":
|
||||
return {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: true,
|
||||
};
|
||||
default:
|
||||
return fileRights("read");
|
||||
}
|
||||
}
|
||||
|
||||
function readRights(): Record<string, boolean> {
|
||||
return { mayRead: true };
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* S/MIME certificate enrolment (`C-08`, server half).
|
||||
*
|
||||
* The plugin generates a keypair in the browser and sends only a CSR here. The
|
||||
* private key never leaves the device — this route never sees it and has no way
|
||||
* to ask for it.
|
||||
*
|
||||
* What this route exists to decide: **which addresses the issued certificate is
|
||||
* allowed to assert.** That question cannot be answered in the browser, and it
|
||||
* must not be answered by the CSR — a CSR is a self-assertion, and honouring its
|
||||
* `subjectAltName` would let anyone mint a certificate for any address, which is
|
||||
* indistinguishable from having no CA at all.
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
import { readStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||
import { CaError, getCaProvider } from '@/lib/smime-ca';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_CSR_BYTES = 8 * 1024;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const provider = getCaProvider();
|
||||
if (!provider) {
|
||||
return NextResponse.json(
|
||||
{ error: 'S/MIME enrolment is not configured on this server' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: { csrPem?: unknown; slot?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const csrPem = typeof body.csrPem === 'string' ? body.csrPem.trim() : '';
|
||||
if (!csrPem) {
|
||||
return NextResponse.json({ error: 'csrPem is required' }, { status: 400 });
|
||||
}
|
||||
if (csrPem.length > MAX_CSR_BYTES) {
|
||||
return NextResponse.json({ error: 'csrPem too large' }, { status: 413 });
|
||||
}
|
||||
// Shape check only. This is not a security control — see the module comment on
|
||||
// why the CSR's contents are not trusted regardless of what they contain.
|
||||
if (!/^-----BEGIN (NEW )?CERTIFICATE REQUEST-----[\s\S]+-----END (NEW )?CERTIFICATE REQUEST-----$/
|
||||
.test(csrPem)) {
|
||||
return NextResponse.json({ error: 'csrPem is not a PEM PKCS#10 request' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = Number.isInteger(body.slot) ? (body.slot as number) : 0;
|
||||
if (slot < 0 || slot > 9) {
|
||||
return NextResponse.json({ error: 'invalid slot' }, { status: 400 });
|
||||
}
|
||||
|
||||
// The auth context is an encrypted, server-minted cookie, so `username` cannot
|
||||
// be forged by the client. It still isn't sufficient on its own — see below.
|
||||
const auth = await readStalwartAuthContext(slot);
|
||||
if (!auth) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
let identity: { addresses: string[]; displayName?: string };
|
||||
try {
|
||||
identity = await resolveIdentity(auth.serverUrl, auth.authHeader);
|
||||
} catch (cause) {
|
||||
console.error('[smime-enroll] identity resolution failed:', cause);
|
||||
return NextResponse.json(
|
||||
{ error: 'could not confirm your sending addresses with the mail server' },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
if (identity.addresses.length === 0) {
|
||||
// An authenticated principal with no sending identity — an admin-only
|
||||
// account, or a mailbox with submission disabled. Refuse rather than falling
|
||||
// back to the cookie's username, which would issue a certificate for an
|
||||
// address the mail server will not actually let this account send from.
|
||||
return NextResponse.json(
|
||||
{ error: 'this account has no sending address, so no certificate can be issued for it' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const issued = await provider.enroll({
|
||||
csrPem,
|
||||
addresses: identity.addresses,
|
||||
commonName: identity.displayName || identity.addresses[0],
|
||||
});
|
||||
|
||||
// Audit before returning. A certificate that exists with no record of who
|
||||
// asked for it is the thing you most want during an incident.
|
||||
console.info(
|
||||
`[smime-enroll] issued serial=${issued.serialNumber} ca=${provider.id} `
|
||||
+ `account=${auth.username} addresses=${identity.addresses.join(',')}`,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
certificatePem: issued.certificatePem,
|
||||
chainPem: issued.chainPem,
|
||||
serialNumber: issued.serialNumber,
|
||||
issuerDn: issued.issuerDn,
|
||||
notAfter: issued.notAfter,
|
||||
addresses: identity.addresses,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CaError) {
|
||||
console.error(`[smime-enroll] CA error for ${auth.username}:`, error.message, error.cause);
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
console.error('[smime-enroll] unexpected error:', error);
|
||||
return NextResponse.json({ error: 'enrolment failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask Stalwart which addresses this session may send from, via `Identity/get`.
|
||||
*
|
||||
* This is deliberately not derived from the auth cookie's `username`. The right
|
||||
* authority for "may this person have a signing certificate for this address" is
|
||||
* the mail server that already decides "may this person send from this address" —
|
||||
* anything else invents a second, weaker answer to a question already settled.
|
||||
*
|
||||
* It also handles the cases the cookie cannot: an alias the account legitimately
|
||||
* sends as (which should be on the certificate) and an administrative principal
|
||||
* with no mailbox at all (which should get no certificate). The latter is not
|
||||
* hypothetical here — `admin@sandbox.vnc.de` authenticates successfully and has
|
||||
* no mail session, and trusting the cookie would have issued it a certificate.
|
||||
*/
|
||||
async function resolveIdentity(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
): Promise<{ addresses: string[]; displayName?: string }> {
|
||||
const session = await fetchJmapSession(serverUrl, authHeader);
|
||||
if (!session) throw new Error('no JMAP session');
|
||||
|
||||
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
|
||||
if (!accountId) throw new Error('no primary mail account');
|
||||
|
||||
const apiUrl = rebaseApiUrl(session, serverUrl);
|
||||
if (!apiUrl) throw new Error('session advertises no usable apiUrl');
|
||||
|
||||
const res = await postJmap(apiUrl, authHeader, JSON.stringify({
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission'],
|
||||
methodCalls: [['Identity/get', { accountId }, '0']],
|
||||
}));
|
||||
if (!res.ok) throw new Error(`Identity/get returned ${res.status}`);
|
||||
|
||||
const payload = await res.json() as {
|
||||
methodResponses?: [string, { list?: { email?: string; name?: string }[] }, string][];
|
||||
};
|
||||
const first = payload.methodResponses?.[0];
|
||||
if (!first || first[0] !== 'Identity/get') {
|
||||
throw new Error('Identity/get failed');
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const addresses: string[] = [];
|
||||
let displayName: string | undefined;
|
||||
|
||||
for (const entry of first[1]?.list ?? []) {
|
||||
const email = typeof entry.email === 'string' ? entry.email.trim().toLowerCase() : '';
|
||||
// Stalwart can report a wildcard identity (`*@domain`) for accounts allowed
|
||||
// to send as anything in a domain. That is a real capability, but it is not
|
||||
// an address and must never reach a certificate — a `rfc822Name` SAN of
|
||||
// `*@vnc.de` is either rejected by clients or, worse, honoured.
|
||||
if (!email || email.includes('*') || !email.includes('@')) continue;
|
||||
if (seen.has(email)) continue;
|
||||
seen.add(email);
|
||||
addresses.push(email);
|
||||
if (!displayName && typeof entry.name === 'string' && entry.name.trim()) {
|
||||
displayName = entry.name.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return { addresses, displayName };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createVncMeeting } from "@/lib/vnctalk/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
function getClientIP(request: NextRequest): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) return forwarded.split(",")[0].trim();
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.name || !body.start || !body.end) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: name, start, end" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const invitees: string[] = Array.isArray(body.invitees) ? body.invitees : [];
|
||||
|
||||
const result = await createVncMeeting({
|
||||
name: String(body.name),
|
||||
start: String(body.start),
|
||||
end: String(body.end),
|
||||
invitees,
|
||||
password: body.password ? String(body.password) : undefined,
|
||||
description: body.description ? String(body.description) : undefined,
|
||||
});
|
||||
|
||||
logger.info("VNCtalk meeting created", {
|
||||
meetingId: result.meetingId,
|
||||
ip: getClientIP(request),
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error("VNCtalk meeting creation failed", { error: message });
|
||||
|
||||
if (message.includes("not configured")) {
|
||||
return NextResponse.json({ error: message }, { status: 503 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@@ -558,6 +559,40 @@ body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* Shake animation (for rejected input) */
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
20%,
|
||||
60% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
40%,
|
||||
80% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
/* Fade in animation (for popovers) */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Slide in from right animation (for mobile views) */
|
||||
@keyframes slide-in-from-right {
|
||||
from {
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
const appName =
|
||||
branded<string>("appName", "") ||
|
||||
process.env.NEXT_PUBLIC_APP_NAME ||
|
||||
"Bulwark Webmail";
|
||||
"VNCmail+";
|
||||
|
||||
const shortName = branded<string>("appShortName", "") || appName;
|
||||
const description =
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,264 @@
|
||||
'use client';
|
||||
|
||||
// The AI Assistant's entry point in the MAIN mail view — a Sparkles button in
|
||||
// the search toolbar that opens a compact Ask dialog. Until this existed, the
|
||||
// only way to ask the assistant anything was the "Try it" box buried in
|
||||
// Settings → AI Assistant, which is a configuration screen, not a workflow.
|
||||
//
|
||||
// Deliberately reuses the exact same wire client (lib/ai/local-client's
|
||||
// askMail) and the exact same persisted provider settings as the Settings
|
||||
// pane — this is a second door to the same room, not a second room. When no
|
||||
// provider is configured yet, the dialog deep-links to the Settings pane
|
||||
// (where local-discovery offers the one-click Connect) instead of duplicating
|
||||
// that setup flow here.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, Loader2, Settings2, Sparkles, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
|
||||
import { supportsLocalLlm } from '@/lib/platform-capabilities';
|
||||
import { getAiApiKey } from '@/lib/ai/key-store';
|
||||
import { loadAiSettings, type AiLocalSettings } from '@/lib/ai/local-settings';
|
||||
import { askMail, type AskResult } from '@/lib/ai/local-client';
|
||||
|
||||
function useAiPolicy(): { policy: AiPolicy; loaded: boolean } {
|
||||
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/ai/policy');
|
||||
if (res.ok && !cancelled) setPolicy(await res.json());
|
||||
} catch {
|
||||
/* stays at DEFAULT (disabled) — the button simply doesn't render */
|
||||
} finally {
|
||||
if (!cancelled) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
return { policy, loaded };
|
||||
}
|
||||
|
||||
/** Mirrors the Settings pane's canAsk gating: is any provider actually ready? */
|
||||
function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolean {
|
||||
const classes = policy.entitlement.classes;
|
||||
switch (settings.provider) {
|
||||
case 'local':
|
||||
return supportsLocalLlm() && classes.includes('local') && !!settings.localModel;
|
||||
case 'server':
|
||||
return classes.includes('server') && !!settings.serverModel;
|
||||
case 'opencode':
|
||||
return classes.includes('opencode') && !!settings.opencodeModel;
|
||||
case 'public': {
|
||||
const active = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
|
||||
return classes.includes('public') && !!active && settings.publicConsentAccepted;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AiAskButton() {
|
||||
const router = useRouter();
|
||||
const { policy, loaded } = useAiPolicy();
|
||||
// Retrieval must read the SAME account slot the indexer wrote under.
|
||||
const activeSlot = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.cookieSlot);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Re-read on every open: the user may have just configured a provider in
|
||||
// Settings and come straight back here — a mount-time snapshot would still
|
||||
// say "not configured".
|
||||
const [settings, setSettings] = useState<AiLocalSettings>(() => loadAiSettings());
|
||||
|
||||
const [question, setQuestion] = useState('');
|
||||
const [asking, setAsking] = useState(false);
|
||||
const [askResult, setAskResult] = useState<AskResult | null>(null);
|
||||
const [askError, setAskError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const openDialog = useCallback(() => {
|
||||
setSettings(loadAiSettings());
|
||||
setAskResult(null);
|
||||
setAskError(null);
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
textareaRef.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open]);
|
||||
|
||||
const configured = providerConfigured(settings, policy);
|
||||
const canAsk = configured && question.trim().length > 0 && !asking;
|
||||
|
||||
const runAsk = useCallback(async () => {
|
||||
if (!canAsk) return;
|
||||
setAsking(true);
|
||||
setAskError(null);
|
||||
setAskResult(null);
|
||||
try {
|
||||
const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
|
||||
const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
|
||||
const result = await askMail(question.trim(), {
|
||||
provider: settings.provider as 'local' | 'server' | 'public' | 'opencode',
|
||||
localBaseUrl: settings.localBaseUrl,
|
||||
localModel: settings.localModel,
|
||||
serverModel: settings.serverModel,
|
||||
opencodeModel: settings.opencodeModel,
|
||||
slot: activeSlot,
|
||||
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
|
||||
});
|
||||
setAskResult(result);
|
||||
} catch (err) {
|
||||
setAskError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAsking(false);
|
||||
}
|
||||
}, [canAsk, question, settings, activeSlot]);
|
||||
|
||||
const goToSettings = useCallback(() => {
|
||||
// The Settings page's one-shot deep-link channel (see readPersistedTab in
|
||||
// app/(main)/[locale]/settings/page.tsx) — lands directly on the AI pane,
|
||||
// where local-discovery's Connect banner does the actual setup.
|
||||
try {
|
||||
sessionStorage.setItem('settings-deep-link-tab', 'ai_assistant');
|
||||
} catch {
|
||||
/* private mode — the settings page just opens on its default tab */
|
||||
}
|
||||
setOpen(false);
|
||||
router.push('/settings');
|
||||
}, [router]);
|
||||
|
||||
// Hidden entirely when the admin gate is off or no provider class is
|
||||
// allowed — same visibility rule as the Settings pane itself.
|
||||
if (!loaded || !policy.enabled || policy.entitlement.classes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDialog}
|
||||
className="flex-shrink-0 p-2 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
title="AI Assistant"
|
||||
aria-label="AI Assistant"
|
||||
data-tour="ai-assistant"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-[10vh]"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) setOpen(false);
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="AI Assistant"
|
||||
>
|
||||
<div className="w-full max-w-xl rounded-xl border border-border bg-popover text-popover-foreground shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">AI Assistant</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{!configured ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No AI provider is set up yet. Pick one in Settings — if Ollama is running on this machine, a
|
||||
one-click Connect is waiting there.
|
||||
</p>
|
||||
<div>
|
||||
<Button size="sm" onClick={goToSettings}>
|
||||
<Settings2 className="w-3.5 h-3.5 me-1.5" />
|
||||
Open AI settings
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') void runAsk();
|
||||
}}
|
||||
rows={3}
|
||||
placeholder="Ask a question about your mail…"
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 resize-y"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" onClick={() => void runAsk()} disabled={!canAsk}>
|
||||
{asking && <Loader2 className="w-3.5 h-3.5 me-1.5 animate-spin" />}
|
||||
Ask
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">⌘⏎ to send</span>
|
||||
</div>
|
||||
|
||||
{askError && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{askError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askResult && (
|
||||
<div className={cn('flex flex-col gap-2 rounded-lg border border-border p-4', 'max-h-[45vh] overflow-y-auto')}>
|
||||
{askResult.retrievalState === 'no-index' && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No local mail index available in this session — answered without your mail.
|
||||
</p>
|
||||
)}
|
||||
{askResult.retrievalState === 'no-match' && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Your mail index is available, but nothing in it matched this question — answered
|
||||
without your mail. It matches on keywords, so content questions work better than
|
||||
recency ones.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{askResult.answer}</p>
|
||||
{askResult.sources.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border pt-2 mt-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Sources</span>
|
||||
{askResult.sources.map((s, i) => (
|
||||
<span key={s.id} className="text-xs text-muted-foreground truncate">
|
||||
[{i + 1}] {s.subject}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import { Pencil, Trash2, Copy } from "lucide-react";
|
||||
|
||||
interface CalendarMonthViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -25,6 +27,9 @@ interface CalendarMonthViewProps {
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
||||
onCreateAtTime?: (date: Date) => void;
|
||||
onEditEvent?: (event: CalendarEvent) => void;
|
||||
onDeleteEvent?: (event: CalendarEvent) => void;
|
||||
onDuplicateEvent?: (event: CalendarEvent) => void;
|
||||
firstDayOfWeek?: number;
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
@@ -41,6 +46,9 @@ export function CalendarMonthView({
|
||||
onContextMenuEvent,
|
||||
onContextMenuEmpty,
|
||||
onCreateAtTime,
|
||||
onEditEvent,
|
||||
onDeleteEvent,
|
||||
onDuplicateEvent,
|
||||
firstDayOfWeek = 1,
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
@@ -112,6 +120,54 @@ export function CalendarMonthView({
|
||||
|
||||
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuEvent, setRadialMenuEvent] = useState<CalendarEvent | null>(null);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuEvent) return [];
|
||||
const ev = radialMenuEvent;
|
||||
const items: RadialMenuItem[] = [];
|
||||
if (onEditEvent) {
|
||||
items.push({
|
||||
id: "edit",
|
||||
icon: <Pencil className="w-5 h-5" />,
|
||||
label: t("edit"),
|
||||
onClick: () => { onEditEvent(ev); },
|
||||
});
|
||||
}
|
||||
if (onDeleteEvent) {
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("delete"),
|
||||
onClick: () => { onDeleteEvent(ev); },
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
if (onDuplicateEvent) {
|
||||
items.push({
|
||||
id: "duplicate",
|
||||
icon: <Copy className="w-5 h-5" />,
|
||||
label: t("duplicate"),
|
||||
onClick: () => { onDuplicateEvent(ev); },
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [radialMenuEvent, t, onEditEvent, onDeleteEvent, onDuplicateEvent]);
|
||||
|
||||
const handleRadialMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuEvent(event);
|
||||
setRadialMenuOpen(true);
|
||||
onContextMenuEvent?.(e, event);
|
||||
}, [onContextMenuEvent]);
|
||||
|
||||
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
|
||||
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
||||
e.preventDefault();
|
||||
@@ -294,7 +350,7 @@ export function CalendarMonthView({
|
||||
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
||||
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
onContextMenu={handleRadialMenuEvent}
|
||||
draggable
|
||||
className={isMobile ? "text-[10px] px-1" : undefined}
|
||||
/>
|
||||
@@ -306,6 +362,13 @@ export function CalendarMonthView({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||
Pencil, Trash2, Copy, Send, Check,
|
||||
Pencil, Trash2, Copy, Send, Check, ExternalLink, Globe,
|
||||
} from "lucide-react";
|
||||
import { format, isSameDay } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -107,6 +107,25 @@ function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTransl
|
||||
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
||||
}
|
||||
|
||||
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?'")\]}>])/g;
|
||||
|
||||
function linkifyText(text: string): (string | { url: string })[] {
|
||||
const parts: (string | { url: string })[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
parts.push({ url: match[1] });
|
||||
lastIndex = match.index + match[1].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function EventDetailPopover({
|
||||
event,
|
||||
calendar,
|
||||
@@ -383,21 +402,34 @@ export function EventDetailPopover({
|
||||
{locationName && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a
|
||||
href={locationName}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline truncate"
|
||||
title={locationName}
|
||||
>
|
||||
{(() => {
|
||||
try { return new URL(locationName).hostname; } catch { return locationName; }
|
||||
})()}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a
|
||||
href={locationName}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline truncate block"
|
||||
title={locationName}
|
||||
>
|
||||
{(() => {
|
||||
try { return new URL(locationName).hostname; } catch { return locationName; }
|
||||
})()}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
<a
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(locationName)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-primary hover:underline mt-0.5 inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View on Map
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -413,6 +445,8 @@ export function EventDetailPopover({
|
||||
title={virtualLocation}
|
||||
>
|
||||
{(() => {
|
||||
const isVncMeeting = event.links?.["vnctalk-meeting"];
|
||||
if (isVncMeeting) return "Join VNCtalk Meeting";
|
||||
try {
|
||||
return new URL(virtualLocation).hostname;
|
||||
} catch {
|
||||
@@ -423,6 +457,30 @@ export function EventDetailPopover({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VNCtalk Meeting "Join" button (when meeting via links) */}
|
||||
{!virtualLocation && event.links?.["vnctalk-meeting"] && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a
|
||||
href={event.links["vnctalk-meeting"].href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Join VNCtalk Meeting
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timezone */}
|
||||
{!event.showWithoutTime && event.timeZone && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Globe className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{event.timeZone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{hasParticipants && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -476,7 +534,21 @@ export function EventDetailPopover({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3">
|
||||
{event.description}
|
||||
{linkifyText(event.description).map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
<span key={i}>{part}</span>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={part.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{part.url}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,13 +4,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff, ExternalLink, Reply, ReplyAll, Globe, Building2 } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||
import { FreeBusyView } from "./free-busy-view";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -25,6 +26,10 @@ import { generateUUID } from "@/lib/utils";
|
||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||
import { calendarHooks } from "@/lib/plugin-hooks";
|
||||
import type { ConflictWarning } from "@/lib/plugin-types";
|
||||
import { RecipientPopover } from "@/components/email/recipient-popover";
|
||||
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||
import { ResourcePicker } from "./resource-picker";
|
||||
import { useResourceStore } from "@/stores/resource-store";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
start: Date;
|
||||
@@ -49,6 +54,10 @@ interface EventModalProps {
|
||||
onPreviewChange?: (preview: PendingEventPreview | null) => void;
|
||||
currentUserEmails?: string[];
|
||||
isMobile?: boolean;
|
||||
prefillTitle?: string;
|
||||
prefillDescription?: string;
|
||||
prefillParticipants?: { name?: string; email: string }[];
|
||||
prefillDate?: string;
|
||||
}
|
||||
|
||||
function formatDateInput(d: Date): string {
|
||||
@@ -59,6 +68,25 @@ function formatTimeInput(d: Date): string {
|
||||
return format(d, "HH:mm");
|
||||
}
|
||||
|
||||
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?'")\]}>])/g;
|
||||
|
||||
function linkifyText(text: string): (string | { url: string })[] {
|
||||
const parts: (string | { url: string })[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
parts.push({ url: match[1] });
|
||||
lastIndex = match.index + match[1].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildDuration(startDate: Date, endDate: Date): string {
|
||||
const diffMs = endDate.getTime() - startDate.getTime();
|
||||
const totalMinutes = Math.max(0, Math.floor(diffMs / 60000));
|
||||
@@ -178,6 +206,10 @@ export function EventModal({
|
||||
onPreviewChange,
|
||||
currentUserEmails = [],
|
||||
isMobile = false,
|
||||
prefillTitle,
|
||||
prefillDescription,
|
||||
prefillParticipants,
|
||||
prefillDate,
|
||||
}: EventModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const locale = useLocale();
|
||||
@@ -228,6 +260,10 @@ export function EventModal({
|
||||
d.setHours(now.getHours() + 1, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
if (prefillDate) {
|
||||
const d = new Date(prefillDate);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() + 1, 0, 0, 0);
|
||||
return d;
|
||||
@@ -244,8 +280,8 @@ export function EventModal({
|
||||
return addHours(getInitialStart(), 1);
|
||||
};
|
||||
|
||||
const [title, setTitle] = useState(event?.title || "");
|
||||
const [description, setDescription] = useState(event?.description || "");
|
||||
const [title, setTitle] = useState(event?.title || prefillTitle || "");
|
||||
const [description, setDescription] = useState(event?.description || prefillDescription || "");
|
||||
const [location, setLocation] = useState(
|
||||
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
|
||||
);
|
||||
@@ -328,13 +364,29 @@ export function EventModal({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
|
||||
if (!event?.participants) return [];
|
||||
if (!event?.participants) {
|
||||
if (prefillParticipants && prefillParticipants.length > 0) {
|
||||
return prefillParticipants.map(p => ({ name: p.name || "", email: p.email }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return existingParticipants
|
||||
.filter(p => !p.isOrganizer)
|
||||
.map(p => ({ name: p.name, email: p.email }));
|
||||
});
|
||||
const [sendInvitations, setSendInvitations] = useState(true);
|
||||
const [showFreeBusy, setShowFreeBusy] = useState(false);
|
||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||
const [createVncMeeting, setCreateVncMeeting] = useState(false);
|
||||
const [meetingCreating, setMeetingCreating] = useState(false);
|
||||
const [timezone, setTimezone] = useState(() => {
|
||||
if (event?.timeZone) return event.timeZone;
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; }
|
||||
});
|
||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||
|
||||
const resourceStore = useResourceStore();
|
||||
const [showResources, setShowResources] = useState(false);
|
||||
|
||||
// Plugin transform: collect conflict warnings for the current event form.
|
||||
// Re-runs (debounced) whenever fields that affect scheduling change.
|
||||
@@ -361,6 +413,13 @@ export function EventModal({
|
||||
return () => { cancelled = true; clearTimeout(t); };
|
||||
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (event?.id) {
|
||||
resourceStore.fetchEventBookings(event.id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [event?.id]);
|
||||
|
||||
// Report live preview to parent for grid outline
|
||||
useEffect(() => {
|
||||
if (!onPreviewChange || isEdit) return;
|
||||
@@ -416,7 +475,7 @@ export function EventModal({
|
||||
duration = buildDuration(start, end);
|
||||
}
|
||||
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const timeZone = timezone;
|
||||
|
||||
const data: Partial<CalendarEvent> = {
|
||||
title: trimmedTitle,
|
||||
@@ -534,14 +593,72 @@ export function EventModal({
|
||||
data.organizerCalendarAddress = null;
|
||||
}
|
||||
|
||||
// VNCtalk meeting creation
|
||||
if (createVncMeeting && effectiveAttendees.length > 0 && !allDay) {
|
||||
setMeetingCreating(true);
|
||||
try {
|
||||
const vncRes = await fetch("/api/vnctalk/meeting", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: trimmedTitle,
|
||||
start: startStr,
|
||||
end: allDay
|
||||
? `${endDate}T23:59:59`
|
||||
: `${endDate}T${endTime}:00`,
|
||||
invitees: effectiveAttendees.map((a: { email: string }) => a.email),
|
||||
description: description.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
if (vncRes.ok) {
|
||||
const { meetingUrl, meetingId } = await vncRes.json();
|
||||
data.virtualLocations = {
|
||||
vl1: {
|
||||
"@type": "VirtualLocation",
|
||||
name: "VNCtalk Meeting",
|
||||
description: `Meeting ID: ${meetingId}`,
|
||||
uri: meetingUrl,
|
||||
features: null,
|
||||
},
|
||||
};
|
||||
data.links = {
|
||||
"vnctalk-meeting": {
|
||||
"@type": "Link",
|
||||
href: meetingUrl,
|
||||
cid: meetingId,
|
||||
contentType: null,
|
||||
size: null,
|
||||
rel: "vnctalk-meeting",
|
||||
display: null,
|
||||
title: "VNCtalk Meeting",
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create VNCtalk meeting:", err);
|
||||
} finally {
|
||||
setMeetingCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(data, shouldSendScheduling);
|
||||
if (resourceStore.selectedResources.length > 0) {
|
||||
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
|
||||
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
|
||||
const eventRef = event?.id || data.uid;
|
||||
await resourceStore.bookSelectedResources(
|
||||
startStr,
|
||||
endStr,
|
||||
eventRef,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving, createVncMeeting, timezone, resourceStore]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
@@ -575,6 +692,30 @@ export function EventModal({
|
||||
onDuplicate(data);
|
||||
}, [event, onDuplicate]);
|
||||
|
||||
const handleReply = useCallback((replyAll: boolean) => {
|
||||
if (!event) return;
|
||||
const participants = getParticipantList(event);
|
||||
const recipientEmails = replyAll
|
||||
? participants.map((p) => ({ email: p.email, name: p.name }))
|
||||
: (() => {
|
||||
const org = participants.find((p) => p.isOrganizer);
|
||||
return org ? [{ email: org.email, name: org.name }] : [];
|
||||
})();
|
||||
if (recipientEmails.length === 0) return;
|
||||
openComposeTab({
|
||||
sessionId: Date.now(),
|
||||
mode: replyAll ? "replyAll" : "reply",
|
||||
title: `Re: ${event.title}`,
|
||||
replyTo: {
|
||||
subject: `Re: ${event.title}`,
|
||||
to: recipientEmails,
|
||||
},
|
||||
});
|
||||
}, [event, openComposeTab]);
|
||||
|
||||
const handleReplyAll = useCallback(() => handleReply(true), [handleReply]);
|
||||
const handleReplySingle = useCallback(() => handleReply(false), [handleReply]);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -855,22 +996,57 @@ export function EventModal({
|
||||
{locationName && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={locationName}>
|
||||
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{/^https?:\/\//i.test(locationName) ? (
|
||||
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={locationName}>
|
||||
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-foreground">{locationName}</span>
|
||||
<a
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(locationName)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-primary hover:underline mt-0.5 inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
View on Map
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Virtual Location */}
|
||||
{/* Virtual Location / Meeting Link */}
|
||||
{virtualLoc && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={virtualLoc}>
|
||||
{(() => { try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; } })()}
|
||||
<div className="min-w-0">
|
||||
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={virtualLoc}>
|
||||
{(() => {
|
||||
const isVncMeeting = event.links?.["vnctalk-meeting"];
|
||||
if (isVncMeeting) return "Join VNCtalk Meeting";
|
||||
try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; }
|
||||
})()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VNCtalk Meeting "Join" button (when meeting via links) */}
|
||||
{!virtualLoc && event.links?.["vnctalk-meeting"] && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<a
|
||||
href={event.links["vnctalk-meeting"].href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
Join VNCtalk Meeting
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
@@ -887,7 +1063,7 @@ export function EventModal({
|
||||
{viewParticipants.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate text-foreground">
|
||||
{p.name || p.email}
|
||||
<RecipientPopover name={p.name} email={p.email} />
|
||||
{p.isOrganizer && (
|
||||
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
||||
)}
|
||||
@@ -900,6 +1076,34 @@ export function EventModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resources (booked) */}
|
||||
{resourceStore.bookings.length > 0 && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Building2 className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{resourceStore.bookings.map((b) => {
|
||||
const res = resourceStore.resources.find((r) => r.id === b.resourceId);
|
||||
return (
|
||||
<span
|
||||
key={b.id}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
{res?.name || b.resourceId}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timezone */}
|
||||
{!event.showWithoutTime && event.timeZone && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Globe className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">{event.timeZone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence */}
|
||||
{recurrenceLabel && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -920,7 +1124,23 @@ export function EventModal({
|
||||
{event.description && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{event.description}</p>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line">
|
||||
{linkifyText(event.description).map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
<span key={i}>{part}</span>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={part.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{part.url}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -933,7 +1153,7 @@ export function EventModal({
|
||||
showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
|
||||
<Button variant="outline" size="sm" onClick={() => { resourceStore.cancelEventBookings(event.id); onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||
@@ -953,6 +1173,18 @@ export function EventModal({
|
||||
{t("events.duplicate")}
|
||||
</Button>
|
||||
)}
|
||||
{hasParticipants && !showDeleteConfirm && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleReplySingle} aria-label="Reply to organizer">
|
||||
<Reply className="w-4 h-4 me-1" />
|
||||
Reply
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleReplyAll} aria-label="Reply All">
|
||||
<ReplyAll className="w-4 h-4 me-1" />
|
||||
Reply All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!showDeleteConfirm && (
|
||||
<Button onClick={() => setMode("edit")}>
|
||||
@@ -1042,6 +1274,21 @@ export function EventModal({
|
||||
setVirtualLocation,
|
||||
}}
|
||||
/>
|
||||
{attendees.length > 0 && !allDay && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="createVncMeeting"
|
||||
checked={createVncMeeting}
|
||||
onChange={(e) => setCreateVncMeeting(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
disabled={meetingCreating}
|
||||
/>
|
||||
<label htmlFor="createVncMeeting" className="text-sm">
|
||||
{meetingCreating ? "Creating meeting..." : "Create VNCtalk Meeting"}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1057,6 +1304,44 @@ export function EventModal({
|
||||
onAdd={handleAddAttendee}
|
||||
onRemove={handleRemoveAttendee}
|
||||
/>
|
||||
{attendees.length > 0 && !allDay && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFreeBusy((prev) => !prev)}
|
||||
className="text-xs"
|
||||
>
|
||||
{showFreeBusy ? (
|
||||
<EyeOff className="w-3.5 h-3.5 me-1" />
|
||||
) : (
|
||||
<Eye className="w-3.5 h-3.5 me-1" />
|
||||
)}
|
||||
{showFreeBusy ? t("freeBusy.hide") : t("freeBusy.check")}
|
||||
</Button>
|
||||
{showFreeBusy && (
|
||||
<div className="mt-3">
|
||||
<FreeBusyView
|
||||
participants={attendees}
|
||||
startDate={(() => {
|
||||
const d = new Date(`${startDate}T${startTime}:00`);
|
||||
return isNaN(d.getTime()) ? new Date() : d;
|
||||
})()}
|
||||
endDate={(() => {
|
||||
const d = new Date(`${endDate}T${endTime}:00`);
|
||||
return isNaN(d.getTime()) ? addHours(new Date(`${startDate}T${startTime}:00`), 8) : d;
|
||||
})()}
|
||||
onTimeSelect={(start, end) => {
|
||||
setStartDate(formatDateInput(start));
|
||||
setStartTime(formatTimeInput(start));
|
||||
setEndDate(formatDateInput(end));
|
||||
setEndTime(formatTimeInput(end));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isEdit && statusCounts && (existingParticipants.length > 0) && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{t("participants.status_summary", {
|
||||
@@ -1067,6 +1352,27 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setShowResources((prev) => !prev)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Building2 className="w-3.5 h-3.5 me-1" />
|
||||
{showResources ? t("resources.hide") : t("resources.title")}
|
||||
</Button>
|
||||
{showResources && (
|
||||
<div className="mt-3">
|
||||
<ResourcePicker
|
||||
start={allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`}
|
||||
end={allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1121,6 +1427,32 @@ export function EventModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!allDay && (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Globe className="w-4 h-4" />
|
||||
Timezone
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{(() => {
|
||||
try {
|
||||
return Intl.supportedValuesOf("timeZone");
|
||||
} catch {
|
||||
return [timezone || "UTC"];
|
||||
}
|
||||
})().map((tz: string) => (
|
||||
<option key={tz} value={tz}>{tz}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pluginConflictWarnings.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{pluginConflictWarnings.map(w => (
|
||||
@@ -1304,7 +1636,7 @@ export function EventModal({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||
onClick={() => { resourceStore.cancelEventBookings(event!.id); onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||
>
|
||||
{t("events.delete")}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { addMinutes, differenceInMinutes, format } from "date-fns";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fetchFreeBusy, type FreeBusySlot, isWorkingHour as isWorkingHourFn } from "@/lib/calendar-freebusy";
|
||||
|
||||
export interface ResourceFreeBusyEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
availabilityMap: Map<number, FreeBusySlot["status"]>;
|
||||
}
|
||||
|
||||
export interface FreeBusyViewProps {
|
||||
participants: { name?: string; email: string }[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onTimeSelect?: (start: Date, end: Date) => void;
|
||||
resources?: ResourceFreeBusyEntry[];
|
||||
}
|
||||
|
||||
const SLOT_MINUTES = 30;
|
||||
const WORK_START_HOUR = 8;
|
||||
const WORK_END_HOUR = 18;
|
||||
|
||||
const statusColors: Record<FreeBusySlot["status"], string> = {
|
||||
free: "bg-emerald-100 dark:bg-emerald-900/40 border-emerald-200 dark:border-emerald-800",
|
||||
busy: "bg-red-100 dark:bg-red-900/40 border-red-200 dark:border-red-800",
|
||||
tentative: "bg-amber-100 dark:bg-amber-900/40 border-amber-200 dark:border-amber-800",
|
||||
unavailable: "bg-purple-100 dark:bg-purple-900/40 border-purple-200 dark:border-purple-800",
|
||||
unknown: "bg-muted border-muted-foreground/20",
|
||||
};
|
||||
|
||||
const statusHoverColors: Record<FreeBusySlot["status"], string> = {
|
||||
free: "hover:bg-emerald-200 dark:hover:bg-emerald-800/60",
|
||||
busy: "hover:bg-red-200 dark:hover:bg-red-800/60",
|
||||
tentative: "hover:bg-amber-200 dark:hover:bg-amber-800/60",
|
||||
unavailable: "hover:bg-purple-200 dark:hover:bg-purple-800/60",
|
||||
unknown: "hover:bg-muted-foreground/20",
|
||||
};
|
||||
|
||||
function clampToSlot(d: Date): Date {
|
||||
const clone = new Date(d);
|
||||
clone.setSeconds(0, 0);
|
||||
const mins = clone.getMinutes();
|
||||
const remainder = mins % SLOT_MINUTES;
|
||||
if (remainder !== 0) {
|
||||
clone.setMinutes(mins - remainder, 0, 0);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
function buildHourSlots(start: Date, end: Date): { label: string; slots: FreeBusySlot[] }[] {
|
||||
const hours: { label: string; slots: FreeBusySlot[] }[] = [];
|
||||
let cursor = clampToSlot(start);
|
||||
while (cursor < end) {
|
||||
const hourEnd = new Date(cursor);
|
||||
hourEnd.setHours(hourEnd.getHours() + 1, 0, 0, 0);
|
||||
const hourSlots: FreeBusySlot[] = [];
|
||||
let slotCursor = new Date(cursor);
|
||||
while (slotCursor < hourEnd && slotCursor < end) {
|
||||
const slotEnd = addMinutes(slotCursor, SLOT_MINUTES);
|
||||
hourSlots.push({
|
||||
start: new Date(slotCursor),
|
||||
end: slotEnd > end ? new Date(end) : slotEnd,
|
||||
status: "unknown",
|
||||
});
|
||||
slotCursor = slotEnd;
|
||||
}
|
||||
hours.push({ label: format(cursor, "HH:mm"), slots: hourSlots });
|
||||
cursor = hourEnd;
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
function isWorkingHour(hour: number): boolean {
|
||||
return isWorkingHourFn(hour, WORK_START_HOUR, WORK_END_HOUR);
|
||||
}
|
||||
|
||||
export function FreeBusyView({
|
||||
participants,
|
||||
startDate,
|
||||
endDate,
|
||||
onTimeSelect,
|
||||
resources = [],
|
||||
}: FreeBusyViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const [freeBusyData, setFreeBusyData] = useState<Map<string, FreeBusySlot[]> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hoveredSlot, setHoveredSlot] = useState<{
|
||||
participant: string;
|
||||
slotIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const hourSlots = useMemo(() => buildHourSlots(startDate, endDate), [startDate, endDate]);
|
||||
const totalHalfHourSlots = useMemo(() => {
|
||||
let c = 0;
|
||||
for (const h of hourSlots) c += h.slots.length;
|
||||
return c;
|
||||
}, [hourSlots]);
|
||||
|
||||
const now = new Date();
|
||||
const showNowLine =
|
||||
now >= startDate && now <= endDate;
|
||||
const nowPositionPercent = showNowLine
|
||||
? Math.max(0, Math.min(100, (differenceInMinutes(now, startDate) / differenceInMinutes(endDate, startDate)) * 100))
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || participants.length === 0) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchFreeBusy(client, participants, startDate, endDate)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setFreeBusyData(data);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, participants, startDate, endDate]);
|
||||
|
||||
const handleSlotClick = useCallback(
|
||||
(slot: FreeBusySlot) => {
|
||||
if (slot.status === "free" && onTimeSelect) {
|
||||
onTimeSelect(new Date(slot.start), new Date(slot.end));
|
||||
}
|
||||
},
|
||||
[onTimeSelect]
|
||||
);
|
||||
|
||||
const timezone = useMemo(
|
||||
() => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
[]
|
||||
);
|
||||
|
||||
if (participants.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("freeBusy.no_participants")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("freeBusy.timezone")}: {timezone}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="text-xs text-muted-foreground animate-pulse">
|
||||
{t("freeBusy.loading")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto border border-border rounded-lg">
|
||||
<div className="min-w-max" style={{ minWidth: totalHalfHourSlots * 24 + 200 }}>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 bg-background border-b border-r border-border px-3 py-2 text-left w-[180px] min-w-[180px]">
|
||||
{t("participants.title")}
|
||||
</th>
|
||||
{hourSlots.map((hour, i) => (
|
||||
<th
|
||||
key={i}
|
||||
colSpan={hour.slots.length}
|
||||
className={cn(
|
||||
"border-b border-r border-border px-1 py-2 text-center font-medium",
|
||||
isWorkingHour(new Date(hour.slots[0]?.start).getHours())
|
||||
? "bg-muted/50"
|
||||
: "bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{hour.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participants.map((p) => {
|
||||
const key = p.email.toLowerCase();
|
||||
const slots = freeBusyData?.get(key);
|
||||
return (
|
||||
<tr key={key} className="border-b border-border">
|
||||
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={p.name}
|
||||
email={p.email}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{p.name || p.email}
|
||||
</div>
|
||||
{p.name && (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{p.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{hourSlots.map((hour) =>
|
||||
hour.slots.map((hourSlot, si) => {
|
||||
const globalSlotIndex =
|
||||
hourSlots
|
||||
.slice(0, hourSlots.indexOf(hour))
|
||||
.reduce((acc, h) => acc + h.slots.length, 0) + si;
|
||||
|
||||
const slot = slots?.[globalSlotIndex];
|
||||
const status = slot?.status ?? "unknown";
|
||||
const isFree = status === "free";
|
||||
const isHovered =
|
||||
hoveredSlot?.participant === key &&
|
||||
hoveredSlot?.slotIndex === globalSlotIndex;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={si}
|
||||
className={cn(
|
||||
"border-r border-border py-1 text-center relative cursor-default transition-colors",
|
||||
statusColors[status],
|
||||
isFree && statusHoverColors[status],
|
||||
isFree && "cursor-pointer",
|
||||
isHovered && "ring-1 ring-inset ring-primary/50",
|
||||
isWorkingHour(new Date(hourSlot.start).getHours())
|
||||
? ""
|
||||
: "opacity-70"
|
||||
)}
|
||||
title={format(hourSlot.start, "HH:mm")}
|
||||
onClick={() =>
|
||||
isFree ? handleSlotClick(slot!) : undefined
|
||||
}
|
||||
onMouseEnter={() =>
|
||||
setHoveredSlot({
|
||||
participant: key,
|
||||
slotIndex: globalSlotIndex,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() => setHoveredSlot(null)}
|
||||
>
|
||||
{status === "free" && (
|
||||
<span className="block w-full h-full"> </span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{resources.map((res) => (
|
||||
<tr key={`res-${res.id}`} className="border-b border-border">
|
||||
<td className="sticky left-0 z-10 bg-background border-r border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center shrink-0">
|
||||
<span className="text-[10px] font-bold text-blue-600 dark:text-blue-400">
|
||||
R
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate text-sm">
|
||||
{res.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{hourSlots.map((hour) =>
|
||||
hour.slots.map((hourSlot, si) => {
|
||||
const globalSlotIndex =
|
||||
hourSlots
|
||||
.slice(0, hourSlots.indexOf(hour))
|
||||
.reduce((acc, h) => acc + h.slots.length, 0) + si;
|
||||
|
||||
const status = res.availabilityMap.get(globalSlotIndex) ?? "unknown";
|
||||
const isFree = status === "free";
|
||||
|
||||
return (
|
||||
<td
|
||||
key={si}
|
||||
className={cn(
|
||||
"border-r border-border py-1 text-center relative cursor-default transition-colors",
|
||||
statusColors[status],
|
||||
isFree && "cursor-pointer",
|
||||
isWorkingHour(new Date(hourSlot.start).getHours())
|
||||
? ""
|
||||
: "opacity-70"
|
||||
)}
|
||||
title={`${res.name} - ${format(hourSlot.start, "HH:mm")}`}
|
||||
>
|
||||
{status === "free" && (
|
||||
<span className="block w-full h-full"> </span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNowLine && nowPositionPercent !== null && (
|
||||
<div
|
||||
className="absolute pointer-events-none z-20"
|
||||
style={{
|
||||
left: `calc(180px + ${nowPositionPercent}% * (1 - 180px / ${totalHalfHourSlots * 24 + 200}))`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-emerald-200 dark:border-emerald-800 bg-emerald-100 dark:bg-emerald-900/40" />
|
||||
{t("freeBusy.free")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-red-200 dark:border-red-800 bg-red-100 dark:bg-red-900/40" />
|
||||
{t("freeBusy.busy")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-amber-200 dark:border-amber-800 bg-amber-100 dark:bg-amber-900/40" />
|
||||
{t("freeBusy.tentative")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-purple-200 dark:border-purple-800 bg-purple-100 dark:bg-purple-900/40" />
|
||||
{t("freeBusy.unavailable")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded border border-muted-foreground/20 bg-muted" />
|
||||
{t("freeBusy.unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
format,
|
||||
isToday,
|
||||
isSameDay,
|
||||
addMonths,
|
||||
subMonths,
|
||||
isSameMonth,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||
|
||||
interface MiniCalendarDashletProps {
|
||||
events?: { date: string; color?: string }[];
|
||||
onDayClick?: (date: Date) => void;
|
||||
selectedDate?: Date;
|
||||
}
|
||||
|
||||
const ALL_DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
||||
|
||||
export function MiniCalendarDashlet({
|
||||
events: propEvents,
|
||||
onDayClick,
|
||||
selectedDate: propSelectedDate,
|
||||
}: MiniCalendarDashletProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const router = useRouter();
|
||||
const firstDayOfWeek = useSettingsStore((s) => s.firstDayOfWeek);
|
||||
const storeSelectedDate = useCalendarStore((s) => s.selectedDate);
|
||||
const storeEvents = useCalendarStore((s) => s.events);
|
||||
const selectedDate = propSelectedDate ?? storeSelectedDate;
|
||||
const client = useAuthStore((s) => s.client);
|
||||
|
||||
const [displayMonth, setDisplayMonth] = useState(() => new Date());
|
||||
|
||||
const weekStartsOn = useMemo(() => {
|
||||
if (firstDayOfWeek === 0) return 0 as const;
|
||||
if (firstDayOfWeek === 6) return 6 as const;
|
||||
return 1 as const;
|
||||
}, [firstDayOfWeek]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
const start = format(startOfMonth(displayMonth), "yyyy-MM-dd'T'00:00:00");
|
||||
const end = format(endOfMonth(displayMonth), "yyyy-MM-dd'T'23:59:59");
|
||||
const { dateRange } = useCalendarStore.getState();
|
||||
if (dateRange?.start === start && dateRange?.end === end) return;
|
||||
useCalendarStore.getState().fetchEvents(client, start, end);
|
||||
}, [displayMonth, client]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(displayMonth);
|
||||
const monthEnd = endOfMonth(displayMonth);
|
||||
const calStart = startOfWeek(monthStart, { weekStartsOn });
|
||||
const calEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||
return eachDayOfInterval({ start: calStart, end: calEnd });
|
||||
}, [displayMonth, weekStartsOn]);
|
||||
|
||||
const eventDates = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const e of storeEvents) {
|
||||
try {
|
||||
const { startDay, endDay } = getEventDayBounds(e);
|
||||
const cursor = new Date(startDay);
|
||||
while (cursor <= endDay) {
|
||||
set.add(format(cursor, "yyyy-MM-dd"));
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (propEvents) {
|
||||
for (const e of propEvents) {
|
||||
set.add(e.date);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}, [storeEvents, propEvents]);
|
||||
|
||||
const dayHeaders = useMemo(
|
||||
() => [...ALL_DAY_KEYS.slice(weekStartsOn), ...ALL_DAY_KEYS.slice(0, weekStartsOn)],
|
||||
[weekStartsOn],
|
||||
);
|
||||
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
setDisplayMonth((prev) => subMonths(prev, 1));
|
||||
}, []);
|
||||
|
||||
const handleNextMonth = useCallback(() => {
|
||||
setDisplayMonth((prev) => addMonths(prev, 1));
|
||||
}, []);
|
||||
|
||||
const handleGoToToday = useCallback(() => {
|
||||
setDisplayMonth(new Date());
|
||||
}, []);
|
||||
|
||||
const handleDayClick = useCallback(
|
||||
(day: Date) => {
|
||||
useCalendarStore.getState().setSelectedDate(day);
|
||||
if (onDayClick) {
|
||||
onDayClick(day);
|
||||
} else {
|
||||
router.push("/calendar");
|
||||
}
|
||||
},
|
||||
[onDayClick, router],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="select-none px-2 py-1.5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<button
|
||||
onClick={handlePrevMonth}
|
||||
className="p-0.5 rounded hover:bg-muted transition-colors"
|
||||
aria-label={t("nav_prev")}
|
||||
>
|
||||
<ChevronLeft className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoToToday}
|
||||
className="text-xs font-medium hover:bg-muted px-1.5 py-0.5 rounded transition-colors"
|
||||
title={t("views.today")}
|
||||
>
|
||||
{format(displayMonth, "MMM yyyy")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextMonth}
|
||||
className="p-0.5 rounded hover:bg-muted transition-colors"
|
||||
aria-label={t("nav_next")}
|
||||
>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 mb-0.5">
|
||||
{dayHeaders.map((dh) => (
|
||||
<div
|
||||
key={dh}
|
||||
className="text-center text-[9px] font-medium text-muted-foreground py-0.5"
|
||||
>
|
||||
{t(`days.${dh}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0">
|
||||
{days.map((day) => {
|
||||
const inMonth = isSameMonth(day, displayMonth);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const today = isToday(day);
|
||||
const dateStr = format(day, "yyyy-MM-dd");
|
||||
const hasEvent = eventDates.has(dateStr);
|
||||
const dotColor =
|
||||
propEvents?.find((e) => e.date === dateStr && e.color)?.color ??
|
||||
undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-6 h-6 text-[11px] rounded-full transition-colors mx-auto",
|
||||
!inMonth && "text-muted-foreground/30",
|
||||
inMonth && !selected && "hover:bg-muted",
|
||||
today && !selected && "font-bold text-primary",
|
||||
selected && "bg-primary text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{day.getDate()}
|
||||
{hasEvent && !selected && (
|
||||
<span
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary"
|
||||
style={dotColor ? { backgroundColor: dotColor } : undefined}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useResourceStore } from "@/stores/resource-store";
|
||||
import type { Resource } from "@/lib/resources/client";
|
||||
import {
|
||||
Building2,
|
||||
Car,
|
||||
Wrench,
|
||||
Box,
|
||||
MapPin,
|
||||
Users,
|
||||
Search,
|
||||
X,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ResourcePickerProps {
|
||||
start?: string;
|
||||
end?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const typeIcons: Record<Resource["type"], typeof Building2> = {
|
||||
room: Building2,
|
||||
vehicle: Car,
|
||||
equipment: Wrench,
|
||||
other: Box,
|
||||
};
|
||||
|
||||
type TypeFilter = "all" | Resource["type"];
|
||||
|
||||
export function ResourcePicker({ start, end, compact = false }: ResourcePickerProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const {
|
||||
resources,
|
||||
selectedResources,
|
||||
isLoading,
|
||||
fetchResources,
|
||||
searchResources,
|
||||
toggleResource,
|
||||
deselectResource,
|
||||
clearSelection,
|
||||
} = useResourceStore();
|
||||
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [availabilityMap, setAvailabilityMap] = useState<Record<string, "available" | "conflict" | "unknown">>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchResources();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = typeFilter === "all" ? resources : resources.filter((r) => r.type === typeFilter);
|
||||
if (query.trim()) {
|
||||
list = searchResources(query).filter((r) => typeFilter === "all" || r.type === typeFilter);
|
||||
}
|
||||
return list;
|
||||
}, [resources, typeFilter, query, searchResources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!start || !end) return;
|
||||
let cancelled = false;
|
||||
|
||||
const checkAll = async () => {
|
||||
const map: Record<string, "available" | "conflict" | "unknown"> = {};
|
||||
for (const resource of filtered) {
|
||||
try {
|
||||
const params = new URLSearchParams({ start, end });
|
||||
const { apiFetch } = await import("@/lib/browser-navigation");
|
||||
const res = await apiFetch(
|
||||
`/api/resources/${resource.id}/availability?${params.toString()}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
map[resource.id] = data.available ? "available" : "conflict";
|
||||
} else {
|
||||
map[resource.id] = "unknown";
|
||||
}
|
||||
} catch {
|
||||
map[resource.id] = "unknown";
|
||||
}
|
||||
}
|
||||
if (!cancelled) setAvailabilityMap(map);
|
||||
};
|
||||
|
||||
checkAll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filtered, start, end]);
|
||||
|
||||
const filters: { key: TypeFilter; label: string }[] = [
|
||||
{ key: "all", label: t("resources.filter_all") },
|
||||
{ key: "room", label: t("resources.type_room") },
|
||||
{ key: "vehicle", label: t("resources.type_vehicle") },
|
||||
{ key: "equipment", label: t("resources.type_equipment") },
|
||||
{ key: "other", label: t("resources.type_other") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setTypeFilter(f.key)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
||||
typeFilter === f.key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("resources.search_placeholder")}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("resources.no_resources")}
|
||||
</p>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"border border-border rounded-lg divide-y divide-border",
|
||||
!compact && "max-h-64 overflow-y-auto"
|
||||
)}>
|
||||
{filtered.map((resource) => {
|
||||
const TypeIcon = typeIcons[resource.type];
|
||||
const isSelected = selectedResources.some((r) => r.id === resource.id);
|
||||
const avail = availabilityMap[resource.id] || "unknown";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={resource.id}
|
||||
type="button"
|
||||
onClick={() => toggleResource(resource)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/10 hover:bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<span className="relative flex-shrink-0">
|
||||
<TypeIcon className="w-5 h-5 text-muted-foreground" />
|
||||
{start && end && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-background",
|
||||
avail === "available" && "bg-emerald-500",
|
||||
avail === "conflict" && "bg-red-500",
|
||||
avail === "unknown" && "bg-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{resource.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{resource.location && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{resource.location}
|
||||
</span>
|
||||
)}
|
||||
{resource.capacity != null && resource.capacity > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Users className="w-3 h-3" />
|
||||
{resource.capacity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors",
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "border-muted-foreground/40"
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="w-3.5 h-3.5" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedResources.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{selectedResources.map((resource) => (
|
||||
<span
|
||||
key={resource.id}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
{resource.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deselectResource(resource.id)}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/20 transition-colors"
|
||||
aria-label={t("resources.remove", { name: resource.name })}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{selectedResources.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
className="text-xs text-muted-foreground hover:text-foreground ml-1"
|
||||
>
|
||||
{t("resources.clear_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,4 +102,98 @@ describe('ContactForm', () => {
|
||||
expect.arrayContaining([expect.objectContaining({ kind: 'given', value: 'Jane' })])
|
||||
);
|
||||
});
|
||||
|
||||
it('saves an organization-only card when the organization type is selected', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('type_organization'));
|
||||
fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } });
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const savedData = onSave.mock.calls[0][0];
|
||||
expect(savedData.kind).toBe('org');
|
||||
expect(savedData.organizations.o0.name).toBe('Acme Corp');
|
||||
// No personal name components; the org name carries the display name instead.
|
||||
expect(savedData.name.components).toBeUndefined();
|
||||
expect(savedData.name.full).toBe('Acme Corp');
|
||||
});
|
||||
|
||||
it('hides the personal name fields in organization mode', () => {
|
||||
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.getByPlaceholderText('given_name')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('type_organization'));
|
||||
|
||||
expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('surname')).not.toBeInTheDocument();
|
||||
// The organization field moves into the identity section, so it appears once.
|
||||
expect(screen.getAllByPlaceholderText('organization_placeholder')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still requires a name in organization mode', async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('type_organization'));
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('name_required')).toBeInTheDocument();
|
||||
});
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts an organization instead of a personal name in person mode', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('section_work'));
|
||||
fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } });
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(onSave.mock.calls[0][0].name.full).toBe('Acme Corp');
|
||||
});
|
||||
|
||||
it('opens an existing org card in organization mode', () => {
|
||||
const orgContact: ContactCard = {
|
||||
id: '2',
|
||||
addressBookIds: {},
|
||||
kind: 'org',
|
||||
name: { full: 'Acme Corp' },
|
||||
organizations: { o0: { name: 'Acme Corp' } },
|
||||
};
|
||||
render(<ContactForm contact={orgContact} onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Acme Corp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches an org card back to a person', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const orgContact: ContactCard = {
|
||||
id: '2',
|
||||
addressBookIds: {},
|
||||
kind: 'org',
|
||||
name: { full: 'Acme Corp' },
|
||||
organizations: { o0: { name: 'Acme Corp' } },
|
||||
};
|
||||
render(<ContactForm contact={orgContact} onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('type_person'));
|
||||
fireEvent.change(screen.getByPlaceholderText('given_name'), { target: { value: 'Jane' } });
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(onSave.mock.calls[0][0].kind).toBe('individual');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,7 +171,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
|
||||
const hasNickname = nicknames.length > 0;
|
||||
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
|
||||
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
|
||||
// On an organization card the org name is already the heading; don't repeat it.
|
||||
const orgName = orgs[0]?.name;
|
||||
const subtitleParts = [titleLine, orgName === name ? undefined : orgName].filter(Boolean) as string[];
|
||||
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
|
||||
const hasWork = titles.length > 0 || orgs.length > 0;
|
||||
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
|
||||
|
||||
@@ -266,6 +266,16 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : ""
|
||||
);
|
||||
|
||||
// A card may describe an organization instead of a person (RFC 9553 kind "org").
|
||||
// Older cards predate the explicit kind, so fall back to "has an org name but no
|
||||
// personal name".
|
||||
const [isOrg, setIsOrg] = useState(() => {
|
||||
if (!contact) return false;
|
||||
if (contact.kind) return contact.kind === "org";
|
||||
const hasPersonName = !!(findComponent("given") || findComponent("surname"));
|
||||
return !hasPersonName && !!Object.values(contact.organizations || {})[0]?.name;
|
||||
});
|
||||
|
||||
const [jobTitle, setJobTitle] = useState(() => {
|
||||
if (contact?.titles) {
|
||||
const t = Object.values(contact.titles).find(t => t.kind !== "role");
|
||||
@@ -424,7 +434,10 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!givenName.trim() && !surname.trim()) {
|
||||
// An organization name identifies the card just as well as a personal name.
|
||||
const orgName = organization.trim();
|
||||
const hasPersonName = !!(givenName.trim() || surname.trim());
|
||||
if (isOrg ? !orgName : (!hasPersonName && !orgName)) {
|
||||
setError(t("name_required"));
|
||||
return;
|
||||
}
|
||||
@@ -461,11 +474,19 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
|
||||
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
|
||||
const nameComponents = [];
|
||||
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
|
||||
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
||||
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
|
||||
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
||||
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
|
||||
if (!isOrg) {
|
||||
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
|
||||
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
||||
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
|
||||
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
||||
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
|
||||
}
|
||||
|
||||
// Without personal name components, carry the organization name in `name.full`
|
||||
// so servers and other clients have something to display.
|
||||
const nameValue: ContactCard["name"] = nameComponents.length > 0
|
||||
? { components: nameComponents, isOrdered: true }
|
||||
: { full: orgName };
|
||||
|
||||
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
||||
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
||||
@@ -530,14 +551,20 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
const mediaValue: Record<string, ContactMedia> | null | undefined =
|
||||
Object.keys(mediaMap).length > 0 ? mediaMap : (hadMedia ? null : undefined);
|
||||
|
||||
// Only send `kind` when this form owns the answer: switching a card between
|
||||
// person and organization. Leave other kinds (group, location, ...) untouched.
|
||||
const kindValue: ContactCard["kind"] | undefined =
|
||||
isOrg ? "org" : (contact?.kind === "org" ? "individual" : undefined);
|
||||
|
||||
const data: Partial<ContactCard> = {
|
||||
name: { components: nameComponents, isOrdered: true },
|
||||
name: nameValue,
|
||||
...(kindValue ? { kind: kindValue } : {}),
|
||||
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
|
||||
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
|
||||
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
|
||||
titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined,
|
||||
organizations: organization.trim()
|
||||
? { o0: { name: organization.trim(), units: orgUnits } }
|
||||
organizations: orgName
|
||||
? { o0: { name: orgName, units: orgUnits } }
|
||||
: undefined,
|
||||
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
|
||||
onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined,
|
||||
@@ -570,7 +597,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
}
|
||||
};
|
||||
|
||||
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
|
||||
const previewName = (isOrg ? "" : [givenName, surname].filter(Boolean).join(" ").trim()) || organization.trim();
|
||||
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
|
||||
|
||||
return (
|
||||
@@ -661,38 +688,81 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
)}
|
||||
|
||||
<FormSection icon={User} title={t("section_identity")}>
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
||||
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("given_name")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("surname")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
|
||||
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">{t("contact_type")}</span>
|
||||
<div role="radiogroup" aria-label={t("contact_type")} className="inline-flex gap-0.5 rounded-md border border-input p-0.5">
|
||||
{[
|
||||
{ org: false, label: t("type_person"), icon: User },
|
||||
{ org: true, label: t("type_organization"), icon: Building },
|
||||
].map(({ org, label, icon: Icon }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isOrg === org}
|
||||
onClick={() => setIsOrg(org)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2.5 py-1 text-xs rounded transition-colors",
|
||||
isOrg === org
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
|
||||
<Input value={additionalName} onChange={(e) => setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
|
||||
{isOrg ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("organization")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
|
||||
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
|
||||
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
|
||||
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("given_name")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("surname")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
|
||||
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
|
||||
<Input value={additionalName} onChange={(e) => setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
|
||||
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
{/* Email */}
|
||||
@@ -806,12 +876,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
</FormSection>
|
||||
|
||||
{/* Work & Organization */}
|
||||
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
|
||||
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!((organization && !isOrg) || department || jobTitle || role)}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
||||
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
|
||||
</div>
|
||||
{/* In organization mode the org name is the card's identity, edited above. */}
|
||||
{!isOrg && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
|
||||
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("department")}</label>
|
||||
<Input value={department} onChange={(e) => setDepartment(e.target.value)} placeholder={t("department_placeholder")} />
|
||||
|
||||
@@ -2,26 +2,39 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react";
|
||||
import { Upload, FileText, AlertTriangle, X, Check, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseVCard, detectDuplicates } from "@/lib/vcard";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import {
|
||||
parseCSV,
|
||||
autoMapColumns,
|
||||
mapRowToContact,
|
||||
detectDuplicatesByEmail,
|
||||
type CsvColumnMapping,
|
||||
type CsvParseResult,
|
||||
} from "@/lib/contact-csv-import";
|
||||
|
||||
type FileType = "vcf" | "csv" | null;
|
||||
|
||||
interface ContactImportDialogProps {
|
||||
existingContacts: ContactCard[];
|
||||
addressBooks?: AddressBook[];
|
||||
onImport: (contacts: ContactCard[]) => Promise<number>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ContactImportDialog({
|
||||
existingContacts,
|
||||
addressBooks,
|
||||
onImport,
|
||||
onClose,
|
||||
}: ContactImportDialogProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [fileType, setFileType] = useState<FileType>(null);
|
||||
const [parsed, setParsed] = useState<ContactCard[]>([]);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [duplicates, setDuplicates] = useState<Map<number, string>>(new Map());
|
||||
@@ -29,41 +42,111 @@ export function ContactImportDialog({
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [csvData, setCsvData] = useState<CsvParseResult | null>(null);
|
||||
const [mapping, setMapping] = useState<CsvColumnMapping | null>(null);
|
||||
const [targetBookId, setTargetBookId] = useState("");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const ALLOWED_ACCEPT = ".vcf,.vcard,.csv,text/csv,text/vcard";
|
||||
|
||||
const books = addressBooks || [];
|
||||
const defaultBookId =
|
||||
books.find((b) => b.isDefault)?.id || books[0]?.id || "";
|
||||
const effectiveBookId = targetBookId || defaultBookId;
|
||||
const bookOptions = books.map((b) => ({
|
||||
value: b.id,
|
||||
label: b.name,
|
||||
}));
|
||||
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setFileType(null);
|
||||
setParsed([]);
|
||||
setSelected(new Set());
|
||||
setDuplicates(new Map());
|
||||
setCsvData(null);
|
||||
setMapping(null);
|
||||
setShowPreview(false);
|
||||
setTargetBookId("");
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError(t("import.file_too_large"));
|
||||
return;
|
||||
}
|
||||
|
||||
const name = file.name.toLowerCase();
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const contacts = parseVCard(text);
|
||||
if (name.endsWith(".csv") || file.type === "text/csv") {
|
||||
setFileType("csv");
|
||||
const text = await file.text();
|
||||
const result = parseCSV(text);
|
||||
|
||||
if (contacts.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
if (result.rows.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvData(result);
|
||||
setMapping(autoMapColumns(result.headers));
|
||||
setTargetBookId(defaultBookId);
|
||||
} else {
|
||||
setFileType("vcf");
|
||||
const text = await file.text();
|
||||
const contacts = parseVCard(text);
|
||||
|
||||
if (contacts.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
const dupes = detectDuplicates(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
}
|
||||
|
||||
const dupes = detectDuplicates(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse vCard:', error);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse file:", err);
|
||||
setError(t("import.parse_error"));
|
||||
}
|
||||
}, [existingContacts, t]);
|
||||
}, [existingContacts, t, defaultBookId]);
|
||||
|
||||
const applyCsvMapping = useCallback(() => {
|
||||
if (!csvData || !mapping) return;
|
||||
|
||||
const bookIds = effectiveBookId ? { [effectiveBookId]: true } : {};
|
||||
const contacts: ContactCard[] = [];
|
||||
|
||||
for (const row of csvData.rows) {
|
||||
const contact = mapRowToContact(row, mapping, bookIds);
|
||||
if (contact) contacts.push(contact);
|
||||
}
|
||||
|
||||
if (contacts.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
const dupes = detectDuplicatesByEmail(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
setShowPreview(true);
|
||||
}, [csvData, mapping, effectiveBookId, existingContacts, t]);
|
||||
|
||||
const toggleSelect = (idx: number) => {
|
||||
const next = new Set(selected);
|
||||
@@ -91,14 +174,160 @@ export function ContactImportDialog({
|
||||
try {
|
||||
const count = await onImport(toImport);
|
||||
setResult(count);
|
||||
} catch (error) {
|
||||
console.error('Failed to import contacts:', error);
|
||||
} catch (err) {
|
||||
console.error("Failed to import contacts:", err);
|
||||
setError(t("import.failed"));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCsvMapping = () => {
|
||||
if (!csvData || !mapping) return null;
|
||||
|
||||
const fields: Array<{ key: keyof CsvColumnMapping; label: string }> = [
|
||||
{ key: "firstName", label: t("import.csv_first_name") },
|
||||
{ key: "lastName", label: t("import.csv_last_name") },
|
||||
{ key: "email", label: t("import.csv_email") },
|
||||
{ key: "phone", label: t("import.csv_phone") },
|
||||
{ key: "company", label: t("import.csv_company") },
|
||||
{ key: "jobTitle", label: t("import.csv_job_title") },
|
||||
{ key: "address", label: t("import.csv_address") },
|
||||
{ key: "city", label: t("import.csv_city") },
|
||||
{ key: "region", label: t("import.csv_region") },
|
||||
{ key: "postcode", label: t("import.csv_postcode") },
|
||||
{ key: "country", label: t("import.csv_country") },
|
||||
{ key: "website", label: t("import.csv_website") },
|
||||
{ key: "note", label: t("import.csv_note") },
|
||||
{ key: "nickname", label: t("import.csv_nickname") },
|
||||
];
|
||||
|
||||
const headerOptions = csvData.headers.map((h, i) => ({
|
||||
value: String(i),
|
||||
label: h,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">{t("import.csv_map_columns")}</p>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto">
|
||||
{fields.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground w-24 flex-shrink-0 truncate">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
value={mapping[key] >= 0 ? String(mapping[key]) : "-1"}
|
||||
onChange={(e) => {
|
||||
setMapping((prev) => prev ? {
|
||||
...prev,
|
||||
[key]: parseInt(e.target.value, 10),
|
||||
} : null);
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-xs rounded border border-border bg-muted text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
dir="auto"
|
||||
>
|
||||
<option value="-1">{t("import.csv_ignore")}</option>
|
||||
{headerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{books.length > 0 && (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<label className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{t("import.csv_address_book")}
|
||||
</label>
|
||||
<select
|
||||
value={effectiveBookId}
|
||||
onChange={(e) => setTargetBookId(e.target.value)}
|
||||
className="px-2 py-1 text-xs rounded border border-border bg-muted text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
dir="auto"
|
||||
>
|
||||
{bookOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button size="sm" onClick={applyCsvMapping}>
|
||||
{t("import.csv_preview")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFileType(null);
|
||||
setCsvData(null);
|
||||
setMapping(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}}
|
||||
>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCsvPreview = () => {
|
||||
if (!csvData || !mapping || !showPreview) return null;
|
||||
const previewRows = csvData.rows.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">{t("import.csv_preview_title", { count: parsed.length })}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(false)}
|
||||
>
|
||||
{t("import.csv_back")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-md overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted">
|
||||
{csvData.headers.map((h, i) => (
|
||||
<th key={i} className="px-2 py-1.5 text-start font-medium text-muted-foreground whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewRows.map((row, ri) => (
|
||||
<tr key={ri} className="border-t border-border">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-2 py-1.5 truncate max-w-[150px]">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={applyCsvMapping}>
|
||||
{t("import.csv_load_all")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
@@ -119,12 +348,12 @@ export function ContactImportDialog({
|
||||
{t("import.close")}
|
||||
</Button>
|
||||
</div>
|
||||
) : parsed.length === 0 ? (
|
||||
) : fileType === null ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".vcf,.vcard"
|
||||
accept={ALLOWED_ACCEPT}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
@@ -141,7 +370,7 @@ export function ContactImportDialog({
|
||||
>
|
||||
<Upload className="w-8 h-8" />
|
||||
<p className="text-sm font-medium">{t("import.drop_hint")}</p>
|
||||
<p className="text-xs">{t("import.file_types")}</p>
|
||||
<p className="text-xs">{t("import.file_types_csv")}</p>
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
@@ -151,6 +380,10 @@ export function ContactImportDialog({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : fileType === "csv" && csvData && !showPreview ? (
|
||||
renderCsvMapping()
|
||||
) : fileType === "csv" && csvData && showPreview ? (
|
||||
renderCsvPreview()
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
@@ -217,7 +450,23 @@ export function ContactImportDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parsed.length > 0 && result === null && (
|
||||
{parsed.length > 0 && result === null && fileType !== "csv" && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={isImporting}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
|
||||
{isImporting ? t("import.importing") : t("import.import_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileType === "csv" && showPreview && parsed.length > 0 && result === null && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react";
|
||||
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, Pencil } from "lucide-react";
|
||||
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 { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
|
||||
@@ -142,6 +143,63 @@ export function ContactList({
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuContact, setRadialMenuContact] = useState<ContactCard | null>(null);
|
||||
|
||||
const openRadialMenu = useCallback((e: React.MouseEvent, contact: ContactCard) => {
|
||||
e.preventDefault();
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuContact(contact);
|
||||
setRadialMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuContact) return [];
|
||||
const c = radialMenuContact;
|
||||
const items: RadialMenuItem[] = [];
|
||||
items.push({
|
||||
id: "edit",
|
||||
icon: <Pencil className="w-5 h-5" />,
|
||||
label: t("edit"),
|
||||
onClick: () => { onEditContact(c.id); },
|
||||
});
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("delete"),
|
||||
onClick: () => { onDeleteContact(c); },
|
||||
destructive: true,
|
||||
});
|
||||
if (c.emails && Object.keys(c.emails).length > 0) {
|
||||
const contactEmails = c.emails;
|
||||
items.push({
|
||||
id: "send-email",
|
||||
icon: <Mail className="w-5 h-5" />,
|
||||
label: t("send_email"),
|
||||
onClick: () => {
|
||||
const values = Object.values(contactEmails);
|
||||
if (values[0]?.address) {
|
||||
window.location.href = `mailto:${values[0].address}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
id: "export",
|
||||
icon: <Download className="w-5 h-5" />,
|
||||
label: t("export"),
|
||||
onClick: () => { onBulkExport(); },
|
||||
});
|
||||
return items;
|
||||
}, [radialMenuContact, t, onEditContact, onDeleteContact, onBulkExport]);
|
||||
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
|
||||
const activeFilters = countActiveFilters(filters);
|
||||
@@ -571,7 +629,7 @@ export function ContactList({
|
||||
e.stopPropagation();
|
||||
onToggleSelection(contact.id);
|
||||
}}
|
||||
onContextMenu={(e, c) => openContextMenu(e, c)}
|
||||
onContextMenu={(e, c) => { openContextMenu(e, c); openRadialMenu(e, c); }}
|
||||
/>
|
||||
);
|
||||
return groupByLetter ? (
|
||||
@@ -592,6 +650,14 @@ export function ContactList({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Radial Action Menu */}
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
|
||||
{contextMenu.data && (
|
||||
<ContactContextMenu
|
||||
contact={contextMenu.data}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { EmailListItem } from '../email-list-item';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
// Mock the drag hook
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||
}));
|
||||
|
||||
// Mock identity badge
|
||||
vi.mock('../email-identity-badge', () => ({
|
||||
EmailIdentityBadge: () => null,
|
||||
}));
|
||||
|
||||
// Mock auth store
|
||||
vi.mock('@/stores/auth-store', () => ({
|
||||
useAuthStore: () => ({ identities: [] }),
|
||||
}));
|
||||
|
||||
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
||||
id: 'email-1',
|
||||
threadId: 'thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1000,
|
||||
receivedAt: '2024-01-15T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: 'Test Subject',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('EmailListItem tag badge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show tag badge when email has no label keyword', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
// No keyword label should appear
|
||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows tag badge with label when email has $label: keyword', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows tag badge for legacy $color: keyword', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a gray fallback badge when keyword id is not in settings', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
// Unknown tags fall back to the raw id as label with a gray dot
|
||||
// (see email-list-item.tsx: keywordDefs fallback).
|
||||
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows custom keyword label', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [
|
||||
...DEFAULT_KEYWORDS,
|
||||
{ id: 'work', label: 'Work', color: 'teal' },
|
||||
],
|
||||
});
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:work': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Work')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates badge when keyword definition changes', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||
const { rerender } = render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
|
||||
// Update label name
|
||||
act(() => {
|
||||
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
|
||||
});
|
||||
rerender(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Red')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders subject even without tag', () => {
|
||||
const email = makeEmail({ subject: 'Hello World' });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders inline preview text in focused mail layout', () => {
|
||||
useSettingsStore.setState({
|
||||
showPreview: true,
|
||||
mailLayout: 'focus',
|
||||
});
|
||||
const email = makeEmail({ preview: 'Inline preview content' });
|
||||
const { container } = render(<EmailListItem email={email} />);
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||
expect(container.querySelector('p')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailListItem shift-range checkbox', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' });
|
||||
});
|
||||
|
||||
it('shift-clicking the checkbox extends the selection from the anchor', () => {
|
||||
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
|
||||
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
|
||||
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
|
||||
// selection mode active (so the checkbox renders), anchor on e1
|
||||
useEmailStore.setState({
|
||||
emails: [e1, e2, e3],
|
||||
selectedEmailIds: new Set(['e1']),
|
||||
lastSelectedEmailId: 'e1',
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
|
||||
render(<EmailListItem email={e3} />);
|
||||
// the checkbox is the first button in the row (shown in selection mode)
|
||||
const checkbox = screen.getAllByRole('button')[0];
|
||||
act(() => {
|
||||
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
|
||||
});
|
||||
|
||||
const sel = useEmailStore.getState().selectedEmailIds;
|
||||
expect(sel.has('e1')).toBe(true);
|
||||
expect(sel.has('e2')).toBe(true); // the in-between row got filled in
|
||||
expect(sel.has('e3')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
|
||||
// ─── Heavy component mocks (mirrors recipient-paste.test.tsx) ─────────────────
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
|
||||
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
|
||||
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
|
||||
vi.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ ref: { current: null } }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
|
||||
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
|
||||
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
|
||||
}));
|
||||
|
||||
// ─── Store mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const state = {
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
isAuthenticated: false,
|
||||
isDemoMode: false,
|
||||
activeAccountId: null,
|
||||
connectionLost: false,
|
||||
getClientForAccount: () => undefined,
|
||||
getAllConnectedClients: () => new Map(),
|
||||
syncIdentities: () => {},
|
||||
refreshIdentities: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAuthStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/identity-store', () => {
|
||||
const state = {
|
||||
identities: [
|
||||
{ id: 'id-me', email: 'me@example.com', name: 'Me' },
|
||||
{ id: 'id-info', email: 'info@example.com', name: 'Info' },
|
||||
],
|
||||
defaultIdentityId: 'id-me',
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useIdentityStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const state = { accounts: [], getAccountById: () => undefined };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
sendRawEmail: async () => ({ sent: true }),
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useEmailStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/settings-store', () => {
|
||||
const state = {
|
||||
timeFormat: '24h',
|
||||
plainTextMode: false,
|
||||
subAddressDelimiter: '+',
|
||||
autoSelectReplyIdentity: true,
|
||||
attachmentReminderEnabled: false,
|
||||
attachmentReminderKeywords: [],
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'above_quote',
|
||||
signatureSeparatorEnabled: false,
|
||||
requestReadReceiptDefault: false,
|
||||
addTrustedSender: () => {},
|
||||
trustedSendersAddressBook: null,
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSettingsStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/contact-store', () => {
|
||||
const state = {
|
||||
contacts: [],
|
||||
getAutocomplete: async () => [],
|
||||
addToTrustedSendersBook: async () => {},
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useContactStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/template-store', () => {
|
||||
const state = { templates: [], addTemplate: async () => {} };
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useTemplateStore: hook };
|
||||
});
|
||||
|
||||
// ─── Misc dependency mocks ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { info: () => {}, error: () => {}, success: () => {} },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/plugin-hooks', () => ({
|
||||
emailHooks: {
|
||||
onComposerOpen: { call: async () => [] },
|
||||
onRecipientChange: { call: async () => [] },
|
||||
getRecipientSuggestions: { call: async () => [] },
|
||||
onSend: { call: async () => [] },
|
||||
beforeSend: { call: async () => [] },
|
||||
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-sanitization', () => ({
|
||||
sanitizeSignatureHtml: (v: string) => v,
|
||||
sanitizeEmailHtml: (v: string) => v,
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
vi.mock('@/lib/signature-utils', () => ({
|
||||
appendPlainTextSignature: (body: string) => body,
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
serializeEditorContent: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const RECEIVED = {
|
||||
from: [{ email: 'bob@other.com', name: 'Bob' }],
|
||||
to: [{ email: 'me@example.com', name: 'Me' }, { email: 'carol@other.com', name: 'Carol' }],
|
||||
cc: [{ email: 'dave@other.com', name: 'Dave' }],
|
||||
subject: 'Hello',
|
||||
};
|
||||
|
||||
/** The same conversation, but the message opened is the one we sent back. */
|
||||
const SELF_SENT = {
|
||||
from: [{ email: 'me@example.com', name: 'Me' }],
|
||||
to: [{ email: 'bob@other.com', name: 'Bob' }],
|
||||
cc: [{ email: 'carol@other.com', name: 'Carol' }],
|
||||
subject: 'Re: Hello',
|
||||
};
|
||||
|
||||
/** Chip labels currently shown in a recipient row, in order. Chips are the
|
||||
* draggable spans inside the row; next-intl is mocked to return the key, so
|
||||
* the Cc row is found via its "cc_label" caption. */
|
||||
const chipsIn = (row: HTMLElement) =>
|
||||
Array.from(row.querySelectorAll('[draggable]')).map((el) => el.textContent?.trim());
|
||||
|
||||
const toChips = () => chipsIn(screen.getByTestId('composer-to'));
|
||||
const ccChips = () => chipsIn(screen.getByText('cc_label').parentElement as HTMLElement);
|
||||
|
||||
const identitySelect = () => screen.getByTestId('composer-from') as HTMLSelectElement;
|
||||
|
||||
describe('composer reply addressing', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('addresses a reply to the sender of a received message', () => {
|
||||
render(<EmailComposer mode="reply" replyTo={RECEIVED} />);
|
||||
expect(toChips()).toEqual(['Bob (bob@other.com)']);
|
||||
});
|
||||
|
||||
it('reply-all keeps the other recipients but not our own address', () => {
|
||||
render(<EmailComposer mode="replyAll" replyTo={RECEIVED} />);
|
||||
expect(toChips()).toEqual(['Bob (bob@other.com)', 'Carol (carol@other.com)']);
|
||||
expect(ccChips()).toEqual(['Dave (dave@other.com)']);
|
||||
});
|
||||
|
||||
// #703: replying to our own message inside a thread used to address the
|
||||
// reply back to ourselves instead of continuing the conversation.
|
||||
it('addresses a reply to our own message to the original recipient', () => {
|
||||
render(<EmailComposer mode="reply" replyTo={SELF_SENT} />);
|
||||
expect(toChips()).toEqual(['Bob (bob@other.com)']);
|
||||
});
|
||||
|
||||
it('reply-all on our own message restores the original To and Cc', () => {
|
||||
render(<EmailComposer mode="replyAll" replyTo={SELF_SENT} />);
|
||||
expect(toChips()).toEqual(['Bob (bob@other.com)']);
|
||||
expect(ccChips()).toEqual(['Carol (carol@other.com)']);
|
||||
});
|
||||
|
||||
it('sends the reply to our own message from the identity that sent it', () => {
|
||||
render(<EmailComposer mode="reply" replyTo={{ ...SELF_SENT, from: [{ email: 'info@example.com', name: 'Info' }] }} />);
|
||||
expect(identitySelect().value).toBe('id-info');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TagBadge } from '../tag-badge';
|
||||
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
|
||||
|
||||
const TAGS: KeywordDefinition[] = [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
];
|
||||
|
||||
describe('TagBadge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
|
||||
});
|
||||
|
||||
it('names the tag by its full path', () => {
|
||||
render(<TagBadge tagId="work/clients" variant="badge" />);
|
||||
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a tag it has no definition for by its id', () => {
|
||||
render(<TagBadge tagId="from-elsewhere" variant="badge" />);
|
||||
expect(screen.getByText('from-elsewhere')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers removal only when asked to', () => {
|
||||
const onRemove = vi.fn();
|
||||
const { rerender } = render(<TagBadge tagId="work" variant="badge" />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<TagBadge tagId="work" variant="badge" onRemove={onRemove} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'remove_tag' }));
|
||||
expect(onRemove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('leaves the dot alone, having nowhere to put the control', () => {
|
||||
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Work')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TagPicker } from '../tag-picker';
|
||||
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
|
||||
|
||||
const TAGS: KeywordDefinition[] = [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
{ id: 'work/clients/acme', label: 'Acme', color: 'red' },
|
||||
{ id: 'personal', label: 'Personal', color: 'purple' },
|
||||
];
|
||||
|
||||
/** Ten tags is the point at which the filter box appears. */
|
||||
const MANY_TAGS: KeywordDefinition[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `tag-${i}`,
|
||||
label: i === 0 ? 'Invoices' : `Tag ${i}`,
|
||||
color: 'blue',
|
||||
}));
|
||||
|
||||
describe('TagPicker', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
|
||||
});
|
||||
|
||||
it('names a nested tag by its own label, not the whole path', () => {
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
// The tree conveys the hierarchy, so a child needs only its own name.
|
||||
expect(screen.getByText('Clients')).toBeInTheDocument();
|
||||
expect(screen.getByText('Acme')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Work/Clients')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('indents each level below its parent', () => {
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
const acme = screen.getByText('Acme');
|
||||
|
||||
// Two levels down: two nested indent wrappers between it and the list.
|
||||
const indents = acme.closest('.ps-4')?.parentElement?.closest('.ps-4');
|
||||
expect(indents).not.toBeNull();
|
||||
expect(container.querySelectorAll('.ps-4').length).toBe(2);
|
||||
});
|
||||
|
||||
it('marks the applied tags and reports toggles by id', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<TagPicker selectedIds={['work/clients']} onToggle={onToggle} />);
|
||||
|
||||
const row = screen.getByText('Clients').closest('button')!;
|
||||
expect(row).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('Work').closest('button')).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(onToggle).toHaveBeenCalledWith('work/clients');
|
||||
});
|
||||
|
||||
it('lists a tag it has no definition for, so it can be taken off', () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = render(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
|
||||
|
||||
const row = screen.getByText('from-elsewhere').closest('button')!;
|
||||
expect(row).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(onToggle).toHaveBeenCalledWith('from-elsewhere');
|
||||
|
||||
// Nothing but the message says it exists, so deselecting is the last of it.
|
||||
rerender(<TagPicker selectedIds={[]} onToggle={onToggle} />);
|
||||
expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('counts undefined tags towards the filter box, and matches them', () => {
|
||||
const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`);
|
||||
const { container } = render(<TagPicker selectedIds={strays} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } });
|
||||
expect(within(container).getByText('stray-3')).toBeInTheDocument();
|
||||
expect(within(container).queryByText('Work')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the filter box until the list is long enough to need one', () => {
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument();
|
||||
|
||||
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
expect(screen.getAllByLabelText('tag_filter_placeholder').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('flattens to matches while filtering, and says so when there are none', () => {
|
||||
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'invo' } });
|
||||
expect(within(container).getByText('Invoices')).toBeInTheDocument();
|
||||
expect(within(container).queryByText('Tag 5')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'zzz' } });
|
||||
expect(within(container).getByText('tag_no_matches')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches the full path, so a child is reachable by its parent name', () => {
|
||||
useSettingsStore.setState({ emailKeywords: [...TAGS, ...MANY_TAGS] });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'work/cli' } });
|
||||
// Filtered rows are flat, so they carry the whole path.
|
||||
expect(within(container).getByText('Work/Clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists tags flat when nesting is off', () => {
|
||||
useSettingsStore.setState({ nestedTags: false });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
expect(container.querySelectorAll('.ps-4').length).toBe(0);
|
||||
expect(screen.getByText('Clients')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ThreadListItem } from '../thread-list-item';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { groupEmailsByThread } from '@/lib/thread-utils';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth-store', () => ({
|
||||
useAuthStore: () => ({ identities: [] }),
|
||||
}));
|
||||
|
||||
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
||||
id: 'email-1',
|
||||
threadId: 'thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1000,
|
||||
receivedAt: '2024-01-15T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: 'Test Subject',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A one-message thread, built through the real grouping so the fixture cannot
|
||||
* drift from what the list actually feeds this component. `ThreadListItem`
|
||||
* delegates to `SingleEmailItem` at that size, which is what draws every
|
||||
* single-message row in the app.
|
||||
*/
|
||||
function renderRow(email: Email) {
|
||||
const [thread] = groupEmailsByThread([email]);
|
||||
return render(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThreadListItem tag badge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show a tag badge when the email has no label keyword', () => {
|
||||
renderRow(makeEmail({ keywords: { $seen: true } }));
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a tag badge for a $label: keyword', () => {
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a tag badge for the legacy $color: keyword', () => {
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$color:blue': true } }));
|
||||
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the raw id when the tag is not in settings', () => {
|
||||
// A keyword created by another client, or one whose definition was deleted.
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } }));
|
||||
|
||||
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a custom tag label', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS, { id: 'work', label: 'Work', color: 'teal' }],
|
||||
});
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$label:work': true } }));
|
||||
|
||||
expect(screen.getByText('Work')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('follows a renamed tag definition', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||
const { rerender } = renderRow(email);
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
|
||||
});
|
||||
const [thread] = groupEmailsByThread([email]);
|
||||
rerender(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Red')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem multi-message thread', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
function renderThread(emails: Email[], expanded = false) {
|
||||
const [thread] = groupEmailsByThread(emails);
|
||||
return render(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={expanded}
|
||||
expandedEmails={expanded ? emails : undefined}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('carries the tags of every message, not just the first', () => {
|
||||
// A collapsed row stands in for the whole thread, so a tag applied only to
|
||||
// a later message still has to surface.
|
||||
renderThread([
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
|
||||
]);
|
||||
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a tag shared by several messages once', () => {
|
||||
renderThread([
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
]);
|
||||
|
||||
expect(screen.getAllByText('Red')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows each message its own tags once the thread is expanded', () => {
|
||||
renderThread(
|
||||
[
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
// Once on the header and once on the message that carries it.
|
||||
expect(screen.getAllByText('Red').length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem row content', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the subject without a tag', () => {
|
||||
renderRow(makeEmail({ subject: 'Hello World' }));
|
||||
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders preview text inline in the focused layout', () => {
|
||||
useSettingsStore.setState({ showPreview: true, mailLayout: 'focus' });
|
||||
const { container } = renderRow(makeEmail({ preview: 'Inline preview content' }));
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||
// Focused rows are one line: the preview shares the subject's element
|
||||
// rather than getting a paragraph of its own.
|
||||
expect(container.querySelector('p')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem shift-range checkbox', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
});
|
||||
|
||||
it('shift-clicking the checkbox extends the selection from the anchor', () => {
|
||||
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
|
||||
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
|
||||
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
|
||||
// Selection mode active so the checkbox renders, with the anchor on e1.
|
||||
useEmailStore.setState({
|
||||
emails: [e1, e2, e3],
|
||||
selectedEmailIds: new Set(['e1']),
|
||||
lastSelectedEmailId: 'e1',
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
|
||||
renderRow(e3);
|
||||
const checkbox = screen.getAllByRole('button')[0];
|
||||
act(() => {
|
||||
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
|
||||
});
|
||||
|
||||
const selected = useEmailStore.getState().selectedEmailIds;
|
||||
expect(selected.has('e1')).toBe(true);
|
||||
expect(selected.has('e2')).toBe(true); // the row in between got filled in
|
||||
expect(selected.has('e3')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem row tint', () => {
|
||||
const rowClasses = (container: HTMLElement) =>
|
||||
container.querySelector('[data-email-id="email-1"]')!.className.split(' ');
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
tintListRowsByTag: true,
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set(['email-1']),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a checked row tinted, and says so to either theme', () => {
|
||||
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||
const classes = rowClasses(container);
|
||||
|
||||
expect(classes).toContain('bg-red-50');
|
||||
expect(classes).toContain('dark:bg-red-950/30');
|
||||
expect(classes).not.toContain('bg-accent/40');
|
||||
expect(classes).toContain('ring-primary/20');
|
||||
});
|
||||
|
||||
it('washes a checked row that has no tint to keep', () => {
|
||||
const { container } = renderRow(makeEmail({ keywords: { $seen: true } }));
|
||||
const classes = rowClasses(container);
|
||||
|
||||
expect(classes).toContain('bg-accent/40');
|
||||
expect(classes).toContain('ring-primary/20');
|
||||
});
|
||||
|
||||
it('leaves the tint alone when the setting is off', () => {
|
||||
useSettingsStore.setState({ tintListRowsByTag: false });
|
||||
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||
const classes = rowClasses(container);
|
||||
|
||||
expect(classes).not.toContain('bg-red-50');
|
||||
expect(classes).toContain('bg-accent/40');
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
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, CalendarClock, ChevronDown, MailCheck, Search, Users } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search, Users, PenLine } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -24,6 +24,7 @@ import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useSignatureStore } from "@/stores/signature-store";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
@@ -36,7 +37,8 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { findComposeIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { findComposeIdentityId, findDraftIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
|
||||
import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
|
||||
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import {
|
||||
rewriteCidImagesForEditor,
|
||||
@@ -297,6 +299,34 @@ export function EmailComposer({
|
||||
const { isFeatureEnabled } = usePolicyStore();
|
||||
const templatesEnabled = isFeatureEnabled('templatesEnabled');
|
||||
|
||||
const {
|
||||
signatures,
|
||||
defaultSignatureId,
|
||||
replySignatureId,
|
||||
getSignatureById,
|
||||
getIdentityDefaultSignatureId,
|
||||
getIdentityReplySignatureId,
|
||||
} = useSignatureStore();
|
||||
|
||||
const resolveStoreSignatureId = (): string | null => {
|
||||
const perIdentityId = selectedIdentityId || initialData?.selectedIdentityId || null;
|
||||
if (mode === 'compose') {
|
||||
if (perIdentityId) {
|
||||
const id = getIdentityDefaultSignatureId(perIdentityId);
|
||||
if (id) return id;
|
||||
}
|
||||
return defaultSignatureId;
|
||||
}
|
||||
if (perIdentityId) {
|
||||
const id = getIdentityReplySignatureId(perIdentityId);
|
||||
if (id) return id;
|
||||
}
|
||||
return replySignatureId ?? defaultSignatureId;
|
||||
};
|
||||
|
||||
const [selectedSignatureId, setSelectedSignatureId] = useState<string | null>(resolveStoreSignatureId);
|
||||
const selectedSignature = selectedSignatureId ? getSignatureById(selectedSignatureId) ?? null : null;
|
||||
|
||||
// The signature identity used when embedding the signature into the initial
|
||||
// body for "above quote" mode. Mirrors the signatureIdentity derivation
|
||||
// below, but uses initialData (or primary) since selectedIdentityId state
|
||||
@@ -322,31 +352,17 @@ export function EmailComposer({
|
||||
const toRecipient = (r: { name?: string; email?: string }): Recipient =>
|
||||
({ name: r.name && r.name !== r.email ? r.name : undefined, email: r.email ?? "" });
|
||||
|
||||
const ownIdentityEmails = identities.map(i => i.email).filter((e): e is string => Boolean(e));
|
||||
|
||||
// Initialize with reply/forward data if provided
|
||||
const getInitialTo = (): Recipient[] => {
|
||||
if (!replyTo) return [];
|
||||
// RFC 5322: use Reply-To header if present, otherwise fall back to From
|
||||
const replyTarget = replyTo.replyToAddresses?.length
|
||||
? replyTo.replyToAddresses.filter(r => r.email).map(toRecipient)
|
||||
: (replyTo.from?.[0]?.email ? [toRecipient(replyTo.from[0])] : []);
|
||||
if (mode === 'reply') {
|
||||
return replyTarget;
|
||||
} else if (mode === 'replyAll') {
|
||||
const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean));
|
||||
const originalTo = (replyTo.to ?? [])
|
||||
.filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase()))
|
||||
.map(toRecipient);
|
||||
return [...replyTarget, ...originalTo];
|
||||
}
|
||||
return [];
|
||||
if (mode !== 'reply' && mode !== 'replyAll') return [];
|
||||
return buildReplyRecipients(replyTo, mode, ownIdentityEmails).to.map(toRecipient);
|
||||
};
|
||||
|
||||
const getInitialCc = (): Recipient[] => {
|
||||
if (!replyTo || mode !== 'replyAll') return [];
|
||||
const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean));
|
||||
return (replyTo.cc ?? [])
|
||||
.filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase()))
|
||||
.map(toRecipient);
|
||||
if (mode !== 'replyAll') return [];
|
||||
return buildReplyRecipients(replyTo, mode, ownIdentityEmails).cc.map(toRecipient);
|
||||
};
|
||||
|
||||
const getInitialSubject = () => {
|
||||
@@ -522,17 +538,19 @@ export function EmailComposer({
|
||||
// requests with the same draftId. See bug #303.
|
||||
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
|
||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
|
||||
if (mode === 'forward' && replyTo?.attachments?.length) {
|
||||
return replyTo.attachments
|
||||
if (replyTo?.attachments?.length) {
|
||||
let atts = replyTo.attachments;
|
||||
if (mode === 'forward') {
|
||||
// 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,
|
||||
}));
|
||||
atts = atts.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')));
|
||||
}
|
||||
return atts.map(att => ({
|
||||
name: att.name || 'attachment',
|
||||
type: att.type || 'application/octet-stream',
|
||||
size: att.size,
|
||||
blobId: att.blobId,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
@@ -605,6 +623,7 @@ export function EmailComposer({
|
||||
// when the user switches identity in "above quote" mode without rebuilding
|
||||
// the whole body (which would lose user edits to the surrounding draft).
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const prevSignatureIdentityIdRef = useRef<string | null | undefined>(signatureIdentity?.id);
|
||||
const prevSignatureSeparatorRef = useRef<boolean>(signatureSeparatorEnabled);
|
||||
|
||||
@@ -681,6 +700,28 @@ export function EmailComposer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
|
||||
|
||||
const sigInsertedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (plainTextMode) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
if (!selectedSignatureId) return;
|
||||
if (sigInsertedRef.current) return;
|
||||
const sig = getSignatureById(selectedSignatureId);
|
||||
if (!sig) return;
|
||||
const currentHtml = serializeEditorContent(editor);
|
||||
if (currentHtml.includes(sig.body)) {
|
||||
sigInsertedRef.current = true;
|
||||
return;
|
||||
}
|
||||
sigInsertedRef.current = true;
|
||||
if (mode === 'compose') {
|
||||
editor.chain().focus('end').insertContent(`<p></p>${sig.body}`).run();
|
||||
} else if ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') {
|
||||
editor.chain().focus('start').insertContent(sig.body).run();
|
||||
}
|
||||
}, [selectedSignatureId, plainTextMode, mode, signaturePosition, getSignatureById, editorReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutsideSendMenu = (event: MouseEvent) => {
|
||||
if (!sendMenuRef.current?.contains(event.target as Node)) {
|
||||
@@ -716,6 +757,18 @@ export function EmailComposer({
|
||||
|
||||
if (mode !== 'reply' && mode !== 'replyAll') return;
|
||||
|
||||
// Replying to our own message in a thread (#703): keep sending as the
|
||||
// identity that sent it. Resolving from the recipients here would pick the
|
||||
// *other* party's address - and on a catch-all domain it would even set a
|
||||
// From override to their address.
|
||||
if (isSelfSent({ from: replyTo?.from }, identities.map(i => i.email).filter(Boolean))) {
|
||||
const senderIdentityId = findDraftIdentityId(identities, replyTo?.from?.[0]);
|
||||
if (senderIdentityId) {
|
||||
setSelectedIdentityId(senderIdentityId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = resolveReplyFrom(identities, {
|
||||
to: replyTo?.to,
|
||||
cc: replyTo?.cc,
|
||||
@@ -755,6 +808,7 @@ export function EmailComposer({
|
||||
replyTo?.accountId,
|
||||
replyTo?.bcc,
|
||||
replyTo?.cc,
|
||||
replyTo?.from,
|
||||
replyTo?.to,
|
||||
selectedIdentityId,
|
||||
]);
|
||||
@@ -1848,7 +1902,12 @@ export function EmailComposer({
|
||||
inReplyTo: threadingHeaders?.inReplyTo?.[0],
|
||||
};
|
||||
const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview);
|
||||
if (!sendAllowed) return;
|
||||
if (!sendAllowed) {
|
||||
// A plugin vetoed the send (it is expected to show its own UI).
|
||||
// Leave a trace so a silent no-op send is diagnosable (#592).
|
||||
debug.log('email', 'Send aborted by an onBeforeEmailSend plugin handler');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hand off to a crypto plugin (S/MIME, PGP, …) if one wants to take over
|
||||
// the send: it builds raw MIME, signs/encrypts, and submits via
|
||||
@@ -2493,7 +2552,7 @@ export function EmailComposer({
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
onEditorReady={(ed) => { editorRef.current = ed; }}
|
||||
onEditorReady={(ed) => { editorRef.current = ed; setEditorReady(true); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2652,8 +2711,53 @@ export function EmailComposer({
|
||||
<PluginSlot name="composer-toolbar" />
|
||||
</div>
|
||||
|
||||
{/* Right side - Discard + Send (desktop) */}
|
||||
{/* Right side - Signature selector + Discard + Send (desktop) */}
|
||||
<div className="flex items-center gap-2">
|
||||
{signatures.length > 0 && (
|
||||
<div className="relative hidden md:inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!editorRef.current) return;
|
||||
const sig = selectedSignature;
|
||||
if (sig) {
|
||||
editorRef.current.chain().focus().insertContent(sig.body).run();
|
||||
}
|
||||
}}
|
||||
title={t('insert_signature')}
|
||||
className="h-8 px-2 text-xs gap-1"
|
||||
disabled={!selectedSignature}
|
||||
>
|
||||
<PenLine className="w-4 h-4" />
|
||||
{selectedSignature?.name ?? t('no_signature')}
|
||||
</Button>
|
||||
<select
|
||||
value={selectedSignatureId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
setSelectedSignatureId(id || null);
|
||||
if (id && editorRef.current) {
|
||||
const sig = getSignatureById(id);
|
||||
if (sig) {
|
||||
editorRef.current.chain().focus().insertContent(sig.body).run();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
title={t('select_signature')}
|
||||
aria-label={t('select_signature')}
|
||||
>
|
||||
<option value="">{t('no_signature')}</option>
|
||||
{signatures.map((sig) => (
|
||||
<option key={sig.id} value={sig.id}>
|
||||
{sig.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
|
||||
@@ -23,8 +23,6 @@ import {
|
||||
Archive,
|
||||
FolderInput,
|
||||
Tag,
|
||||
X,
|
||||
Check,
|
||||
Inbox,
|
||||
Send,
|
||||
File,
|
||||
@@ -34,10 +32,12 @@ import {
|
||||
EditIcon,
|
||||
CalendarClock,
|
||||
XCircle,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
@@ -59,12 +59,13 @@ interface EmailContextMenuProps {
|
||||
onReply?: () => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onForwardAsAttachment?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onToggleStar?: () => void;
|
||||
onTogglePinned?: () => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onSetTag?: (tagId: string | null) => void;
|
||||
onMoveToMailbox?: (mailboxId: string) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
@@ -99,20 +100,6 @@ const getMailboxIcon = (role?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Get all active label/color tag IDs from email keywords
|
||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||
tags.push(
|
||||
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
|
||||
export function EmailContextMenu({
|
||||
email,
|
||||
position,
|
||||
@@ -127,12 +114,13 @@ export function EmailContextMenu({
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onForwardAsAttachment,
|
||||
onMarkAsRead,
|
||||
onToggleStar,
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMoveToMailbox,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
@@ -149,13 +137,12 @@ export function EmailContextMenu({
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const _tColor = useTranslations("email_viewer.color_tag");
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const tEmailViewer = useTranslations("email_viewer");
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isDraft = email.keywords?.['$draft'] === true;
|
||||
const currentColors = getCurrentColors(email.keywords);
|
||||
const currentTagIds = getEmailTagIds(email.keywords);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||
@@ -164,13 +151,6 @@ export function EmailContextMenu({
|
||||
const isScheduled = email.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||
|
||||
// Build color options from keyword definitions in settings
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
name: kw.label,
|
||||
value: kw.id,
|
||||
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
|
||||
}));
|
||||
|
||||
// Build mailbox tree for move-to submenu with proper hierarchy
|
||||
const moveTargetIds = new Set(
|
||||
mailboxes
|
||||
@@ -277,6 +257,12 @@ export function EmailContextMenu({
|
||||
onClick={() => handleAction(onForward!)}
|
||||
disabled={!onForward}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Paperclip}
|
||||
label={tEmailViewer("forward_as_attachment")}
|
||||
onClick={() => handleAction(onForwardAsAttachment!)}
|
||||
disabled={!onForwardAsAttachment || !email.blobId}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
@@ -370,37 +356,13 @@ export function EmailContextMenu({
|
||||
|
||||
{/* Set tag submenu - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
role="menuitem"
|
||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="flex-1">{option.name}</span>
|
||||
{isActive && (
|
||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={X}
|
||||
label={t("remove_color")}
|
||||
onClick={() => handleAction(() => onSetColorTag?.(null))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
|
||||
<div className="w-56 max-w-[18rem]">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => onSetTag?.(tagId)}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ interface EmailHoverActionsProps {
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onSetTag?: (tagId: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
||||
// the spam quick-action flips to "not spam".
|
||||
@@ -76,7 +76,7 @@ export function EmailHoverActions({
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMarkAsSpam,
|
||||
isInJunk = false,
|
||||
onUndoSpam,
|
||||
@@ -112,7 +112,7 @@ export function EmailHoverActions({
|
||||
onArchive?.();
|
||||
break;
|
||||
case "tag":
|
||||
onSetColorTag?.(null);
|
||||
onSetTag?.(null);
|
||||
break;
|
||||
case "spam":
|
||||
if (isInJunk) onUndoSpam?.();
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback } from "react";
|
||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { EmailHoverActions } from "./email-hover-actions";
|
||||
import { getEmailColorTags } from "@/lib/thread-utils";
|
||||
|
||||
interface EmailListItemProps {
|
||||
email: Email;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const { identities } = useAuthStore();
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
|
||||
// In aggregate role-views the selected mailbox is virtual → fall back to the
|
||||
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
|
||||
// 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) ?? { id, label: id, color: 'gray' });
|
||||
// Use first tag for background coloring
|
||||
const keywordDef = keywordDefs[0] ?? null;
|
||||
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
// Drag and drop functionality
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
email,
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
|
||||
email
|
||||
);
|
||||
}, [onContextMenu, email]),
|
||||
isMobile
|
||||
);
|
||||
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) {
|
||||
// Shift-click extends the selection from the anchor to here, like
|
||||
// shift-clicking the row (the checkbox stops propagation, so the
|
||||
// row's shift handler never runs — replicate it here).
|
||||
selectRangeEmails(email.id);
|
||||
} else {
|
||||
toggleEmailSelection(email.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...dragHandlers}
|
||||
{...longPressHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
// Apply color tag as background, with selected and unread states
|
||||
colorTag ? colorTag : (
|
||||
selected
|
||||
? "bg-selection"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !selected && !colorTag && "bg-warning/10",
|
||||
// Add visual feedback for checked state
|
||||
isChecked && "ring-2 ring-primary/20 bg-selection/60",
|
||||
// Drag state visual feedback
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||
// Long press visual feedback
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
toggleEmailSelection(email.id);
|
||||
} else if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRangeEmails(email.id);
|
||||
} else {
|
||||
if (selectedEmailIds.size > 0) clearSelection();
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<div
|
||||
className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleCheckboxClick}
|
||||
className={cn(
|
||||
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||
!isFocusedMailLayout && 'mt-2',
|
||||
"hover:bg-muted/50 hover:scale-110",
|
||||
"active:scale-95",
|
||||
"animate-in fade-in zoom-in-95 duration-150",
|
||||
isChecked && "text-primary"
|
||||
)}
|
||||
>
|
||||
{isChecked ? (
|
||||
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
|
||||
) : (
|
||||
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
{density !== 'extra-compact' && (
|
||||
<SelectableAvatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
checked={isChecked}
|
||||
onToggle={() => toggleEmailSelection(email.id)}
|
||||
selectLabel={tBatch('select')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{sender?.name || sender?.email || 'Unknown'}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'min-w-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{isAnswered && isForwarded && (
|
||||
<>
|
||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{keywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||
))}
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
)}
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{isImportant && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
|
||||
Important
|
||||
</span>
|
||||
)}
|
||||
<EmailIdentityBadge email={email} identities={identities} compact={true} />
|
||||
{isAnswered && !isForwarded && (
|
||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{isForwarded && !isAnswered && (
|
||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{isAnswered && isForwarded && (
|
||||
<>
|
||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{keywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||
{kd.label}
|
||||
</span>
|
||||
))}
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{trimmedPreview || t('no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+144
-23
@@ -15,8 +15,11 @@ import { useUIStore } from "@/stores/ui-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import { Reply, ReplyAll, Forward, Star, Archive, FolderOpen } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { SearchChips } from "@/components/search/search-chips";
|
||||
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||
|
||||
@@ -33,12 +36,13 @@ interface EmailListProps {
|
||||
onReply?: (email: Email) => void;
|
||||
onReplyAll?: (email: Email) => void;
|
||||
onForward?: (email: Email) => void;
|
||||
onForwardAsAttachment?: (email: Email) => void;
|
||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
onTogglePinned?: (email: Email) => void;
|
||||
onDelete?: (email: Email) => void;
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onSetTag?: (emailId: string, tagId: string | null) => void;
|
||||
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
@@ -63,12 +67,13 @@ export function EmailList({
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onForwardAsAttachment,
|
||||
onMarkAsRead,
|
||||
onToggleStar,
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
onMoveToMailbox,
|
||||
@@ -130,10 +135,115 @@ export function EmailList({
|
||||
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
|
||||
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||
/**
|
||||
* The row the menu was opened on, as the list currently has it. The menu holds
|
||||
* the message it was handed when it opened, but tags can be applied from
|
||||
* inside it without dismissing it, so what it draws has to keep up.
|
||||
*/
|
||||
const contextMenuEmail = contextMenu.data
|
||||
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
|
||||
: null;
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuEmail, setRadialMenuEmail] = useState<Email | null>(null);
|
||||
|
||||
const openRadialMenu = useCallback((e: React.MouseEvent, email: Email) => {
|
||||
e.preventDefault();
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuEmail(email);
|
||||
setRadialMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuEmail) return [];
|
||||
const email = radialMenuEmail;
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
|
||||
const act = (fn?: (email: Email) => void) => fn ? () => { fn(email); } : undefined;
|
||||
|
||||
const items: RadialMenuItem[] = [];
|
||||
|
||||
if (onReply) {
|
||||
items.push({
|
||||
id: "reply",
|
||||
icon: <Reply className="w-5 h-5" />,
|
||||
label: t("../context_menu.reply"),
|
||||
onClick: () => { act(onReply)!(); },
|
||||
});
|
||||
}
|
||||
if (onReplyAll) {
|
||||
items.push({
|
||||
id: "reply-all",
|
||||
icon: <ReplyAll className="w-5 h-5" />,
|
||||
label: t("../context_menu.reply_all"),
|
||||
onClick: () => { act(onReplyAll)!(); },
|
||||
});
|
||||
}
|
||||
if (onForward) {
|
||||
items.push({
|
||||
id: "forward",
|
||||
icon: <Forward className="w-5 h-5" />,
|
||||
label: t("../context_menu.forward"),
|
||||
onClick: () => { act(onForward)!(); },
|
||||
});
|
||||
}
|
||||
if (onToggleStar) {
|
||||
items.push({
|
||||
id: "star",
|
||||
icon: <Star className="w-5 h-5" fill={isStarred ? "currentColor" : "none"} />,
|
||||
label: isStarred ? t("../context_menu.unstar") : t("../context_menu.star"),
|
||||
onClick: () => { act(onToggleStar)!(); },
|
||||
});
|
||||
}
|
||||
if (onMarkAsRead) {
|
||||
items.push({
|
||||
id: "mark-read",
|
||||
icon: isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />,
|
||||
label: isUnread ? t("../context_menu.mark_read") : t("../context_menu.mark_unread"),
|
||||
onClick: () => { onMarkAsRead(email, !isUnread); },
|
||||
});
|
||||
}
|
||||
if (onArchive) {
|
||||
items.push({
|
||||
id: "archive",
|
||||
icon: <Archive className="w-5 h-5" />,
|
||||
label: t("../context_menu.archive"),
|
||||
onClick: () => { act(onArchive)!(); },
|
||||
});
|
||||
}
|
||||
if (onDelete) {
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("../context_menu.delete"),
|
||||
onClick: () => { act(onDelete)!(); },
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
if (onMoveToMailbox) {
|
||||
items.push({
|
||||
id: "move",
|
||||
icon: <FolderOpen className="w-5 h-5" />,
|
||||
label: t("../context_menu.move_to"),
|
||||
onClick: () => { openContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: radialMenuPos.x, clientY: radialMenuPos.y } as React.MouseEvent, email); },
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [radialMenuEmail, radialMenuPos, t, onReply, onReplyAll, onForward, onToggleStar, onMarkAsRead, onArchive, onDelete, onMoveToMailbox, openContextMenu]);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// One tag treatment for the whole list, measured from the scroll container.
|
||||
const tagDisplay = useMeasuredTagDisplay(parentRef);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -330,6 +440,7 @@ export function EmailList({
|
||||
}, [density, isFocusedMailLayout, showPreview]);
|
||||
|
||||
return (
|
||||
<TagDisplayContext.Provider value={tagDisplay}>
|
||||
<div className={cn("flex flex-col min-h-0", className)}>
|
||||
{/* Batch Actions Toolbar */}
|
||||
<div
|
||||
@@ -535,13 +646,13 @@ export function EmailList({
|
||||
onEmailSelect?.(email);
|
||||
}}
|
||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={openContextMenu}
|
||||
onContextMenu={(e, email) => { openContextMenu(e, email); openRadialMenu(e, email); }}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onSetTag={onSetTag}
|
||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
||||
/>
|
||||
@@ -567,10 +678,18 @@ export function EmailList({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Radial Action Menu */}
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu.data && (
|
||||
{contextMenuEmail && (
|
||||
<EmailContextMenu
|
||||
email={contextMenu.data}
|
||||
email={contextMenuEmail}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onClose={closeContextMenu}
|
||||
@@ -578,24 +697,25 @@ export function EmailList({
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
currentMailboxRole={effectiveMailboxRole}
|
||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
||||
isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
|
||||
selectedCount={selectedEmailIds.size}
|
||||
onReply={() => onReply?.(contextMenu.data!)}
|
||||
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
||||
onForward={() => onForward?.(contextMenu.data!)}
|
||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
||||
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
||||
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
|
||||
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
|
||||
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
|
||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
|
||||
onReply={() => onReply?.(contextMenuEmail!)}
|
||||
onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
|
||||
onForward={() => onForward?.(contextMenuEmail!)}
|
||||
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)}
|
||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)}
|
||||
onToggleStar={() => onToggleStar?.(contextMenuEmail!)}
|
||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined}
|
||||
onDelete={() => onDelete?.(contextMenuEmail!)}
|
||||
onArchive={() => onArchive?.(contextMenuEmail!)}
|
||||
onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)}
|
||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)}
|
||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
|
||||
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
|
||||
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
|
||||
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined}
|
||||
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
|
||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchArchive={async () => {
|
||||
@@ -642,5 +762,6 @@ export function EmailList({
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</TagDisplayContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
+140
-162
@@ -9,9 +9,15 @@ import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourc
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { resolveThemeLogo } from "@/lib/theme-logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { generateEmailSource } from "@/lib/email-source";
|
||||
@@ -19,6 +25,7 @@ import {
|
||||
Reply,
|
||||
ReplyAll,
|
||||
Forward,
|
||||
Paperclip,
|
||||
Trash2,
|
||||
Archive,
|
||||
Star,
|
||||
@@ -69,11 +76,12 @@ import {
|
||||
PlayCircle,
|
||||
PenSquare,
|
||||
CalendarClock,
|
||||
CalendarPlus,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -81,6 +89,8 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
@@ -109,11 +119,12 @@ interface EmailViewerProps {
|
||||
onReply?: (draftText?: string) => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onForwardAsAttachment?: () => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (emailId: string, read: boolean) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onSetTag?: (emailId: string, tagId: string | null) => void;
|
||||
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void;
|
||||
onQuickReply?: (body: string) => Promise<void>;
|
||||
onMarkAsSpam?: () => void;
|
||||
@@ -198,19 +209,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
|
||||
return 'Attachment';
|
||||
};
|
||||
|
||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||
tags.push(
|
||||
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
|
||||
// Helper function to format recipients with contextual display
|
||||
const _formatRecipients = (
|
||||
recipients: Array<{ name?: string; email: string }> | undefined,
|
||||
@@ -621,11 +619,12 @@ export function EmailViewer({
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onForwardAsAttachment,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onToggleStar,
|
||||
onMarkAsRead,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onDownloadAttachment,
|
||||
onQuickReply,
|
||||
onMarkAsSpam,
|
||||
@@ -664,6 +663,7 @@ export function EmailViewer({
|
||||
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -706,12 +706,9 @@ export function EmailViewer({
|
||||
const isScheduled = email?.isScheduled === true;
|
||||
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
|
||||
|
||||
// Color options for email tags (from user-defined keyword settings)
|
||||
const colorOptions = emailKeywords.map((kw) => ({
|
||||
name: kw.label,
|
||||
value: kw.id,
|
||||
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
|
||||
}));
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
const createAppointmentVisible = !isScheduled && !isDraft && calendarEnabled && !!email;
|
||||
|
||||
|
||||
// Tablet list visibility
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
@@ -776,6 +773,8 @@ export function EmailViewer({
|
||||
return new Date(time).toISOString();
|
||||
}, [client, t, tComposer]);
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const activeThemeId = useThemeStore((state) => state.activeThemeId);
|
||||
const installedThemes = useThemeStore((state) => state.installedThemes);
|
||||
const { startTour } = useTour();
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
@@ -816,8 +815,13 @@ export function EmailViewer({
|
||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
||||
const currentColors = getCurrentColors(email?.keywords);
|
||||
const currentColor = currentColors[0] ?? null;
|
||||
const currentTagIds = getEmailTagIds(email?.keywords);
|
||||
const sortedTagIds = sortTagIds(currentTagIds);
|
||||
// The header spans the reading pane, so it measures its own width rather than
|
||||
// inheriting the message list's answer.
|
||||
const headerTagsRef = useRef<HTMLDivElement>(null);
|
||||
const { variant: headerTagVariant } = useMeasuredTagDisplay(headerTagsRef);
|
||||
const currentColor = currentTagIds[0] ?? null;
|
||||
|
||||
// Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic
|
||||
// onRenderEmailBody hook. Verification/decryption status UI is provided by the
|
||||
@@ -1015,7 +1019,7 @@ export function EmailViewer({
|
||||
showToolbarLabels,
|
||||
isLoading,
|
||||
moveTree.length,
|
||||
colorOptions.length,
|
||||
emailKeywords.length,
|
||||
currentColor,
|
||||
isInJunkFolder,
|
||||
isTablet,
|
||||
@@ -1031,6 +1035,34 @@ export function EmailViewer({
|
||||
const { isMobile: isMobileDevice } = useDeviceDetection();
|
||||
const router = useRouter();
|
||||
|
||||
const handleCreateAppointment = useCallback(() => {
|
||||
if (!email) return;
|
||||
const subject = email.subject ? `Re: ${email.subject}` : "";
|
||||
const body = email.htmlBody?.[0]?.partId
|
||||
? email.bodyValues?.[email.htmlBody[0].partId]?.value || ""
|
||||
: "";
|
||||
const participants: { name?: string; email: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addParticipant = (p?: { name?: string; email?: string }) => {
|
||||
if (!p?.email) return;
|
||||
const normalized = p.email.toLowerCase();
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
participants.push({ name: p.name, email: p.email });
|
||||
}
|
||||
};
|
||||
if (email.from) email.from.forEach(addParticipant);
|
||||
if (email.to) email.to.forEach(addParticipant);
|
||||
if (email.cc) email.cc.forEach(addParticipant);
|
||||
useCalendarStore.getState().setNewEventPrefill({
|
||||
title: subject,
|
||||
description: body,
|
||||
participants,
|
||||
date: email.receivedAt,
|
||||
});
|
||||
router.push('/calendar');
|
||||
}, [email, router]);
|
||||
|
||||
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
|
||||
if (isMobileDevice) {
|
||||
// No room for a sidebar on mobile - send the user to the contacts page
|
||||
@@ -2734,15 +2766,20 @@ export function EmailViewer({
|
||||
|
||||
if (!email) {
|
||||
if (isDemoMode) {
|
||||
const logoSrc = withBasePath(resolvedTheme === 'dark'
|
||||
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
|
||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
|
||||
// Same resolution as navigation-rail.tsx/login: active theme's own
|
||||
// brand logo (SRC mark / VNClagoon wordmark), falling back to the SRC
|
||||
// mark rather than a hardcoded brand image - this demo empty state has
|
||||
// no admin-override concept of its own, so there's no global override
|
||||
// to check here.
|
||||
const logoSrc = withBasePath(
|
||||
resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', '/branding/SRC_Symbol.png', '/branding/SRC_Symbol.png'),
|
||||
);
|
||||
return (
|
||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||
<div className="text-center p-8 max-w-md">
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Bulwark Mail"
|
||||
alt="VNCmail+"
|
||||
className="h-12 mx-auto mb-6"
|
||||
/>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h3>
|
||||
@@ -2906,6 +2943,20 @@ export function EmailViewer({
|
||||
<Forward className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
|
||||
</Button>
|
||||
{createAppointmentVisible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCreateAppointment}
|
||||
data-overflow-item
|
||||
data-overflow-priority="3.5"
|
||||
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('create_appointment')}
|
||||
>
|
||||
<CalendarPlus className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('create_appointment')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
</>)}
|
||||
<PluginSlot name="toolbar-actions" />
|
||||
</div>
|
||||
@@ -2994,64 +3045,18 @@ export function EmailViewer({
|
||||
<div ref={tagMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||
className={cn(
|
||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
||||
currentColors.length > 0 && "bg-muted/50"
|
||||
)}
|
||||
title={t('set_color')}
|
||||
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
|
||||
title={t('set_tag')}
|
||||
>
|
||||
{currentColors.length > 0 ? (
|
||||
<>
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.slice(0, 3).map((tagId) => {
|
||||
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 ?? currentColors[0]}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag className="w-4 h-4 text-muted-foreground" />
|
||||
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
||||
</>
|
||||
)}
|
||||
<Tag className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
|
||||
</button>
|
||||
{tagMenuOpen && (
|
||||
<div className="absolute end-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="truncate">{option.name}</span>
|
||||
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setTagMenuOpen(false); }}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<X className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3243,7 +3248,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
{/* Overflow: tag - submenu */}
|
||||
{colorOptions.length > 0 && (
|
||||
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
|
||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||
onMouseLeave={() => setMoreMenuSub(null)}
|
||||
@@ -3257,36 +3262,11 @@ export function EmailViewer({
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
{moreMenuSub === 'tag' && (
|
||||
<div className="absolute end-full top-0 me-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="truncate">{option.name}</span>
|
||||
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<X className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3340,6 +3320,16 @@ export function EmailViewer({
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
{/* Forward as attachment */}
|
||||
{onForwardAsAttachment && email?.blobId && (
|
||||
<button
|
||||
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
{t('forward_as_attachment')}
|
||||
</button>
|
||||
)}
|
||||
{/* Export email */}
|
||||
<button
|
||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
@@ -3420,19 +3410,21 @@ export function EmailViewer({
|
||||
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
||||
</button>
|
||||
{/* Tag (opens sub-view) */}
|
||||
{colorOptions.length > 0 && (
|
||||
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
|
||||
<button
|
||||
onClick={() => setMoreMenuSub('tag')}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
||||
>
|
||||
<Tag className="w-5 h-5" />
|
||||
<span className="flex-1">{t('tag')}</span>
|
||||
{currentColors.length > 0 && (
|
||||
{currentTagIds.length > 0 && (
|
||||
<div className="flex -space-x-1 me-1">
|
||||
{currentColors.slice(0, 3).map((c) => {
|
||||
const opt = colorOptions.find((o) => o.value === c);
|
||||
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
|
||||
})}
|
||||
{sortedTagIds.slice(0, 3).map((tagId) => (
|
||||
<span
|
||||
key={tagId}
|
||||
className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
@@ -3462,6 +3454,15 @@ export function EmailViewer({
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
{onForwardAsAttachment && email?.blobId && (
|
||||
<button
|
||||
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
||||
>
|
||||
<Paperclip className="w-5 h-5" />
|
||||
{t('forward_as_attachment')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
||||
@@ -3519,35 +3520,12 @@ export function EmailViewer({
|
||||
};
|
||||
return renderMobileNodes(moveTree);
|
||||
})()}
|
||||
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
|
||||
<>
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className={cn(
|
||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="truncate">{option.name}</span>
|
||||
{isActive && <Check className="w-4 h-4 ms-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||
>
|
||||
<X className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
{moreMenuSub === 'tag' && (
|
||||
<TagPicker
|
||||
touch
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3605,24 +3583,24 @@ export function EmailViewer({
|
||||
)} />
|
||||
</button>
|
||||
)}
|
||||
{/* Color tag dots */}
|
||||
{currentColors.length > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.map((tagId) => {
|
||||
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>
|
||||
)}
|
||||
{isImportant && (
|
||||
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
||||
{t('important')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sortedTagIds.length > 0 && (
|
||||
<div ref={headerTagsRef} className="mt-1.5 flex flex-wrap items-center gap-1">
|
||||
{sortedTagIds.map((tagId) => (
|
||||
<TagBadge
|
||||
key={tagId}
|
||||
tagId={tagId}
|
||||
variant={headerTagVariant}
|
||||
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
|
||||
<div className="hidden sm:block flex-shrink-0 text-end">
|
||||
|
||||
@@ -21,6 +21,7 @@ import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-ht
|
||||
import { SignatureBlock } from "@/components/email/signature-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
@@ -153,6 +154,7 @@ const TEXT_COLORS = [
|
||||
];
|
||||
|
||||
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
||||
const t = useTranslations("email_composer.toolbar");
|
||||
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
||||
return (
|
||||
<div>
|
||||
@@ -180,7 +182,7 @@ function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => v
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1.5 text-center">
|
||||
{hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"}
|
||||
{hover ? `${hover.r + 1} × ${hover.c + 1}` : t("pick_size")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -343,6 +345,7 @@ export function RichTextEditor({
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const tToolbar = useTranslations("email_composer.toolbar");
|
||||
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
||||
@@ -383,28 +386,28 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
active={editor.isActive("bold")}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
title="Bold"
|
||||
title={tToolbar("bold")}
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("italic")}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
title="Italic"
|
||||
title={tToolbar("italic")}
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("underline")}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
title="Underline"
|
||||
title={tToolbar("underline")}
|
||||
>
|
||||
<UnderlineIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("strike")}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
title="Strikethrough"
|
||||
title={tToolbar("strikethrough")}
|
||||
>
|
||||
<Strikethrough className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -412,7 +415,7 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
active={!!editor.getAttributes("textStyle").color}
|
||||
onClick={() => setColorMenuOpen((v) => !v)}
|
||||
title="Text color"
|
||||
title={tToolbar("text_color")}
|
||||
>
|
||||
{/* The icon itself previews the active colour - no layout shift. */}
|
||||
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
|
||||
@@ -446,7 +449,7 @@ export function RichTextEditor({
|
||||
setColorMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" /> Remove color
|
||||
<RemoveFormatting className="w-4 h-4" /> {tToolbar("remove_color")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -457,14 +460,14 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
active={editor.isActive("heading", { level: 1 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
title="Heading 1"
|
||||
title={tToolbar("heading_1")}
|
||||
>
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("heading", { level: 2 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
title="Heading 2"
|
||||
title={tToolbar("heading_2")}
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -474,28 +477,28 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
active={editor.isActive("bulletList")}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
title="Bullet List"
|
||||
title={tToolbar("bullet_list")}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("orderedList")}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
title="Ordered List"
|
||||
title={tToolbar("ordered_list")}
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("blockquote")}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
title="Quote"
|
||||
title={tToolbar("quote")}
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("codeBlock")}
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
title="Code Block"
|
||||
title={tToolbar("code_block")}
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -505,21 +508,21 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
active={editor.isActive({ textAlign: "left" })}
|
||||
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||
title="Align Left"
|
||||
title={tToolbar("align_left")}
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive({ textAlign: "center" })}
|
||||
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||||
title="Align Center"
|
||||
title={tToolbar("align_center")}
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive({ textAlign: "right" })}
|
||||
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||
title="Align Right"
|
||||
title={tToolbar("align_right")}
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -534,7 +537,7 @@ export function RichTextEditor({
|
||||
editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir;
|
||||
editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run();
|
||||
}}
|
||||
title="Text direction (RTL/LTR)"
|
||||
title={tToolbar("text_direction")}
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -545,7 +548,7 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
active={editor.isActive("link")}
|
||||
onClick={addLink}
|
||||
title="Link"
|
||||
title={tToolbar("link")}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -554,7 +557,7 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
active={editor.isActive("table")}
|
||||
onClick={() => setTableMenuOpen((v) => !v)}
|
||||
title="Table"
|
||||
title={tToolbar("table")}
|
||||
>
|
||||
<TableIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -567,28 +570,28 @@ export function RichTextEditor({
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Rows3 className="w-4 h-4" /> Add row above
|
||||
<Rows3 className="w-4 h-4" /> {tToolbar("add_row_above")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Rows3 className="w-4 h-4" /> Add row below
|
||||
<Rows3 className="w-4 h-4" /> {tToolbar("add_row_below")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Columns3 className="w-4 h-4" /> Add column before
|
||||
<Columns3 className="w-4 h-4" /> {tToolbar("add_column_before")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Columns3 className="w-4 h-4" /> Add column after
|
||||
<Columns3 className="w-4 h-4" /> {tToolbar("add_column_after")}
|
||||
</button>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
@@ -596,21 +599,21 @@ export function RichTextEditor({
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> Delete row
|
||||
<Trash2 className="w-4 h-4" /> {tToolbar("delete_row")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> Delete column
|
||||
<Trash2 className="w-4 h-4" /> {tToolbar("delete_column")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
|
||||
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Rows3 className="w-4 h-4" /> Toggle header row
|
||||
<Rows3 className="w-4 h-4" /> {tToolbar("toggle_header_row")}
|
||||
</button>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
@@ -618,7 +621,7 @@ export function RichTextEditor({
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start text-red-600 dark:text-red-400"
|
||||
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> Delete table
|
||||
<Trash2 className="w-4 h-4" /> {tToolbar("delete_table")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -637,7 +640,7 @@ export function RichTextEditor({
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||
title="Clear Formatting"
|
||||
title={tToolbar("clear_formatting")}
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
@@ -647,14 +650,14 @@ export function RichTextEditor({
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo"
|
||||
title={tToolbar("undo")}
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo"
|
||||
title={tToolbar("redo")}
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
|
||||
/**
|
||||
* How much room the surface has for a tag.
|
||||
* - `badge` names the tag; `dot` only identifies it by colour.
|
||||
*/
|
||||
export type TagBadgeVariant = "badge" | "dot";
|
||||
|
||||
/**
|
||||
* The lozenge shape, shared so anything standing next to a tag lines up with
|
||||
* it rather than approximating its padding and text size.
|
||||
*/
|
||||
export const TAG_LOZENGE_CLASS =
|
||||
"inline-flex min-w-0 shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium";
|
||||
|
||||
/**
|
||||
* The row a group of tags sits in. Using it for neighbouring lozenges too keeps
|
||||
* the spacing between them the same as the spacing within them - a wider gap on
|
||||
* one side is what makes a neighbour look indented.
|
||||
*/
|
||||
export const TAG_GROUP_CLASS = "flex shrink-0 items-center gap-1";
|
||||
|
||||
/**
|
||||
* A tag, drawn the one way tags are drawn.
|
||||
*
|
||||
* The lozenge carries the colour in its border and text rather than pairing a
|
||||
* swatch with plain text: the name is the tag, and the colour is how you pick
|
||||
* it out of a row at a glance. That also matches every other coloured pill in
|
||||
* the app, all of which set a text colour alongside the background.
|
||||
*
|
||||
* A deep name shortens to fit its own box (`Work/../Acme`) before the browser
|
||||
* clips it, so the outermost and innermost levels survive.
|
||||
*/
|
||||
export function TagBadge({
|
||||
tagId,
|
||||
variant,
|
||||
onRemove,
|
||||
className,
|
||||
}: {
|
||||
tagId: string;
|
||||
variant: TagBadgeVariant;
|
||||
/**
|
||||
* Takes the tag off the message. Only the named form offers it - a dot is the
|
||||
* size of the control it would have to hold.
|
||||
*/
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat();
|
||||
const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId));
|
||||
const color = tagColor(tagId);
|
||||
const name = tagName(tagId);
|
||||
|
||||
if (variant === "dot") {
|
||||
return (
|
||||
<span
|
||||
className={cn("h-2.5 w-2.5 shrink-0 rounded-full", color.dot, className)}
|
||||
title={name}
|
||||
aria-label={name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"max-w-[12rem] border",
|
||||
color.fill,
|
||||
color.border,
|
||||
color.text,
|
||||
className,
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
<span ref={labelRef} className="min-w-0 truncate">
|
||||
{shortenedName}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="ms-0.5 shrink-0 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
title={t("remove_tag")}
|
||||
aria-label={t("remove_tag")}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
|
||||
/** Below this many tags a filter box costs more room than it saves. */
|
||||
const SEARCH_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* The list of tags to apply to a message.
|
||||
*
|
||||
* Shared by all four places one appears - the toolbar popover, the overflow
|
||||
* flyout, the mobile sheet and the context menu - because they had drifted into
|
||||
* four different dot sizes, check alignments and separators, and only one of
|
||||
* them capped its height.
|
||||
*
|
||||
* Nested tags are drawn as a tree rather than repeating the parent's name on
|
||||
* every child. Filtering flattens it: with a query the hierarchy is noise, and
|
||||
* the full path is what gets matched.
|
||||
*/
|
||||
export function TagPicker({
|
||||
selectedIds,
|
||||
onToggle,
|
||||
touch = false,
|
||||
}: {
|
||||
selectedIds: string[];
|
||||
onToggle: (tagId: string) => void;
|
||||
/** Larger hit areas for the mobile sheet. */
|
||||
touch?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const keywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const nestedTags = useSettingsStore((state) => state.nestedTags);
|
||||
const { tagName, tagColor } = useKeywordFormat();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
|
||||
/**
|
||||
* Tags on the message this client has no definition for - set from another
|
||||
* client, or outliving the tag they were made with. Listing them is the only
|
||||
* way to take one off, and they leave the list as they are deselected because
|
||||
* nothing but the message itself records that they exist.
|
||||
*/
|
||||
const unknownIds = useMemo(
|
||||
() =>
|
||||
selectedIds
|
||||
.filter((id) => !keywords.some((keyword) => keyword.id === id))
|
||||
.sort((a, b) => tagName(a).localeCompare(tagName(b))),
|
||||
// `tagName` is rebuilt whenever the definitions or the nesting setting change.
|
||||
[selectedIds, keywords, tagName],
|
||||
);
|
||||
|
||||
const showSearch = keywords.length + unknownIds.length >= SEARCH_THRESHOLD;
|
||||
|
||||
const matches = useMemo(
|
||||
() =>
|
||||
trimmedQuery
|
||||
? [...keywords.map((keyword) => keyword.id), ...unknownIds].filter((id) =>
|
||||
tagName(id).toLowerCase().includes(trimmedQuery),
|
||||
)
|
||||
: [],
|
||||
[keywords, unknownIds, trimmedQuery, tagName],
|
||||
);
|
||||
|
||||
const tree = useMemo(
|
||||
() => (nestedTags ? buildKeywordTree(keywords) : keywords.map((k) => ({ ...k, children: [], depth: 0 }))),
|
||||
[keywords, nestedTags],
|
||||
);
|
||||
|
||||
const rowClass = cn(
|
||||
"w-full text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
touch ? "px-4 py-2.5 min-h-[44px] text-sm gap-3" : "px-3 py-1.5 text-sm",
|
||||
);
|
||||
const dotClass = touch ? "w-3.5 h-3.5" : "w-3 h-3";
|
||||
const checkClass = touch ? "w-4 h-4" : "w-3.5 h-3.5";
|
||||
|
||||
const renderRow = (id: string, label: string) => {
|
||||
const isActive = selectedIds.includes(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={isActive}
|
||||
onClick={() => onToggle(id)}
|
||||
className={cn(rowClass, isActive && "bg-accent font-medium")}
|
||||
title={tagName(id)}
|
||||
>
|
||||
<span className={cn("rounded-full flex-shrink-0", dotClass, tagColor(id).dot)} />
|
||||
<span className="flex-1 min-w-0 truncate">{label}</span>
|
||||
{isActive && <Check className={cn("ms-auto flex-shrink-0 text-foreground", checkClass)} />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBranch = (nodes: KeywordNode[]) =>
|
||||
nodes.map((node) => (
|
||||
<div key={node.id}>
|
||||
{renderRow(node.id, node.depth === 0 ? tagName(node.id) : node.label)}
|
||||
{node.children.length > 0 && <div className="ps-4">{renderBranch(node.children)}</div>}
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSearch && (
|
||||
<div className={cn("relative", touch ? "px-3 pb-2" : "px-2 pb-1")}>
|
||||
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("tag_filter_placeholder")}
|
||||
aria-label={t("tag_filter_placeholder")}
|
||||
className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[min(20rem,60vh)] overflow-y-auto">
|
||||
{trimmedQuery ? (
|
||||
matches.length > 0 ? (
|
||||
matches.map((id) => renderRow(id, tagName(id)))
|
||||
) : (
|
||||
<p className="px-3 py-2 text-sm text-muted-foreground">{t("tag_no_matches")}</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{renderBranch(tree)}
|
||||
{unknownIds.length > 0 && (
|
||||
<>
|
||||
{keywords.length > 0 && <div className="h-px bg-border my-1" />}
|
||||
{unknownIds.map((id) => renderRow(id, tagName(id)))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
|
||||
interface ThreadEmailItemProps {
|
||||
email: Email;
|
||||
@@ -35,6 +39,11 @@ export function ThreadEmailItem({
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const { sortTagIds } = useKeywordFormat();
|
||||
const { variant: tagVariant } = useTagDisplay();
|
||||
// A message inside an expanded thread carries its own tags; the collapsed
|
||||
// header pools them, so without this they disappear on the way in.
|
||||
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
|
||||
const sender = email.from?.[0];
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
@@ -178,6 +187,9 @@ export function ThreadEmailItem({
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview snippet */}
|
||||
|
||||
@@ -6,11 +6,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useSettingsStore } 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 { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { ThreadEmailItem } from "./thread-email-item";
|
||||
@@ -34,6 +37,28 @@ function SourceFolderTag({ name }: { name: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many messages a collapsed thread stands for.
|
||||
*
|
||||
* Built from the tag lozenge so it lines up with the tags it sits next to: the
|
||||
* same shape, and the same group spacing.
|
||||
*/
|
||||
function ThreadCountPill({ count, hasUnread, title }: { count: number; hasUnread: boolean; title: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"gap-0.5",
|
||||
hasUnread ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadListItemProps {
|
||||
thread: ThreadGroup;
|
||||
isExpanded: boolean;
|
||||
@@ -50,7 +75,7 @@ interface ThreadListItemProps {
|
||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||
onDelete?: (email: Email) => void;
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onSetTag?: (emailId: string, tagId: string | null) => void;
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
}
|
||||
@@ -62,18 +87,18 @@ interface SingleEmailItemProps {
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
rowTint: string | null;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onSetTag?: (tagId: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
@@ -89,7 +114,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -110,14 +136,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
? formatDateTime(email.scheduledSendAt, timeFormat)
|
||||
: null;
|
||||
|
||||
// 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) ?? { id, label: id, color: 'gray' });
|
||||
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||
const resolvedColorTag = !tintListRowsByTag ? null : (() => {
|
||||
if (colorTag) return colorTag;
|
||||
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
||||
})();
|
||||
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
|
||||
const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null));
|
||||
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
email,
|
||||
@@ -172,19 +192,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
data-unread={isUnread ? 'true' : 'false'}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
resolvedColorTag ? resolvedColorTag : (
|
||||
resolvedRowTint ? resolvedRowTint : (
|
||||
selected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !resolvedColorTag && "shadow-sm",
|
||||
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !resolvedColorTag && "bg-accent/30",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
selected && !resolvedRowTint && "shadow-sm",
|
||||
!resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !resolvedRowTint && "bg-accent/30",
|
||||
isChecked && "ring-2 ring-primary/20",
|
||||
isChecked && !resolvedRowTint && "bg-accent/40",
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
isPressed && "scale-[0.98] ring-2 ring-primary/30",
|
||||
isPressed && !resolvedRowTint && "bg-muted"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(e) => {
|
||||
@@ -258,6 +280,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{sender?.name || sender?.email || 'Unknown'}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
{tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
'min-w-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
@@ -281,9 +310,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||
))}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -322,6 +348,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
{tagPlacement === 'sender' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
@@ -347,15 +380,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||
{kd.label}
|
||||
</span>
|
||||
))}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -378,13 +402,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1.5">
|
||||
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"min-w-0 flex-1 truncate text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
@@ -406,12 +439,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{!email.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
backgroundClassName={resolvedRowTint ? resolvedRowTint : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onSetTag={onSetTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
@@ -440,7 +473,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
}, ref) {
|
||||
@@ -497,11 +530,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
);
|
||||
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
|
||||
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
||||
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
// A collapsed row speaks for every message under it, so it carries their tags too.
|
||||
const tagIds = sortTagIds(getThreadTagIds(thread.emails));
|
||||
const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null;
|
||||
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
@@ -518,12 +552,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
rowTint={rowTint}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
/>
|
||||
@@ -597,19 +631,21 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{...threadLongPressHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
||||
colorTag ? colorTag : (
|
||||
rowTint ? rowTint : (
|
||||
isSelected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
isSelected && !colorTag && "shadow-sm",
|
||||
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
||||
isSelected && !rowTint && "shadow-sm",
|
||||
!rowTint && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!rowTint && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
rowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !rowTint && !isSelected && "bg-accent/30",
|
||||
isExpanded && "border-b border-border/50",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
isChecked && "ring-2 ring-primary/20",
|
||||
isChecked && !rowTint && "bg-accent/40",
|
||||
isThreadPressed && "scale-[0.98] ring-2 ring-primary/30",
|
||||
isThreadPressed && !rowTint && "bg-muted"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onDoubleClick={(e) => {
|
||||
@@ -706,22 +742,25 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||
// Matches SingleEmailItem: the sender column sets where
|
||||
// every row's tags and subject begin, so the two have to
|
||||
// agree or thread rows sit 1rem further right.
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{displayNames.join(', ')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
|
||||
hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{emailCount}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
<ThreadCountPill
|
||||
count={emailCount}
|
||||
hasUnread={hasUnread}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
/>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'min-w-0 truncate',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
@@ -745,9 +784,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</>
|
||||
)}
|
||||
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{keywordDef && (
|
||||
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
||||
)}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -786,17 +822,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}>
|
||||
{displayNames.join(", ")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
||||
hasUnread
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{emailCount}
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
<ThreadCountPill
|
||||
count={emailCount}
|
||||
hasUnread={hasUnread}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
/>
|
||||
{tagPlacement === 'sender' && tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasPinned && (
|
||||
@@ -823,15 +857,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{keywordDef && (
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||
{keywordDef.label}
|
||||
</span>
|
||||
)}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -854,13 +879,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1.5">
|
||||
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"min-w-0 flex-1 truncate text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
@@ -882,12 +916,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{!latestEmail.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
backgroundClassName={rowTint ? rowTint : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
AlertCircle, Star, Clock, FolderUp,
|
||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||
Menu, Users, Share2,
|
||||
Menu, Users, Share2, MailPlus, Paperclip, ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,6 +27,7 @@ import { Avatar } from "@/components/ui/avatar";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
|
||||
@@ -106,6 +107,8 @@ interface FileBrowserProps {
|
||||
sharingEnabled?: boolean;
|
||||
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
|
||||
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
|
||||
/** Send selected files as email attachments - opens the composer with files pre-attached. */
|
||||
onSendAsAttachment?: (names: string[]) => void;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
|
||||
@@ -205,6 +208,14 @@ function isDatabaseFile(name: string): boolean {
|
||||
return DATABASE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
const OFFICE_EXTENSIONS = new Set([
|
||||
"docx", "xlsx", "pptx", "odt", "ods", "odp", "doc", "xls", "ppt",
|
||||
]);
|
||||
function isOfficeFile(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
return OFFICE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
function isPreviewable(name: string): boolean {
|
||||
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
||||
}
|
||||
@@ -384,6 +395,7 @@ export function FileBrowser({
|
||||
ownAccountId,
|
||||
sharingEnabled,
|
||||
onShare,
|
||||
onSendAsAttachment,
|
||||
}: FileBrowserProps) {
|
||||
const t = useTranslations("files");
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
@@ -401,6 +413,60 @@ export function FileBrowser({
|
||||
[sharingEnabled, onShare, client]);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
||||
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuResourceName, setRadialMenuResourceName] = useState<string | null>(null);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuResourceName) return [];
|
||||
const name = radialMenuResourceName;
|
||||
const resource = resources.find((r) => r.name === name);
|
||||
const items: RadialMenuItem[] = [];
|
||||
items.push({
|
||||
id: "rename",
|
||||
icon: <Pencil className="w-5 h-5" />,
|
||||
label: t("rename"),
|
||||
onClick: () => { setRenameTarget(name); },
|
||||
});
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("delete"),
|
||||
onClick: () => { onDelete(name); },
|
||||
destructive: true,
|
||||
});
|
||||
if (resource && !resource.isDirectory) {
|
||||
items.push({
|
||||
id: "download",
|
||||
icon: <Download className="w-5 h-5" />,
|
||||
label: t("download"),
|
||||
onClick: () => { onDownload(name); },
|
||||
});
|
||||
}
|
||||
if (canShare(resource)) {
|
||||
items.push({
|
||||
id: "share",
|
||||
icon: <Share2 className="w-5 h-5" />,
|
||||
label: t("share"),
|
||||
onClick: () => { if (resource?.id) setShareTargetId(resource.id); },
|
||||
});
|
||||
}
|
||||
if (resource && !resource.isDirectory) {
|
||||
items.push({
|
||||
id: "send-as-attachment",
|
||||
icon: <Paperclip className="w-5 h-5" />,
|
||||
label: t("send_as_attachment"),
|
||||
onClick: () => {},
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [radialMenuResourceName, resources, t, onDelete, onDownload, canShare]);
|
||||
const [showNewTextFile, setShowNewTextFile] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -765,6 +831,9 @@ export function FileBrowser({
|
||||
const handleContextMenu = (e: React.MouseEvent, name: string) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, name });
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuResourceName(name);
|
||||
setRadialMenuOpen(true);
|
||||
};
|
||||
|
||||
// Adjust context menu position to stay within viewport
|
||||
@@ -974,28 +1043,76 @@ export function FileBrowser({
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{selectedResources.size > 1 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))}
|
||||
>
|
||||
<Download className="w-4 h-4 me-1" />
|
||||
{t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length})
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onBatchDelete([...selectedResources])}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
{t("delete")} ({selectedResources.size})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{selectedResources.size > 0 && (() => {
|
||||
const fileNames = [...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory);
|
||||
const hasFiles = fileNames.length > 0;
|
||||
const showBatch = selectedResources.size > 1;
|
||||
if (!showBatch && !hasFiles) return null;
|
||||
return (
|
||||
<>
|
||||
{showBatch && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => onBatchDownload(fileNames)}
|
||||
>
|
||||
<Download className="w-4 h-4 me-1" />
|
||||
{t("download")} ({fileNames.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onBatchDelete([...selectedResources])}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
{t("delete")} ({selectedResources.size})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{hasFiles && onSendAsAttachment && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => onSendAsAttachment(fileNames)}
|
||||
>
|
||||
<MailPlus className="w-4 h-4 me-1" />
|
||||
{t("send_as_attachment")} {fileNames.length > 1 && `(${fileNames.length})`}
|
||||
</Button>
|
||||
)}
|
||||
{!showBatch && hasFiles && fileNames.length === 1 && isOfficeFile(fileNames[0]) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={async () => {
|
||||
const file = resources.find((r) => r.name === fileNames[0]);
|
||||
if (!file) return;
|
||||
try {
|
||||
const res = await fetch("/api/collabora/edit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { url } = await res.json();
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Collabora edit failed:", err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4 me-1" />
|
||||
Edit with Collabora
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{clipboard && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1669,6 +1786,14 @@ export function FileBrowser({
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Radial Action Menu */}
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
@@ -1707,6 +1832,32 @@ export function FileBrowser({
|
||||
{t("download")}
|
||||
</button>
|
||||
)}
|
||||
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isOfficeFile(contextMenu.name) && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={async () => {
|
||||
const file = resources.find((r) => r.name === contextMenu.name);
|
||||
if (!file) { setContextMenu(null); return; }
|
||||
try {
|
||||
const res = await fetch("/api/collabora/edit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { url } = await res.json();
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Collabora edit failed:", err);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Edit with Collabora
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||
onClick={() => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
|
||||
interface FilterRuleModalProps {
|
||||
rule?: FilterRule;
|
||||
@@ -89,6 +90,7 @@ export function FilterRuleModal({
|
||||
const t = useTranslations("settings.filters");
|
||||
const isEdit = !!rule;
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { tagName } = useKeywordFormat();
|
||||
|
||||
const [name, setName] = useState(rule?.name || "");
|
||||
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
||||
@@ -477,7 +479,7 @@ export function FilterRuleModal({
|
||||
>
|
||||
<option value="">{t("label_placeholder")}</option>
|
||||
{emailKeywords.map((kw) => (
|
||||
<option key={kw.id} value={kw.id}>{kw.label}</option>
|
||||
<option key={kw.id} value={kw.id}>{tagName(kw.id)}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
|
||||
import type { Identity, EmailAddress } from '@/lib/jmap/types';
|
||||
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
|
||||
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
|
||||
import { useSignatureStore } from '@/stores/signature-store';
|
||||
|
||||
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
|
||||
const SIGNATURE_MAX_BYTES = 2047;
|
||||
@@ -73,6 +74,15 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const {
|
||||
signatures,
|
||||
identitySignatureMap,
|
||||
setIdentitySignature,
|
||||
} = useSignatureStore();
|
||||
const identitySigMapping = identity?.id ? (identitySignatureMap[identity.id] ?? {}) : {};
|
||||
const [sigDefaultId, setSigDefaultId] = useState<string>(identitySigMapping.defaultId ?? '');
|
||||
const [sigReplyId, setSigReplyId] = useState<string>(identitySigMapping.replyId ?? '');
|
||||
|
||||
const parseEmailList = (input: string): EmailAddress[] | undefined => {
|
||||
if (!input.trim()) return undefined;
|
||||
|
||||
@@ -134,6 +144,10 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
};
|
||||
|
||||
await onSave(sanitizedData);
|
||||
if (identity?.id) {
|
||||
setIdentitySignature(identity.id, 'default', sigDefaultId || null);
|
||||
setIdentitySignature(identity.id, 'reply', sigReplyId || null);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -266,6 +280,57 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Signature Store Mapping (per-identity) */}
|
||||
{isEditing && signatures.length > 0 && (
|
||||
<div className="border border-border rounded-md p-4 space-y-3 bg-muted/30">
|
||||
<p className="text-sm font-medium text-foreground">{t('signature_store_mapping')}</p>
|
||||
<div>
|
||||
<label htmlFor="identity-sig-default" className="block text-xs text-muted-foreground mb-1">
|
||||
{t('signature_store_default')}
|
||||
</label>
|
||||
<select
|
||||
id="identity-sig-default"
|
||||
value={sigDefaultId}
|
||||
onChange={(e) => {
|
||||
setSigDefaultId(e.target.value);
|
||||
if (identity?.id) {
|
||||
setIdentitySignature(identity.id, 'default', e.target.value || null);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t('use_global_default')}</option>
|
||||
{signatures.map((sig) => (
|
||||
<option key={sig.id} value={sig.id}>{sig.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="identity-sig-reply" className="block text-xs text-muted-foreground mb-1">
|
||||
{t('signature_store_reply')}
|
||||
</label>
|
||||
<select
|
||||
id="identity-sig-reply"
|
||||
value={sigReplyId}
|
||||
onChange={(e) => {
|
||||
setSigReplyId(e.target.value);
|
||||
if (identity?.id) {
|
||||
setIdentitySignature(identity.id, 'reply', e.target.value || null);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t('use_global_default')}</option>
|
||||
{signatures.map((sig) => (
|
||||
<option key={sig.id} value={sig.id}>{sig.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Signature */}
|
||||
<div>
|
||||
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { evictAll } from '@/lib/account-state-manager';
|
||||
|
||||
/**
|
||||
* After a master-user impersonation handoff (`GET /api/auth/impersonate`), the
|
||||
* server swaps the slot-0 session cookie but the client's *persisted* account
|
||||
* registry still lists the PREVIOUS account — so the top-left account chip keeps
|
||||
* showing the old mailbox even though the message list is correctly the new one.
|
||||
* Only a manual sign-out (which clears `account-registry` / `auth-storage`) fixes
|
||||
* it, because that state lives in localStorage and the impersonation redirect
|
||||
* never reconciles it. (Reported downstream: jabali-panel #646.)
|
||||
*
|
||||
* The impersonate route now redirects to `/?impersonated=1`. Here we drop the
|
||||
* stale persisted account + auth state (and the server-derived caches) and
|
||||
* reload to a clean URL, so the app rehydrates empty and re-derives the single
|
||||
* account from the fresh session cookie — the same result as the manual
|
||||
* sign-out-then-reopen, done automatically. Cookies are untouched, so the
|
||||
* just-granted impersonation session survives the reload.
|
||||
*/
|
||||
const STALE_KEYS = [
|
||||
'account-registry',
|
||||
'auth-storage',
|
||||
'identity-storage',
|
||||
'contact-storage',
|
||||
'calendar-storage',
|
||||
'calendar-notification-storage',
|
||||
];
|
||||
|
||||
export function ImpersonationReconciler() {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('impersonated') !== '1') return;
|
||||
|
||||
try {
|
||||
evictAll();
|
||||
} catch {
|
||||
/* in-memory snapshots are best-effort */
|
||||
}
|
||||
for (const key of STALE_KEYS) {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore storage access errors */
|
||||
}
|
||||
}
|
||||
|
||||
// Reload to a clean URL (drop the marker) so the now-empty persisted stores
|
||||
// rehydrate and the app reconnects + re-derives the impersonated account
|
||||
// from the session cookie. The marker is gone on the second load, so this
|
||||
// runs exactly once.
|
||||
params.delete('impersonated');
|
||||
const query = params.toString();
|
||||
window.location.replace(window.location.pathname + (query ? `?${query}` : ''));
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FolderX,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Position {
|
||||
@@ -86,6 +87,7 @@ interface MailboxContextMenuProps {
|
||||
onRenameFolder?: (mailboxId: string) => void;
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onShareFolder?: (mailboxId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
@@ -105,6 +107,7 @@ export function MailboxContextMenu({
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onShareFolder,
|
||||
onRefresh,
|
||||
}: MailboxContextMenuProps) {
|
||||
const t = useTranslations("mailbox_context_menu");
|
||||
@@ -191,6 +194,12 @@ export function MailboxContextMenu({
|
||||
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
||||
disabled={!onRenameFolder || !canRename}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Share2}
|
||||
label={t("share_folder")}
|
||||
onClick={() => handleAction(() => onShareFolder?.(mailbox.id))}
|
||||
disabled={!onShareFolder || mailbox.isShared}
|
||||
/>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
|
||||
@@ -7,6 +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 { resolveThemeLogo } from "@/lib/theme-logo";
|
||||
import { usePathname, Link, useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
@@ -188,8 +189,10 @@ export function NavigationRail({
|
||||
const t = useTranslations("sidebar");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
|
||||
const { appLogoLightUrl, appLogoDarkUrl, appLogoLightUrlIsCustom, appLogoDarkUrlIsCustom } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const activeThemeId = useThemeStore((s) => s.activeThemeId);
|
||||
const installedThemes = useThemeStore((s) => s.installedThemes);
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { mailboxes } = useEmailStore();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
@@ -462,7 +465,7 @@ export function NavigationRail({
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl));
|
||||
const logoUrl = withBasePath(resolveThemeLogo(installedThemes, activeThemeId, resolvedTheme === 'dark', appLogoLightUrl, appLogoDarkUrl, appLogoLightUrlIsCustom || appLogoDarkUrlIsCustom));
|
||||
return logoUrl ? (
|
||||
<div className="flex items-center justify-center py-3 px-1">
|
||||
<img
|
||||
|
||||
+208
-54
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { MiniCalendarDashlet } from "@/components/calendar/mini-calendar-dashlet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Inbox,
|
||||
@@ -35,9 +36,19 @@ import {
|
||||
BellOff,
|
||||
Mails,
|
||||
MailOpen,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import {
|
||||
buildKeywordTree,
|
||||
countKeywordNodes,
|
||||
filterKeywordTree,
|
||||
hasChildKeywords,
|
||||
type KeywordNode,
|
||||
} from "@/lib/keyword-nesting";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -51,7 +62,8 @@ import { useTagDrop } from "@/hooks/use-tag-drop";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store";
|
||||
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -78,6 +90,7 @@ interface SidebarProps {
|
||||
onDeleteFolder?: (mailboxId: string) => void;
|
||||
onImportEmail?: (mailboxId: string) => void;
|
||||
onRefreshMailboxes?: () => void;
|
||||
onShareFolder?: (mailboxId: string) => void;
|
||||
scheduledTotal?: number;
|
||||
showScheduledMailbox?: boolean;
|
||||
/** True when the unified view spans multiple login accounts (cross-account).
|
||||
@@ -241,6 +254,9 @@ function SidebarRowCounts({
|
||||
interface SidebarRowProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
/** Progressively shorter renderings of `label`, longest first. The widest one
|
||||
* that fits the row is shown; without this the full label is used. */
|
||||
labelCandidates?: string[];
|
||||
depth?: number;
|
||||
isSelected?: boolean;
|
||||
isVirtual?: boolean;
|
||||
@@ -266,6 +282,7 @@ interface SidebarRowProps {
|
||||
function SidebarRow({
|
||||
icon,
|
||||
label,
|
||||
labelCandidates,
|
||||
depth = 0,
|
||||
isSelected = false,
|
||||
isVirtual = false,
|
||||
@@ -288,6 +305,7 @@ function SidebarRow({
|
||||
}: SidebarRowProps) {
|
||||
const t = useTranslations('sidebar');
|
||||
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
||||
const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -353,7 +371,7 @@ function SidebarRow({
|
||||
</span>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
|
||||
<SidebarRowCounts
|
||||
unread={unread}
|
||||
total={total}
|
||||
@@ -542,78 +560,120 @@ function MailboxTreeItem({
|
||||
);
|
||||
}
|
||||
|
||||
const TAG_ICON_COLOR: Record<string, string> = {
|
||||
red: "text-red-600/75 dark:text-red-400/75",
|
||||
orange: "text-orange-600/75 dark:text-orange-400/75",
|
||||
yellow: "text-yellow-600/75 dark:text-yellow-400/75",
|
||||
green: "text-green-600/75 dark:text-green-400/75",
|
||||
blue: "text-blue-600/75 dark:text-blue-400/75",
|
||||
purple: "text-purple-600/75 dark:text-purple-400/75",
|
||||
pink: "text-pink-600/75 dark:text-pink-400/75",
|
||||
teal: "text-teal-600/75 dark:text-teal-400/75",
|
||||
cyan: "text-cyan-600/75 dark:text-cyan-400/75",
|
||||
indigo: "text-indigo-600/75 dark:text-indigo-400/75",
|
||||
amber: "text-amber-600/75 dark:text-amber-400/75",
|
||||
lime: "text-lime-600/75 dark:text-lime-400/75",
|
||||
gray: "text-gray-500",
|
||||
};
|
||||
function ShowAllTagsRow({
|
||||
hiddenCount,
|
||||
showAll,
|
||||
onToggle,
|
||||
isCollapsed,
|
||||
}: {
|
||||
hiddenCount: number;
|
||||
showAll: boolean;
|
||||
onToggle: () => void;
|
||||
isCollapsed: boolean;
|
||||
}) {
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
return (
|
||||
<SidebarRow
|
||||
icon={<MoreHorizontal className="w-4 h-4 text-muted-foreground" />}
|
||||
label={showAll ? t('show_fewer_tags') : t('show_all_tags', { count: hiddenCount })}
|
||||
depth={0}
|
||||
onClick={onToggle}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TagItem({
|
||||
kw,
|
||||
isSelected,
|
||||
node,
|
||||
selectedKeyword,
|
||||
expandedTags,
|
||||
isCollapsed,
|
||||
onTagSelect,
|
||||
totalCount,
|
||||
unreadCount,
|
||||
onToggleExpand,
|
||||
tagCounts,
|
||||
colorful,
|
||||
}: {
|
||||
kw: KeywordDefinition;
|
||||
isSelected: boolean;
|
||||
node: KeywordNode;
|
||||
selectedKeyword: string | null;
|
||||
expandedTags: Set<string>;
|
||||
isCollapsed: boolean;
|
||||
onTagSelect?: (keywordId: string | null) => void;
|
||||
totalCount: number;
|
||||
unreadCount: number;
|
||||
onToggleExpand: (keywordId: string) => void;
|
||||
tagCounts: Record<string, { total: number; unread: number }>;
|
||||
colorful: boolean;
|
||||
}) {
|
||||
const t = useTranslations('notifications');
|
||||
const palette = KEYWORD_PALETTE[kw.color];
|
||||
const { tagNameCandidates, tagColor } = useKeywordFormat();
|
||||
const palette = tagColor(node.id);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedTags.has(node.id);
|
||||
const isSelected = selectedKeyword === node.id;
|
||||
// Nested rows are placed by their indentation, so they show their own name.
|
||||
// A root spells out its path, which matters when an intermediate tag is
|
||||
// missing from this client's settings and the row would otherwise read as a
|
||||
// bare leaf name.
|
||||
const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label];
|
||||
const label = labelCandidates[0];
|
||||
// Toasts have the room for the whole thing, and no indentation to lean on,
|
||||
// so they always spell out the full path - otherwise two leaves with the
|
||||
// same name in different branches (e.g. "Personal/Receipts" and
|
||||
// "Work/Receipts") would read as the same tag.
|
||||
const fullLabel = tagNameCandidates(node.id)[0];
|
||||
const { isDragging: globalDragging } = useDragDropContext();
|
||||
const { dropHandlers, isValidDropTarget } = useTagDrop({
|
||||
tagId: kw.id,
|
||||
onSuccess: (count, _tagLabel) => {
|
||||
tagId: node.id,
|
||||
onSuccess: (count) => {
|
||||
if (count === 1) {
|
||||
toast.success(t('email_tagged'), kw.label);
|
||||
toast.success(t('email_tagged'), fullLabel);
|
||||
} else {
|
||||
toast.success(t('emails_tagged', { count }), kw.label);
|
||||
toast.success(t('emails_tagged', { count }), fullLabel);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('tag_failed'), kw.label);
|
||||
toast.error(t('tag_failed'), fullLabel);
|
||||
},
|
||||
});
|
||||
|
||||
const tagIcon = colorful ? (
|
||||
<Tag
|
||||
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[kw.color] || "text-muted-foreground")}
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Tag className={cn("w-4 h-4 flex-shrink-0", palette.icon)} fill="currentColor" />
|
||||
) : (
|
||||
<span className={cn("w-3 h-3 rounded-full", palette?.dot || "bg-gray-400")} />
|
||||
<span className={cn("w-3 h-3 rounded-full", palette.dot)} />
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarRow
|
||||
icon={tagIcon}
|
||||
label={kw.label}
|
||||
depth={0}
|
||||
isSelected={isSelected}
|
||||
unread={unreadCount}
|
||||
total={totalCount}
|
||||
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
|
||||
isCollapsed={isCollapsed}
|
||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
||||
isValidDropTarget={isValidDropTarget}
|
||||
/>
|
||||
<>
|
||||
<SidebarRow
|
||||
icon={tagIcon}
|
||||
label={label}
|
||||
labelCandidates={labelCandidates}
|
||||
depth={node.depth}
|
||||
isSelected={isSelected}
|
||||
unread={tagCounts[node.id]?.unread ?? 0}
|
||||
total={tagCounts[node.id]?.total ?? 0}
|
||||
onClick={() => onTagSelect?.(isSelected ? null : node.id)}
|
||||
hasChildren={hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
onExpandToggle={() => onToggleExpand(node.id)}
|
||||
isCollapsed={isCollapsed}
|
||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
||||
isValidDropTarget={isValidDropTarget}
|
||||
/>
|
||||
|
||||
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
||||
<TagItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
selectedKeyword={selectedKeyword}
|
||||
expandedTags={expandedTags}
|
||||
isCollapsed={isCollapsed}
|
||||
onTagSelect={onTagSelect}
|
||||
onToggleExpand={onToggleExpand}
|
||||
tagCounts={tagCounts}
|
||||
colorful={colorful}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -720,6 +780,7 @@ export function Sidebar({
|
||||
onDeleteFolder,
|
||||
onImportEmail,
|
||||
onRefreshMailboxes,
|
||||
onShareFolder,
|
||||
scheduledTotal = 0,
|
||||
showScheduledMailbox = false,
|
||||
crossAccountActive = false,
|
||||
@@ -737,6 +798,8 @@ export function Sidebar({
|
||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||
const [showAllTags, setShowAllTags] = useState(false);
|
||||
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
||||
@@ -749,6 +812,12 @@ export function Sidebar({
|
||||
return stored !== null ? JSON.parse(stored) : true;
|
||||
} catch { return true; }
|
||||
});
|
||||
const [calendarDashletExpanded, setCalendarDashletExpanded] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebarCalendarDashletExpanded');
|
||||
return stored !== null ? JSON.parse(stored) : true;
|
||||
} catch { return true; }
|
||||
});
|
||||
const [unifiedExpanded, setUnifiedExpanded] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebarUnifiedExpanded');
|
||||
@@ -779,7 +848,9 @@ export function Sidebar({
|
||||
return new Set();
|
||||
});
|
||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||
const nestedTags = useSettingsStore(s => s.nestedTags);
|
||||
const isEmbedded = useIsEmbedded();
|
||||
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
|
||||
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
|
||||
// own AccountSwitcher would be a redundant second account UI in the same
|
||||
// pane.
|
||||
@@ -842,6 +913,37 @@ export function Sidebar({
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('expandedTags');
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
setExpandedTags(new Set(parsed));
|
||||
} catch (e) {
|
||||
debug.error('Failed to parse expanded tags:', e);
|
||||
}
|
||||
} else {
|
||||
setExpandedTags(
|
||||
new Set(emailKeywords.filter((kw) => hasChildKeywords(kw.id, emailKeywords)).map((kw) => kw.id))
|
||||
);
|
||||
}
|
||||
}, [emailKeywords]);
|
||||
|
||||
const handleToggleTagExpand = (keywordId: string) => {
|
||||
setExpandedTags((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(keywordId)) {
|
||||
next.delete(keywordId);
|
||||
} else {
|
||||
next.add(keywordId);
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('expandedTags', JSON.stringify(Array.from(next)));
|
||||
} catch { /* storage full or unavailable */ }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// When the app renders its own virtual "Scheduled" folder (for delayed
|
||||
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
||||
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
|
||||
@@ -852,6 +954,29 @@ export function Sidebar({
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
|
||||
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
|
||||
|
||||
// With nesting off every tag is its own root, so the same rows render through
|
||||
// one path whether or not the ids describe a hierarchy.
|
||||
const tagTree: KeywordNode[] = nestedTags
|
||||
? buildKeywordTree(emailKeywords)
|
||||
: emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 }));
|
||||
|
||||
// Counts arrive from a separate JMAP round trip, one batch per group of tags;
|
||||
// a tag with no count yet is treated as visible rather than blanking it and
|
||||
// filling it back in. A tag the server answered for with zero unread hides,
|
||||
// which is the point of the setting.
|
||||
const isTagVisible = (node: KeywordNode) => {
|
||||
if (showAllTags || node.id === selectedKeyword) return true;
|
||||
const visibility = getKeywordVisibility(node);
|
||||
if (visibility === 'hide') return false;
|
||||
if (visibility === 'unread') {
|
||||
const count = tagCounts[node.id];
|
||||
return !count || count.unread > 0;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const visibleTagTree = filterKeywordTree(tagTree, isTagVisible);
|
||||
const hiddenTagCount = emailKeywords.length - countKeywordNodes(visibleTagTree);
|
||||
|
||||
// Multi-account mode (Pro shell): render every connected account as its
|
||||
// own collapsible group. The active account's tree comes from the
|
||||
// `mailboxes` prop (which is the live email-store value); other accounts
|
||||
@@ -940,6 +1065,13 @@ export function Sidebar({
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleCalendarDashlet = () => {
|
||||
setCalendarDashletExpanded((prev: boolean) => {
|
||||
const next = !prev;
|
||||
try { localStorage.setItem('sidebarCalendarDashletExpanded', JSON.stringify(next)); } catch { /* */ }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleShared = () => {
|
||||
setSharedExpanded((prev: boolean) => {
|
||||
const next = !prev;
|
||||
@@ -1265,23 +1397,44 @@ export function Sidebar({
|
||||
/>
|
||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{emailKeywords.map((kw) => (
|
||||
{visibleTagTree.map((node) => (
|
||||
<TagItem
|
||||
key={kw.id}
|
||||
kw={kw}
|
||||
isSelected={selectedKeyword === kw.id}
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedKeyword={selectedKeyword}
|
||||
expandedTags={expandedTags}
|
||||
isCollapsed={isCollapsed}
|
||||
onTagSelect={onTagSelect}
|
||||
totalCount={tagCounts[kw.id]?.total ?? 0}
|
||||
unreadCount={tagCounts[kw.id]?.unread ?? 0}
|
||||
onToggleExpand={handleToggleTagExpand}
|
||||
tagCounts={tagCounts}
|
||||
colorful={colorfulSidebarIcons}
|
||||
/>
|
||||
))}
|
||||
{(hiddenTagCount > 0 || showAllTags) && (
|
||||
<ShowAllTagsRow
|
||||
hiddenCount={hiddenTagCount}
|
||||
showAll={showAllTags}
|
||||
onToggle={() => setShowAllTags((prev) => !prev)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && calendarEnabled && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("calendar")}
|
||||
expanded={calendarDashletExpanded}
|
||||
onToggle={toggleCalendarDashlet}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{calendarDashletExpanded && <MiniCalendarDashlet />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
|
||||
</div>
|
||||
|
||||
@@ -1301,6 +1454,7 @@ export function Sidebar({
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onImportEmail={onImportEmail}
|
||||
onShareFolder={onShareFolder}
|
||||
onRefresh={onRefreshMailboxes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,12 +7,16 @@ import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { findDraftIdentityId } from "@/lib/reply-identity";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -54,8 +58,8 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
||||
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
|
||||
const identities = useIdentityStore((s) => s.identities);
|
||||
const multiAccountIdentities = useProMultiAccountIdentities();
|
||||
|
||||
const closeTab = useProTabStore((s) => s.closeTab);
|
||||
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||
@@ -133,6 +137,50 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
// Mirrors handleForward, but attaches the original as a message/rfc822
|
||||
// file instead of quoting it inline - see lib/forward-as-attachment.ts.
|
||||
// This is a separate, self-contained render path from the main Mail
|
||||
// tab's EmailViewer (page.tsx) - Pro tabs fetch their own `email` and
|
||||
// open compose tabs directly via useProTabStore, not through
|
||||
// page.tsx's pendingDraft/selectedEmail plumbing - so it needed its own
|
||||
// wiring rather than falling out of the page.tsx fix automatically.
|
||||
const handleForwardAsAttachment = useCallback(() => {
|
||||
if (!email) return;
|
||||
const {
|
||||
emailDownloadTemplate,
|
||||
filenameSpaceReplacement,
|
||||
filenameLowercase,
|
||||
filenameStripDiacritics,
|
||||
filenameCollapseSeparators,
|
||||
} = useSettingsStore.getState();
|
||||
const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), {
|
||||
template: emailDownloadTemplate,
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
});
|
||||
if (!payload) return;
|
||||
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
sessionId: composerSessionIdRef.current,
|
||||
mode: 'forward',
|
||||
replyTo: {
|
||||
subject: email.subject,
|
||||
attachments: [payload.attachment],
|
||||
},
|
||||
sourceEmailId: email.id,
|
||||
// payload.subject is intentionally blank for a subject-less email (to
|
||||
// match normal Forward's *composer* subject behavior - see
|
||||
// buildForwardAsAttachmentPayload). The Pro tab *title* is a separate
|
||||
// UI label that still needs a sensible fallback, same as handleForward
|
||||
// above uses - reusing payload.subject here would give the tab an
|
||||
// empty title instead of e.g. "Fwd: New message".
|
||||
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
|
||||
});
|
||||
}, [email, openComposeTab, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
@@ -188,21 +236,31 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
}
|
||||
}, [client, markAsRead]);
|
||||
|
||||
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
|
||||
const handleSetTag = useCallback((emailId: string, tagId: string | null) => {
|
||||
if (!email || email.id !== emailId) return;
|
||||
// Drop existing color keywords, optionally add the new one. Matches the
|
||||
// mail page's local optimistic update.
|
||||
// Toggle one tag, or clear them all. Matches the mail page's local
|
||||
// optimistic update, down to reaching tags this client cannot name.
|
||||
const keywords = { ...(email.keywords ?? {}) };
|
||||
for (const kw of settingsKeywords) {
|
||||
delete keywords[`$label:${kw.id}`];
|
||||
}
|
||||
if (color) {
|
||||
const def = settingsKeywords.find((k) => k.color === color);
|
||||
if (def) keywords[`$label:${def.id}`] = true;
|
||||
if (tagId === null) {
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
|
||||
.filter(key => keywords[key]);
|
||||
if (activeKeys.length > 0) {
|
||||
for (const key of activeKeys) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
} else {
|
||||
keywords[KEYWORD_PREFIX + tagId] = true;
|
||||
}
|
||||
}
|
||||
setEmailKeywordsLocal(emailId, keywords);
|
||||
setEmail({ ...email, keywords });
|
||||
}, [email, settingsKeywords, setEmailKeywordsLocal]);
|
||||
}, [email, setEmailKeywordsLocal]);
|
||||
|
||||
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
||||
if (!client || !email) return;
|
||||
@@ -242,11 +300,12 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
? email.bodyValues[draftHtmlPart.partId].value
|
||||
: undefined;
|
||||
|
||||
// Preserve the identity that matches the draft's From address.
|
||||
const draftFromEmail = email.from?.[0]?.email;
|
||||
const matchedIdentity = draftFromEmail
|
||||
? identities.find((id) => id.email === draftFromEmail)
|
||||
: null;
|
||||
// Preserve the identity the draft was composed with — match name + address
|
||||
// against the same list the composer renders (see findDraftIdentityId).
|
||||
const composerIdentities = multiAccountIdentities.enabled
|
||||
? multiAccountIdentities.allIdentities
|
||||
: identities;
|
||||
const matchedIdentityId = findDraftIdentityId(composerIdentities, email.from?.[0]);
|
||||
|
||||
composerSessionIdRef.current += 1;
|
||||
openComposeTab({
|
||||
@@ -261,14 +320,14 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
body: htmlBody || bodyText,
|
||||
showCc: (email.cc?.length || 0) > 0,
|
||||
showBcc: (email.bcc?.length || 0) > 0,
|
||||
selectedIdentityId: matchedIdentity?.id ?? null,
|
||||
selectedIdentityId: matchedIdentityId,
|
||||
subAddressTag: '',
|
||||
mode: 'compose',
|
||||
draftId: email.id,
|
||||
},
|
||||
});
|
||||
closeTab(tabId);
|
||||
}, [email, identities, openComposeTab, closeTab, tabId, t]);
|
||||
}, [email, identities, multiAccountIdentities, openComposeTab, closeTab, tabId, t]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
@@ -279,11 +338,12 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onForwardAsAttachment={handleForwardAsAttachment}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onSetTag={handleSetTag}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
onEditDraft={handleEditDraft}
|
||||
|
||||
@@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { KeywordSettings } from '../keyword-settings';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
|
||||
// Mock SettingsSection to just render children
|
||||
vi.mock('../settings-section', () => ({
|
||||
// Mock SettingsSection to just render children, keeping the real controls
|
||||
vi.mock('../settings-section', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../settings-section')>()),
|
||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
describe('KeywordSettings', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
|
||||
});
|
||||
|
||||
it('renders all default keywords', () => {
|
||||
@@ -31,11 +32,6 @@ describe('KeywordSettings', () => {
|
||||
expect(screen.getByText('add_keyword')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders reset defaults button', () => {
|
||||
render(<KeywordSettings />);
|
||||
expect(screen.getByText('reset_defaults')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows add form when add button clicked', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
@@ -114,18 +110,6 @@ describe('KeywordSettings', () => {
|
||||
expect(kw?.label).toBe('Crimson');
|
||||
});
|
||||
|
||||
it('resets to defaults when reset button clicked', () => {
|
||||
// Modify keywords first
|
||||
useSettingsStore.getState().removeKeyword('red');
|
||||
useSettingsStore.getState().removeKeyword('blue');
|
||||
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 2);
|
||||
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('reset_defaults'));
|
||||
|
||||
expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS);
|
||||
});
|
||||
|
||||
it('normalizes label to id correctly', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
@@ -139,4 +123,90 @@ describe('KeywordSettings', () => {
|
||||
expect(added.id).toBe('my-custom-tag');
|
||||
expect(added.label).toBe('My Custom Tag!');
|
||||
});
|
||||
|
||||
it('offers no parent picker while nesting is off', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
|
||||
expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('nests a new tag under the selected parent', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
|
||||
nestedTags: true,
|
||||
});
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } });
|
||||
fireEvent.click(screen.getByText('add'));
|
||||
|
||||
const keywords = useSettingsStore.getState().emailKeywords;
|
||||
expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' });
|
||||
});
|
||||
|
||||
it('shows nested tags by their full path', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
],
|
||||
nestedTags: true,
|
||||
});
|
||||
render(<KeywordSettings />);
|
||||
|
||||
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
|
||||
expect(screen.getByText('$label:work/clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects a path that would exceed the keyword length limit', () => {
|
||||
const deepId = 'a'.repeat(240);
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }],
|
||||
nestedTags: true,
|
||||
});
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } });
|
||||
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } });
|
||||
|
||||
expect(screen.getByText('too_long')).toBeInTheDocument();
|
||||
expect(screen.getByText('add').closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('locks the name and the delete action of a tag that has nested tags', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
],
|
||||
nestedTags: true,
|
||||
});
|
||||
render(<KeywordSettings />);
|
||||
|
||||
expect(screen.getByTitle('has_children_delete')).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getAllByTitle('edit')[0]);
|
||||
expect(screen.getByDisplayValue('Work')).toBeDisabled();
|
||||
expect(screen.getByText('has_children_locked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults every tag to always visible in the sidebar', () => {
|
||||
render(<KeywordSettings />);
|
||||
|
||||
const pickers = screen.getAllByLabelText('visibility_field');
|
||||
expect(pickers).toHaveLength(DEFAULT_KEYWORDS.length);
|
||||
pickers.forEach((picker) => expect(picker).toHaveValue('show'));
|
||||
});
|
||||
|
||||
it('stores the visibility chosen for a tag', () => {
|
||||
render(<KeywordSettings />);
|
||||
|
||||
fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } });
|
||||
|
||||
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread');
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user